From 7d84888dacdc13cc240d2a6abf58916ad47338f3 Mon Sep 17 00:00:00 2001 From: C5388932 Date: Wed, 10 Jun 2026 15:04:25 +0200 Subject: [PATCH 1/8] fix: dispatch CI workflows for generated changelog PRs --- action.yml | 4 ++ scripts/create-pr.sh | 61 +++++++++++++++++++ scripts/entrypoint.sh | 3 + .../scripts/update-changelog-script.test.ts | 2 +- 4 files changed, 69 insertions(+), 1 deletion(-) diff --git a/action.yml b/action.yml index 91239c5..f3fba00 100644 --- a/action.yml +++ b/action.yml @@ -38,6 +38,10 @@ inputs: description: "Explicit version for releasing (overwrites package.json)" required: false default: "" + ci-workflows: + description: "Comma-separated workflow file names to dispatch after creating a changelog PR, e.g. ci.yml,dummy-ci.yml" + required: false + default: "" runs: using: "docker" diff --git a/scripts/create-pr.sh b/scripts/create-pr.sh index acb7fab..f43b943 100644 --- a/scripts/create-pr.sh +++ b/scripts/create-pr.sh @@ -5,6 +5,61 @@ set -e # Stop the script if any command fails CHANGELOG_FILE_PATH="${CHANGELOG_FILE_PATH:-CHANGELOG.md}" TEMP_DIR=$(mktemp -d) +dispatch_configured_ci_workflows() { + branch_ref="$1" + + if [ -z "$CI_WORKFLOWS" ]; then + echo "No CI workflows configured for dispatch." + return 0 + fi + + if [ -z "$GITHUB_TOKEN" ]; then + echo "::error::GITHUB_TOKEN is required to dispatch CI workflows." + exit 1 + fi + + echo "Dispatching configured CI workflows for branch: $branch_ref" + + old_ifs="$IFS" + IFS="," + + for workflow_file in $CI_WORKFLOWS; do + workflow_file=$(printf '%s' "$workflow_file" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//') + + if [ -z "$workflow_file" ]; then + continue + fi + + echo "Dispatching workflow: $workflow_file" + + dispatch_payload=$(jq -n --arg ref "$branch_ref" '{ref: $ref}') + dispatch_response_file=$(mktemp) + + dispatch_http_code=$(curl -sS -o "$dispatch_response_file" -w "%{http_code}" -X POST \ + -H "Authorization: Bearer $GITHUB_TOKEN" \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + -d "$dispatch_payload" \ + "$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/actions/workflows/$workflow_file/dispatches" || true) + + dispatch_response=$(cat "$dispatch_response_file" || true) + rm -f "$dispatch_response_file" + + case "$dispatch_http_code" in + 200|201|202|204) + echo "Workflow dispatched successfully: $workflow_file" + ;; + *) + echo "::error::Failed to dispatch workflow '$workflow_file' for branch '$branch_ref' (HTTP $dispatch_http_code): $dispatch_response" + IFS="$old_ifs" + exit 1 + ;; + esac + done + + IFS="$old_ifs" +} + if [ "$CHANGELOG_UPDATED" != "true" ]; then echo "Changelog was not updated. Skipping branch creation and pull request." return 0 @@ -81,6 +136,12 @@ export PR_URL="$pr_url" cd "$GITHUB_WORKSPACE" rm -rf "$TEMP_DIR" +if [ "$DRY_RUN" = "true" ]; then + echo "Dry-Run: Skipping CI workflow dispatch." +else + dispatch_configured_ci_workflows "$branch_name" +fi + # Notify the user about the created PR echo "::notice::A pull request has been created for the changelog update." echo "::notice::PR URL: $PR_URL" diff --git a/scripts/entrypoint.sh b/scripts/entrypoint.sh index 9839535..6fbc3cd 100644 --- a/scripts/entrypoint.sh +++ b/scripts/entrypoint.sh @@ -30,6 +30,9 @@ echo "RELEASE_TITLE_PREFIX=$(printenv INPUT_RELEASE-TITLE-PREFIX)" | tee -a "$GI export VERSION_OVERRIDE="$(printenv INPUT_VERSION)" echo "VERSION_OVERRIDE=$VERSION_OVERRIDE" | tee -a "$GITHUB_ENV" +export CI_WORKFLOWS="$(printenv INPUT_CI-WORKFLOWS)" +echo "CI_WORKFLOWS=$CI_WORKFLOWS" | tee -a "$GITHUB_ENV" + # Import scripts instead of executing them with sh . /app/scripts/setup-release.sh . /app/scripts/collect-commits.sh diff --git a/src/__tests__/scripts/update-changelog-script.test.ts b/src/__tests__/scripts/update-changelog-script.test.ts index f4ee1ef..52e7c2b 100644 --- a/src/__tests__/scripts/update-changelog-script.test.ts +++ b/src/__tests__/scripts/update-changelog-script.test.ts @@ -92,7 +92,7 @@ exit 1 expect(releaseBody).toContain("### Added"); expect(releaseBody).toContain("- Existing changelog entry"); expect(releaseBody).toContain("------"); - expect(releaseBody).toContain("## What's Changed (commits)"); + expect(releaseBody).toContain("## What's Changed"); expect(releaseBody).toContain( "* Existing changelog entry by @alice in [1111111](https://github.com/open-resource-discovery/github-release/commit/sha-one)", ); From 38eea1e47846ca2c92e5ae21b7bbc6f9b0f3b9d9 Mon Sep 17 00:00:00 2001 From: C5388932 Date: Sat, 13 Jun 2026 16:38:49 +0200 Subject: [PATCH 2/8] feat: auto-dispatch CI workflows for changelog PRs --- CHANGELOG.md | 8 +++++ action.yml | 4 +-- scripts/create-pr.sh | 82 +++++++++++++++++++++++++++++++++---------- scripts/entrypoint.sh | 5 +++ 4 files changed, 78 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d4ba24..40498ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1 +1,9 @@ ## [unreleased] + +### Added + +- Added automated GitHub release creation. +- Added changelog-based release notes. +- Added configurable tag templates and release metadata. +- Added contributor detection and GitHub-native mentions. +- Added optional CI workflow dispatch for generated changelog PRs. \ No newline at end of file diff --git a/action.yml b/action.yml index f3fba00..f34e25f 100644 --- a/action.yml +++ b/action.yml @@ -39,9 +39,9 @@ inputs: required: false default: "" ci-workflows: - description: "Comma-separated workflow file names to dispatch after creating a changelog PR, e.g. ci.yml,dummy-ci.yml" + description: "Comma-separated workflow file names to dispatch after creating a changelog PR. Use 'auto' to dispatch all pull_request + workflow_dispatch workflows except the release workflow." required: false - default: "" + default: "auto" runs: using: "docker" diff --git a/scripts/create-pr.sh b/scripts/create-pr.sh index f43b943..ba18f04 100644 --- a/scripts/create-pr.sh +++ b/scripts/create-pr.sh @@ -5,30 +5,74 @@ set -e # Stop the script if any command fails CHANGELOG_FILE_PATH="${CHANGELOG_FILE_PATH:-CHANGELOG.md}" TEMP_DIR=$(mktemp -d) -dispatch_configured_ci_workflows() { - branch_ref="$1" +get_current_release_workflow_path() { + printf '%s\n' "$GITHUB_WORKFLOW_REF" | sed -n 's#^[^/]*/[^/]*/\(.github/workflows/[^@]*\)@.*#\1#p' +} + +workflow_supports_auto_dispatch() { + workflow_path="$1" + + workflow_content=$(grep -Ev '^[[:space:]]*#' "$workflow_path" || true) + + printf '%s\n' "$workflow_content" | grep -Eq 'workflow_dispatch' && \ + printf '%s\n' "$workflow_content" | grep -Eq 'pull_request' +} + +resolve_ci_workflows() { + if [ -z "$CI_WORKFLOWS" ] || [ "$CI_WORKFLOWS" = "auto" ]; then + current_release_workflow_path="$(get_current_release_workflow_path)" + + for workflow_path in .github/workflows/*.yml .github/workflows/*.yaml; do + [ -f "$workflow_path" ] || continue + + if [ -n "$current_release_workflow_path" ] && [ "$workflow_path" = "$current_release_workflow_path" ]; then + echo "Skipping release workflow itself: $workflow_path" >&2 + continue + fi + + if workflow_supports_auto_dispatch "$workflow_path"; then + basename "$workflow_path" + fi + done | sort -u + + return 0 + fi - if [ -z "$CI_WORKFLOWS" ]; then - echo "No CI workflows configured for dispatch." + if [ "$CI_WORKFLOWS" = "none" ] || [ "$CI_WORKFLOWS" = "false" ]; then return 0 fi + printf '%s' "$CI_WORKFLOWS" \ + | tr ',' '\n' \ + | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' \ + | sed '/^$/d' \ + | while IFS= read -r workflow_file; do + basename "$workflow_file" + done \ + | sort -u +} + +dispatch_configured_ci_workflows() { + branch_ref="$1" + if [ -z "$GITHUB_TOKEN" ]; then echo "::error::GITHUB_TOKEN is required to dispatch CI workflows." exit 1 fi - echo "Dispatching configured CI workflows for branch: $branch_ref" + workflows_file=$(mktemp) + resolve_ci_workflows > "$workflows_file" - old_ifs="$IFS" - IFS="," + if [ ! -s "$workflows_file" ]; then + echo "No CI workflows configured or discovered for dispatch." + rm -f "$workflows_file" + return 0 + fi - for workflow_file in $CI_WORKFLOWS; do - workflow_file=$(printf '%s' "$workflow_file" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//') + echo "Dispatching CI workflows for branch: $branch_ref" - if [ -z "$workflow_file" ]; then - continue - fi + while IFS= read -r workflow_file; do + [ -z "$workflow_file" ] && continue echo "Dispatching workflow: $workflow_file" @@ -40,7 +84,7 @@ dispatch_configured_ci_workflows() { -H "Accept: application/vnd.github+json" \ -H "X-GitHub-Api-Version: 2022-11-28" \ -d "$dispatch_payload" \ - "$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/actions/workflows/$workflow_file/dispatches" || true) + "$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/actions/workflows/$workflow_file/dispatches" || printf "000") dispatch_response=$(cat "$dispatch_response_file" || true) rm -f "$dispatch_response_file" @@ -51,13 +95,13 @@ dispatch_configured_ci_workflows() { ;; *) echo "::error::Failed to dispatch workflow '$workflow_file' for branch '$branch_ref' (HTTP $dispatch_http_code): $dispatch_response" - IFS="$old_ifs" + rm -f "$workflows_file" exit 1 ;; esac - done + done < "$workflows_file" - IFS="$old_ifs" + rm -f "$workflows_file" } if [ "$CHANGELOG_UPDATED" != "true" ]; then @@ -133,15 +177,15 @@ fi echo "PR_URL=$pr_url" | tee -a $GITHUB_ENV export PR_URL="$pr_url" -cd "$GITHUB_WORKSPACE" -rm -rf "$TEMP_DIR" - if [ "$DRY_RUN" = "true" ]; then echo "Dry-Run: Skipping CI workflow dispatch." else dispatch_configured_ci_workflows "$branch_name" fi +cd "$GITHUB_WORKSPACE" +rm -rf "$TEMP_DIR" + # Notify the user about the created PR echo "::notice::A pull request has been created for the changelog update." echo "::notice::PR URL: $PR_URL" diff --git a/scripts/entrypoint.sh b/scripts/entrypoint.sh index 6fbc3cd..7d5269e 100644 --- a/scripts/entrypoint.sh +++ b/scripts/entrypoint.sh @@ -31,6 +31,11 @@ export VERSION_OVERRIDE="$(printenv INPUT_VERSION)" echo "VERSION_OVERRIDE=$VERSION_OVERRIDE" | tee -a "$GITHUB_ENV" export CI_WORKFLOWS="$(printenv INPUT_CI-WORKFLOWS)" + +if [ -z "$CI_WORKFLOWS" ]; then + CI_WORKFLOWS="auto" +fi + echo "CI_WORKFLOWS=$CI_WORKFLOWS" | tee -a "$GITHUB_ENV" # Import scripts instead of executing them with sh From 19203505b18ebc0b4aa521b635844a0e6864847f Mon Sep 17 00:00:00 2001 From: C5388932 Date: Sat, 13 Jun 2026 17:01:00 +0200 Subject: [PATCH 3/8] fix: default CI workflow dispatch to auto discovery --- scripts/create-pr.sh | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/scripts/create-pr.sh b/scripts/create-pr.sh index ba18f04..5587ea3 100644 --- a/scripts/create-pr.sh +++ b/scripts/create-pr.sh @@ -14,8 +14,8 @@ workflow_supports_auto_dispatch() { workflow_content=$(grep -Ev '^[[:space:]]*#' "$workflow_path" || true) - printf '%s\n' "$workflow_content" | grep -Eq 'workflow_dispatch' && \ - printf '%s\n' "$workflow_content" | grep -Eq 'pull_request' + printf '%s\n' "$workflow_content" | grep -Eq '(^|[^_[:alnum:]-])workflow_dispatch([^_[:alnum:]-]|$)' && \ + printf '%s\n' "$workflow_content" | grep -Eq '(^|[^_[:alnum:]-])pull_request([^_[:alnum:]-]|$)' } resolve_ci_workflows() { @@ -79,12 +79,14 @@ dispatch_configured_ci_workflows() { dispatch_payload=$(jq -n --arg ref "$branch_ref" '{ref: $ref}') dispatch_response_file=$(mktemp) - dispatch_http_code=$(curl -sS -o "$dispatch_response_file" -w "%{http_code}" -X POST \ + if ! dispatch_http_code=$(curl -sS -o "$dispatch_response_file" -w "%{http_code}" -X POST \ -H "Authorization: Bearer $GITHUB_TOKEN" \ -H "Accept: application/vnd.github+json" \ -H "X-GitHub-Api-Version: 2022-11-28" \ -d "$dispatch_payload" \ - "$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/actions/workflows/$workflow_file/dispatches" || printf "000") + "$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/actions/workflows/$workflow_file/dispatches"); then + dispatch_http_code="000" + fi dispatch_response=$(cat "$dispatch_response_file" || true) rm -f "$dispatch_response_file" From ec1f3cde6a439906490f8a9150108a1c3b1e74df Mon Sep 17 00:00:00 2001 From: C5388932 Date: Mon, 15 Jun 2026 10:09:00 +0200 Subject: [PATCH 4/8] fix: mirror dispatched CI results as PR commit statuses --- CHANGELOG.md | 2 +- scripts/create-pr.sh | 221 ++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 221 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 40498ee..87ce0bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,4 +6,4 @@ - Added changelog-based release notes. - Added configurable tag templates and release metadata. - Added contributor detection and GitHub-native mentions. -- Added optional CI workflow dispatch for generated changelog PRs. \ No newline at end of file +- Added optional CI workflow dispatch for generated changelog PRs. diff --git a/scripts/create-pr.sh b/scripts/create-pr.sh index 5587ea3..48c0727 100644 --- a/scripts/create-pr.sh +++ b/scripts/create-pr.sh @@ -5,6 +5,203 @@ set -e # Stop the script if any command fails CHANGELOG_FILE_PATH="${CHANGELOG_FILE_PATH:-CHANGELOG.md}" TEMP_DIR=$(mktemp -d) +urlencode() { + jq -nr --arg value "$1" '$value|@uri' +} + +map_workflow_conclusion_to_status_state() { + conclusion="$1" + + case "$conclusion" in + success|skipped|neutral) + echo "success" + ;; + failure|cancelled|timed_out|action_required) + echo "failure" + ;; + *) + echo "error" + ;; + esac +} + +create_commit_status() { + sha="$1" + context="$2" + state="$3" + target_url="$4" + description="$5" + + status_payload=$(jq -n \ + --arg state "$state" \ + --arg context "$context" \ + --arg target_url "$target_url" \ + --arg description "$description" \ + '{ + state: $state, + context: $context, + target_url: $target_url, + description: $description + }') + + status_response_file=$(mktemp) + + if ! status_http_code=$(curl -sS -o "$status_response_file" -w "%{http_code}" -X POST \ + -H "Authorization: Bearer $GITHUB_TOKEN" \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + -d "$status_payload" \ + "$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/statuses/$sha"); then + status_http_code="000" + fi + + status_response=$(cat "$status_response_file" || true) + rm -f "$status_response_file" + + if [ "$status_http_code" != "201" ]; then + echo "::error::Failed to create commit status '$context' for $sha (HTTP $status_http_code): $status_response" + exit 1 + fi + + echo "Created commit status '$context' with state '$state' for $sha." +} + +find_dispatched_workflow_run_id() { + workflow_file="$1" + branch_ref="$2" + head_sha="$3" + + encoded_branch_ref=$(urlencode "$branch_ref") + attempt=0 + + while [ "$attempt" -lt 60 ]; do + runs_response_file=$(mktemp) + + if ! runs_http_code=$(curl -sS -o "$runs_response_file" -w "%{http_code}" \ + -H "Authorization: Bearer $GITHUB_TOKEN" \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + "$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/actions/workflows/$workflow_file/runs?branch=$encoded_branch_ref&event=workflow_dispatch&per_page=10"); then + runs_http_code="000" + fi + + runs_response=$(cat "$runs_response_file" || true) + rm -f "$runs_response_file" + + if [ "$runs_http_code" = "200" ] && printf '%s\n' "$runs_response" | jq empty > /dev/null 2>&1; then + run_id=$(printf '%s\n' "$runs_response" | jq -r --arg head_sha "$head_sha" ' + .workflow_runs[] + | select(.head_sha == $head_sha) + | .id + ' | head -n 1) + + if [ -n "$run_id" ] && [ "$run_id" != "null" ]; then + echo "$run_id" + return 0 + fi + fi + + attempt=$((attempt + 1)) + sleep 5 + done + + return 1 +} + +wait_for_workflow_run_completion() { + run_id="$1" + + attempt=0 + + while [ "$attempt" -lt 120 ]; do + run_response_file=$(mktemp) + + if ! run_http_code=$(curl -sS -o "$run_response_file" -w "%{http_code}" \ + -H "Authorization: Bearer $GITHUB_TOKEN" \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + "$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/actions/runs/$run_id"); then + run_http_code="000" + fi + + run_response=$(cat "$run_response_file" || true) + rm -f "$run_response_file" + + if [ "$run_http_code" = "200" ] && printf '%s\n' "$run_response" | jq empty > /dev/null 2>&1; then + run_status=$(printf '%s\n' "$run_response" | jq -r '.status // empty') + run_conclusion=$(printf '%s\n' "$run_response" | jq -r '.conclusion // empty') + + echo "Workflow run $run_id status: $run_status conclusion: ${run_conclusion:-none}" + + if [ "$run_status" = "completed" ]; then + return 0 + fi + fi + + attempt=$((attempt + 1)) + sleep 10 + done + + echo "::error::Timed out waiting for workflow run $run_id to complete." + exit 1 +} + +mirror_workflow_jobs_as_commit_statuses() { + run_id="$1" + head_sha="$2" + + jobs_response_file=$(mktemp) + + if ! jobs_http_code=$(curl -sS -o "$jobs_response_file" -w "%{http_code}" \ + -H "Authorization: Bearer $GITHUB_TOKEN" \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + "$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/actions/runs/$run_id/jobs?per_page=100"); then + jobs_http_code="000" + fi + + jobs_response=$(cat "$jobs_response_file" || true) + rm -f "$jobs_response_file" + + if [ "$jobs_http_code" != "200" ] || ! printf '%s\n' "$jobs_response" | jq empty > /dev/null 2>&1; then + echo "::error::Failed to read jobs for workflow run $run_id (HTTP $jobs_http_code): $jobs_response" + exit 1 + fi + + if ! printf '%s\n' "$jobs_response" | jq -e '.jobs | length > 0' > /dev/null 2>&1; then + echo "::error::Workflow run $run_id has no jobs. Cannot mirror required status checks." + exit 1 + fi + + tab=$(printf '\t') + + printf '%s\n' "$jobs_response" | jq -r ' + .jobs[] + | [ + .name, + (.conclusion // "failure"), + (.html_url // "") + ] + | @tsv + ' | while IFS="$tab" read -r job_name job_conclusion job_url; do + [ -z "$job_name" ] && continue + + status_state=$(map_workflow_conclusion_to_status_state "$job_conclusion") + + create_commit_status \ + "$head_sha" \ + "$job_name" \ + "$status_state" \ + "$job_url" \ + "Mirrored result from dispatched workflow run $run_id" + + if [ "$status_state" != "success" ]; then + echo "::error::Dispatched CI job '$job_name' finished with conclusion '$job_conclusion'." + exit 1 + fi + done +} + get_current_release_workflow_path() { printf '%s\n' "$GITHUB_WORKFLOW_REF" | sed -n 's#^[^/]*/[^/]*/\(.github/workflows/[^@]*\)@.*#\1#p' } @@ -54,12 +251,18 @@ resolve_ci_workflows() { dispatch_configured_ci_workflows() { branch_ref="$1" + head_sha="$2" if [ -z "$GITHUB_TOKEN" ]; then echo "::error::GITHUB_TOKEN is required to dispatch CI workflows." exit 1 fi + if [ -z "$head_sha" ]; then + echo "::error::PR head SHA is required to mirror CI statuses." + exit 1 + fi + workflows_file=$(mktemp) resolve_ci_workflows > "$workflows_file" @@ -70,6 +273,7 @@ dispatch_configured_ci_workflows() { fi echo "Dispatching CI workflows for branch: $branch_ref" + echo "Mirroring CI job results to PR head SHA: $head_sha" while IFS= read -r workflow_file; do [ -z "$workflow_file" ] && continue @@ -101,6 +305,17 @@ dispatch_configured_ci_workflows() { exit 1 ;; esac + + run_id=$(find_dispatched_workflow_run_id "$workflow_file" "$branch_ref" "$head_sha") || { + echo "::error::Could not find dispatched workflow run for '$workflow_file' on branch '$branch_ref' and SHA '$head_sha'." + rm -f "$workflows_file" + exit 1 + } + + echo "Found dispatched workflow run: $run_id" + + wait_for_workflow_run_completion "$run_id" + mirror_workflow_jobs_as_commit_statuses "$run_id" "$head_sha" done < "$workflows_file" rm -f "$workflows_file" @@ -138,6 +353,10 @@ else git commit -m "chore: update changelog for version $VERSION" || echo "No changes to commit" fi +branch_head_sha=$(git rev-parse HEAD) +echo "CHANGELOG_PR_HEAD_SHA=$branch_head_sha" | tee -a "$GITHUB_ENV" +export CHANGELOG_PR_HEAD_SHA="$branch_head_sha" + if [ "$DRY_RUN" = "true" ]; then echo "Dry-Run: Skipping 'git push origin $branch_name'." else @@ -182,7 +401,7 @@ export PR_URL="$pr_url" if [ "$DRY_RUN" = "true" ]; then echo "Dry-Run: Skipping CI workflow dispatch." else - dispatch_configured_ci_workflows "$branch_name" + dispatch_configured_ci_workflows "$branch_name" "$CHANGELOG_PR_HEAD_SHA" fi cd "$GITHUB_WORKSPACE" From 0e16dbd5c5bda876f1505281e3436f0cc17ddd22 Mon Sep 17 00:00:00 2001 From: C5388932 Date: Mon, 15 Jun 2026 13:36:10 +0200 Subject: [PATCH 5/8] fix: mirror dispatched CI jobs as check runs --- scripts/create-pr.sh | 115 ++++++++++++++++++++++++------------------- 1 file changed, 64 insertions(+), 51 deletions(-) diff --git a/scripts/create-pr.sh b/scripts/create-pr.sh index 48c0727..64c2a36 100644 --- a/scripts/create-pr.sh +++ b/scripts/create-pr.sh @@ -9,61 +9,64 @@ urlencode() { jq -nr --arg value "$1" '$value|@uri' } -map_workflow_conclusion_to_status_state() { +map_job_conclusion_to_check_conclusion() { conclusion="$1" case "$conclusion" in - success|skipped|neutral) - echo "success" - ;; - failure|cancelled|timed_out|action_required) - echo "failure" + success|failure|neutral|cancelled|skipped|timed_out|action_required) + echo "$conclusion" ;; *) - echo "error" + echo "failure" ;; esac } -create_commit_status() { +create_check_run() { sha="$1" - context="$2" - state="$3" - target_url="$4" - description="$5" - - status_payload=$(jq -n \ - --arg state "$state" \ - --arg context "$context" \ - --arg target_url "$target_url" \ - --arg description "$description" \ + check_name="$2" + conclusion="$3" + details_url="$4" + summary="$5" + + check_payload=$(jq -n \ + --arg name "$check_name" \ + --arg head_sha "$sha" \ + --arg conclusion "$conclusion" \ + --arg details_url "$details_url" \ + --arg summary "$summary" \ '{ - state: $state, - context: $context, - target_url: $target_url, - description: $description - }') + name: $name, + head_sha: $head_sha, + status: "completed", + conclusion: $conclusion, + output: { + title: $name, + summary: $summary + } + } + | if $details_url == "" then . else . + {details_url: $details_url} end') - status_response_file=$(mktemp) + check_response_file=$(mktemp) - if ! status_http_code=$(curl -sS -o "$status_response_file" -w "%{http_code}" -X POST \ + if ! check_http_code=$(curl -sS -o "$check_response_file" -w "%{http_code}" -X POST \ -H "Authorization: Bearer $GITHUB_TOKEN" \ -H "Accept: application/vnd.github+json" \ -H "X-GitHub-Api-Version: 2022-11-28" \ - -d "$status_payload" \ - "$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/statuses/$sha"); then - status_http_code="000" + -d "$check_payload" \ + "$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/check-runs"); then + check_http_code="000" fi - status_response=$(cat "$status_response_file" || true) - rm -f "$status_response_file" + check_response=$(cat "$check_response_file" || true) + rm -f "$check_response_file" - if [ "$status_http_code" != "201" ]; then - echo "::error::Failed to create commit status '$context' for $sha (HTTP $status_http_code): $status_response" + if [ "$check_http_code" != "201" ]; then + echo "::error::Failed to create check run '$check_name' for $sha (HTTP $check_http_code): $check_response" exit 1 fi - echo "Created commit status '$context' with state '$state' for $sha." + echo "Created check run '$check_name' with conclusion '$conclusion' for $sha." } find_dispatched_workflow_run_id() { @@ -81,7 +84,7 @@ find_dispatched_workflow_run_id() { -H "Authorization: Bearer $GITHUB_TOKEN" \ -H "Accept: application/vnd.github+json" \ -H "X-GitHub-Api-Version: 2022-11-28" \ - "$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/actions/workflows/$workflow_file/runs?branch=$encoded_branch_ref&event=workflow_dispatch&per_page=10"); then + "$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/actions/workflows/$workflow_file/runs?branch=$encoded_branch_ref&event=workflow_dispatch&per_page=20"); then runs_http_code="000" fi @@ -90,7 +93,7 @@ find_dispatched_workflow_run_id() { if [ "$runs_http_code" = "200" ] && printf '%s\n' "$runs_response" | jq empty > /dev/null 2>&1; then run_id=$(printf '%s\n' "$runs_response" | jq -r --arg head_sha "$head_sha" ' - .workflow_runs[] + .workflow_runs[]? | select(.head_sha == $head_sha) | .id ' | head -n 1) @@ -110,7 +113,6 @@ find_dispatched_workflow_run_id() { wait_for_workflow_run_completion() { run_id="$1" - attempt=0 while [ "$attempt" -lt 120 ]; do @@ -146,7 +148,7 @@ wait_for_workflow_run_completion() { exit 1 } -mirror_workflow_jobs_as_commit_statuses() { +mirror_workflow_jobs_as_check_runs() { run_id="$1" head_sha="$2" @@ -169,10 +171,11 @@ mirror_workflow_jobs_as_commit_statuses() { fi if ! printf '%s\n' "$jobs_response" | jq -e '.jobs | length > 0' > /dev/null 2>&1; then - echo "::error::Workflow run $run_id has no jobs. Cannot mirror required status checks." + echo "::error::Workflow run $run_id has no jobs. Cannot mirror required checks." exit 1 fi + jobs_file=$(mktemp) tab=$(printf '\t') printf '%s\n' "$jobs_response" | jq -r ' @@ -183,23 +186,32 @@ mirror_workflow_jobs_as_commit_statuses() { (.html_url // "") ] | @tsv - ' | while IFS="$tab" read -r job_name job_conclusion job_url; do + ' > "$jobs_file" + + while IFS="$tab" read -r job_name job_conclusion job_url; do [ -z "$job_name" ] && continue - status_state=$(map_workflow_conclusion_to_status_state "$job_conclusion") + check_conclusion=$(map_job_conclusion_to_check_conclusion "$job_conclusion") - create_commit_status \ + create_check_run \ "$head_sha" \ "$job_name" \ - "$status_state" \ + "$check_conclusion" \ "$job_url" \ - "Mirrored result from dispatched workflow run $run_id" + "Mirrored result from dispatched workflow run $run_id." - if [ "$status_state" != "success" ]; then - echo "::error::Dispatched CI job '$job_name' finished with conclusion '$job_conclusion'." - exit 1 - fi - done + case "$check_conclusion" in + success|neutral|skipped) + ;; + *) + rm -f "$jobs_file" + echo "::error::Dispatched CI job '$job_name' finished with conclusion '$job_conclusion'." + exit 1 + ;; + esac + done < "$jobs_file" + + rm -f "$jobs_file" } get_current_release_workflow_path() { @@ -259,7 +271,7 @@ dispatch_configured_ci_workflows() { fi if [ -z "$head_sha" ]; then - echo "::error::PR head SHA is required to mirror CI statuses." + echo "::error::PR head SHA is required to mirror CI checks." exit 1 fi @@ -273,7 +285,7 @@ dispatch_configured_ci_workflows() { fi echo "Dispatching CI workflows for branch: $branch_ref" - echo "Mirroring CI job results to PR head SHA: $head_sha" + echo "Mirroring dispatched CI jobs as check runs for PR head SHA: $head_sha" while IFS= read -r workflow_file; do [ -z "$workflow_file" ] && continue @@ -315,7 +327,7 @@ dispatch_configured_ci_workflows() { echo "Found dispatched workflow run: $run_id" wait_for_workflow_run_completion "$run_id" - mirror_workflow_jobs_as_commit_statuses "$run_id" "$head_sha" + mirror_workflow_jobs_as_check_runs "$run_id" "$head_sha" done < "$workflows_file" rm -f "$workflows_file" @@ -356,6 +368,7 @@ fi branch_head_sha=$(git rev-parse HEAD) echo "CHANGELOG_PR_HEAD_SHA=$branch_head_sha" | tee -a "$GITHUB_ENV" export CHANGELOG_PR_HEAD_SHA="$branch_head_sha" +echo "Changelog PR head SHA: $CHANGELOG_PR_HEAD_SHA" if [ "$DRY_RUN" = "true" ]; then echo "Dry-Run: Skipping 'git push origin $branch_name'." From ef4a965de1ddc738957fcd68024e09d0da4f3f4c Mon Sep 17 00:00:00 2001 From: nirooxx Date: Tue, 16 Jun 2026 12:58:48 +0200 Subject: [PATCH 6/8] fix: mirror dispatched CI jobs as PR check runs --- action.yml | 8 +- scripts/create-pr.sh | 40 +- .../scripts/create-pr-script.test.ts | 462 ++++++++++++++++++ src/__tests__/scripts/test-utils.ts | 189 +++++++ 4 files changed, 694 insertions(+), 5 deletions(-) create mode 100644 src/__tests__/scripts/create-pr-script.test.ts diff --git a/action.yml b/action.yml index f34e25f..67e1200 100644 --- a/action.yml +++ b/action.yml @@ -39,7 +39,13 @@ inputs: required: false default: "" ci-workflows: - description: "Comma-separated workflow file names to dispatch after creating a changelog PR. Use 'auto' to dispatch all pull_request + workflow_dispatch workflows except the release workflow." + description: > + Comma-separated workflow file names to dispatch after creating a changelog PR. + Use 'auto' (default) to dispatch all workflow_dispatch-enabled workflows except the release workflow itself. + Use 'none' or 'false' to disable dispatch entirely. + Dispatched CI job results are mirrored as GitHub Check Runs on the changelog PR head SHA, + so required checks are satisfied automatically. + Requires: permissions: actions: write, checks: write (plus contents: write, pull-requests: write). required: false default: "auto" diff --git a/scripts/create-pr.sh b/scripts/create-pr.sh index 64c2a36..2e33871 100644 --- a/scripts/create-pr.sh +++ b/scripts/create-pr.sh @@ -1,6 +1,9 @@ #!/bin/sh set -e # Stop the script if any command fails +CREATE_PR_SCRIPT_VERSION="ci-dispatch-check-run-v2" +echo "CREATE_PR_SCRIPT_VERSION=$CREATE_PR_SCRIPT_VERSION" + # Define variables CHANGELOG_FILE_PATH="${CHANGELOG_FILE_PATH:-CHANGELOG.md}" TEMP_DIR=$(mktemp -d) @@ -73,9 +76,15 @@ find_dispatched_workflow_run_id() { workflow_file="$1" branch_ref="$2" head_sha="$3" + dispatch_started_at="$4" + + # All diagnostic output goes to stderr so it appears in logs but is not + # captured into the $() that reads this function's return value. + echo "Resolving workflow run for dispatched workflow: $workflow_file" >&2 encoded_branch_ref=$(urlencode "$branch_ref") attempt=0 + runs_response="" while [ "$attempt" -lt 60 ]; do runs_response_file=$(mktemp) @@ -98,6 +107,14 @@ find_dispatched_workflow_run_id() { | .id ' | head -n 1) + if [ -z "$run_id" ] || [ "$run_id" = "null" ]; then + run_id=$(printf '%s\n' "$runs_response" | jq -r \ + --arg branch "$branch_ref" \ + --arg since "$dispatch_started_at" \ + '.workflow_runs[]? | select(.head_branch == $branch and .created_at >= $since) | .id' \ + | head -n 1) + fi + if [ -n "$run_id" ] && [ "$run_id" != "null" ]; then echo "$run_id" return 0 @@ -108,6 +125,10 @@ find_dispatched_workflow_run_id() { sleep 5 done + echo "::error::Could not find dispatched workflow run for '$workflow_file' on branch '$branch_ref' (SHA '$head_sha', since '$dispatch_started_at')." >&2 + echo "Available workflow runs (last page):" >&2 + printf '%s\n' "$runs_response" | jq -r '.workflow_runs[]? | + " id=\(.id) branch=\(.head_branch) sha=\(.head_sha) status=\(.status) conclusion=\(.conclusion // "null") created=\(.created_at) url=\(.html_url)"' >&2 || true return 1 } @@ -223,8 +244,7 @@ workflow_supports_auto_dispatch() { workflow_content=$(grep -Ev '^[[:space:]]*#' "$workflow_path" || true) - printf '%s\n' "$workflow_content" | grep -Eq '(^|[^_[:alnum:]-])workflow_dispatch([^_[:alnum:]-]|$)' && \ - printf '%s\n' "$workflow_content" | grep -Eq '(^|[^_[:alnum:]-])pull_request([^_[:alnum:]-]|$)' + printf '%s\n' "$workflow_content" | grep -Eq '(^|[^_[:alnum:]-])workflow_dispatch([^_[:alnum:]-]|$)' } resolve_ci_workflows() { @@ -292,6 +312,8 @@ dispatch_configured_ci_workflows() { echo "Dispatching workflow: $workflow_file" + dispatch_started_at=$(date -u +"%Y-%m-%dT%H:%M:%SZ") + dispatch_payload=$(jq -n --arg ref "$branch_ref" '{ref: $ref}') dispatch_response_file=$(mktemp) @@ -318,8 +340,7 @@ dispatch_configured_ci_workflows() { ;; esac - run_id=$(find_dispatched_workflow_run_id "$workflow_file" "$branch_ref" "$head_sha") || { - echo "::error::Could not find dispatched workflow run for '$workflow_file' on branch '$branch_ref' and SHA '$head_sha'." + run_id=$(find_dispatched_workflow_run_id "$workflow_file" "$branch_ref" "$head_sha" "$dispatch_started_at") || { rm -f "$workflows_file" exit 1 } @@ -411,6 +432,17 @@ fi echo "PR_URL=$pr_url" | tee -a $GITHUB_ENV export PR_URL="$pr_url" +echo "--- CI dispatch debug ---" +echo " pwd: $(pwd)" +echo " branch_name: $branch_name" +echo " CHANGELOG_PR_HEAD_SHA: $CHANGELOG_PR_HEAD_SHA" +echo " CI_WORKFLOWS: $CI_WORKFLOWS" +echo " .github/workflows files:" +for _dbg_wf in .github/workflows/*.yml .github/workflows/*.yaml; do + [ -f "$_dbg_wf" ] && echo " $_dbg_wf" +done +echo "--- end debug ---" + if [ "$DRY_RUN" = "true" ]; then echo "Dry-Run: Skipping CI workflow dispatch." else diff --git a/src/__tests__/scripts/create-pr-script.test.ts b/src/__tests__/scripts/create-pr-script.test.ts new file mode 100644 index 0000000..c540dd5 --- /dev/null +++ b/src/__tests__/scripts/create-pr-script.test.ts @@ -0,0 +1,462 @@ +import * as fs from "node:fs"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, test } from "@jest/globals"; +import { + createTempDir, + runShellFunction, + writeExecutable, + writeJqMock, +} from "./test-utils.js"; + +const SCRIPT = "scripts/create-pr.sh"; + +const BASE_ENV = { + GITHUB_TOKEN: "test-token", + GITHUB_API_URL: "https://api.github.com", + GITHUB_REPOSITORY: "owner/repo", + GITHUB_WORKFLOW_REF: + "owner/repo/.github/workflows/release.yml@refs/heads/main", + GITHUB_ENV: "/dev/null", + CI_WORKFLOWS: "auto", + VERSION: "1.0.0", + DRY_RUN: "false", +}; + +// --------------------------------------------------------------------------- +// Auto-discovery tests (resolve_ci_workflows — no jq needed) +// --------------------------------------------------------------------------- +describe("create-pr.sh — auto-discovery", () => { + let tempDir: string; + let binDir: string; + + beforeEach(() => { + tempDir = createTempDir("github-release-create-pr-"); + binDir = path.join(tempDir, "bin"); + fs.mkdirSync(binDir); + fs.mkdirSync(path.join(tempDir, ".github", "workflows"), { + recursive: true, + }); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + test("finds workflow that declares workflow_dispatch", () => { + fs.writeFileSync( + path.join(tempDir, ".github", "workflows", "dummy-ci.yml"), + [ + "on:", + " workflow_dispatch:", + "jobs:", + " test:", + " runs-on: ubuntu-latest", + ].join("\n"), + "utf8", + ); + + const result = runShellFunction({ + scriptRelativePath: SCRIPT, + setup: "", + functionCall: "resolve_ci_workflows", + cwd: tempDir, + binDir, + env: BASE_ENV, + }); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("dummy-ci.yml"); + }); + + test("skips the release workflow itself", () => { + fs.writeFileSync( + path.join(tempDir, ".github", "workflows", "release.yml"), + [ + "on:", + " push:", + " workflow_dispatch:", + "jobs:", + " release:", + " runs-on: ubuntu-latest", + ].join("\n"), + "utf8", + ); + + fs.writeFileSync( + path.join(tempDir, ".github", "workflows", "ci.yml"), + [ + "on:", + " workflow_dispatch:", + "jobs:", + " test:", + " runs-on: ubuntu-latest", + ].join("\n"), + "utf8", + ); + + const result = runShellFunction({ + scriptRelativePath: SCRIPT, + setup: "", + functionCall: "resolve_ci_workflows", + cwd: tempDir, + binDir, + env: { + ...BASE_ENV, + GITHUB_WORKFLOW_REF: + "owner/repo/.github/workflows/release.yml@refs/heads/main", + }, + }); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("ci.yml"); + expect(result.stdout).not.toContain("release.yml"); + }); + + test("does not discover workflow that has no workflow_dispatch trigger", () => { + fs.writeFileSync( + path.join(tempDir, ".github", "workflows", "deploy.yml"), + [ + "on:", + " push:", + " branches: [main]", + "jobs:", + " deploy:", + " runs-on: ubuntu-latest", + ].join("\n"), + "utf8", + ); + + const result = runShellFunction({ + scriptRelativePath: SCRIPT, + setup: "", + functionCall: "resolve_ci_workflows", + cwd: tempDir, + binDir, + env: BASE_ENV, + }); + + expect(result.exitCode).toBe(0); + // deploy.yml must not appear; version banner lines are expected noise + expect(result.stdout).not.toContain("deploy.yml"); + expect(result.stdout).not.toContain(".yml"); + }); +}); + +// --------------------------------------------------------------------------- +// dispatch_configured_ci_workflows tests (require jq) +// --------------------------------------------------------------------------- +describe("create-pr.sh — dispatch_configured_ci_workflows", () => { + let tempDir: string; + let binDir: string; + + const PR_HEAD_SHA = "abc123def456abc123def456abc123def456abc1"; + const RUN_ID = 42; + const BRANCH = "release-changelog-update/1.0.0"; + + const RUNS_JSON = JSON.stringify({ + workflow_runs: [ + { + id: RUN_ID, + head_sha: PR_HEAD_SHA, + head_branch: BRANCH, + status: "completed", + conclusion: "success", + created_at: "2024-01-01T00:00:00Z", + html_url: `https://github.com/owner/repo/actions/runs/${RUN_ID}`, + }, + ], + }); + + const RUN_STATUS_JSON = JSON.stringify({ + status: "completed", + conclusion: "success", + }); + + const JOBS_JSON = JSON.stringify({ + jobs: [ + { + name: "Dummy CI Check", + conclusion: "success", + html_url: `https://github.com/owner/repo/actions/runs/${RUN_ID}/jobs/1`, + }, + ], + }); + + function writeCurlMock( + overrides: { + dispatchStatus?: string; + runsJson?: string; + runStatusJson?: string; + jobsJson?: string; + checkRunStatus?: string; + } = {}, + ): void { + const dispatchStatus = overrides.dispatchStatus ?? "204"; + const runsJson = overrides.runsJson ?? RUNS_JSON; + const runStatusJson = overrides.runStatusJson ?? RUN_STATUS_JSON; + const jobsJson = overrides.jobsJson ?? JOBS_JSON; + const checkRunStatus = overrides.checkRunStatus ?? "201"; + + // Write each response body as a temp file so the mock can serve them + const runsFile = path.join(tempDir, "mock-runs.json"); + const runFile = path.join(tempDir, "mock-run.json"); + const jobsFile = path.join(tempDir, "mock-jobs.json"); + const checkFile = path.join(tempDir, "mock-check.json"); + + fs.writeFileSync(runsFile, runsJson, "utf8"); + fs.writeFileSync(runFile, runStatusJson, "utf8"); + fs.writeFileSync(jobsFile, jobsJson, "utf8"); + fs.writeFileSync(checkFile, '{"id":99}', "utf8"); + + const runsFilePosix = runsFile.replace(/\\/g, "/"); + const runFilePosix = runFile.replace(/\\/g, "/"); + const jobsFilePosix = jobsFile.replace(/\\/g, "/"); + const checkFilePosix = checkFile.replace(/\\/g, "/"); + + writeExecutable( + path.join(binDir, "curl"), + `#!/bin/sh +# Extract -o and the URL (last non-flag arg) from argument list. +# Matching on the URL only (not $* which includes the request body) avoids +# false matches when a body payload contains paths like /runs/42/jobs/1. +output_file="" +url="" +prev="" +for arg in "$@"; do + if [ "$prev" = "-o" ]; then + output_file="$arg" + fi + case "$arg" in + http://*|https://*) url="$arg" ;; + esac + prev="$arg" +done + +case "$url" in + */dispatches*) + [ -n "$output_file" ] && printf '' > "$output_file" + printf '%s' "${dispatchStatus}" + ;; + */runs/*/jobs*) + [ -n "$output_file" ] && cat '${jobsFilePosix}' > "$output_file" + printf '%s' "200" + ;; + */runs/${RUN_ID}*) + [ -n "$output_file" ] && cat '${runFilePosix}' > "$output_file" + printf '%s' "200" + ;; + */workflows/*/runs*) + [ -n "$output_file" ] && cat '${runsFilePosix}' > "$output_file" + printf '%s' "200" + ;; + */check-runs*) + [ -n "$output_file" ] && cat '${checkFilePosix}' > "$output_file" + printf '%s' "${checkRunStatus}" + ;; + *) + [ -n "$output_file" ] && printf '' > "$output_file" + printf '%s' "000" + ;; +esac +`, + ); + } + + beforeEach(() => { + tempDir = createTempDir("github-release-create-pr-dispatch-"); + binDir = path.join(tempDir, "bin"); + fs.mkdirSync(binDir); + fs.mkdirSync(path.join(tempDir, ".github", "workflows"), { + recursive: true, + }); + + // Mock sleep so polling loops don't actually wait + writeExecutable(path.join(binDir, "sleep"), "#!/bin/sh\n"); + + writeJqMock(binDir, tempDir); + + fs.writeFileSync( + path.join(tempDir, ".github", "workflows", "dummy-ci.yml"), + [ + "on:", + " workflow_dispatch:", + "jobs:", + " Dummy CI Check:", + " runs-on: ubuntu-latest", + ].join("\n"), + "utf8", + ); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + test("full happy path: dispatch → find run → wait → create check run with exact job name", () => { + writeCurlMock(); + + const result = runShellFunction({ + scriptRelativePath: SCRIPT, + setup: "", + functionCall: `dispatch_configured_ci_workflows '${BRANCH}' '${PR_HEAD_SHA}'`, + cwd: tempDir, + binDir, + env: { ...BASE_ENV, CI_WORKFLOWS: "auto" }, + }); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Dispatching workflow: dummy-ci.yml"); + expect(result.stdout).toContain( + "Workflow dispatched successfully: dummy-ci.yml", + ); + // Resolving message goes to stderr (function is called via $() so only stdout is captured) + expect(result.stderr).toContain( + "Resolving workflow run for dispatched workflow: dummy-ci.yml", + ); + expect(result.stdout).toContain(`Found dispatched workflow run: ${RUN_ID}`); + expect(result.stdout).toContain( + "Created check run 'Dummy CI Check' with conclusion 'success'", + ); + expect(result.stdout).toContain(PR_HEAD_SHA); + }); + + test("dispatch call sends the changelog branch ref", () => { + const captureFile = path.join(tempDir, "curl-args.txt"); + const captureFilePosix = captureFile.replace(/\\/g, "/"); + + const runsFile = path.join(tempDir, "mock-runs.json"); + const runFile = path.join(tempDir, "mock-run.json"); + const jobsFile = path.join(tempDir, "mock-jobs.json"); + const checkFile = path.join(tempDir, "mock-check.json"); + fs.writeFileSync(runsFile, RUNS_JSON, "utf8"); + fs.writeFileSync(runFile, RUN_STATUS_JSON, "utf8"); + fs.writeFileSync(jobsFile, JOBS_JSON, "utf8"); + fs.writeFileSync(checkFile, '{"id":99}', "utf8"); + + const runsFilePosix = runsFile.replace(/\\/g, "/"); + const runFilePosix = runFile.replace(/\\/g, "/"); + const jobsFilePosix = jobsFile.replace(/\\/g, "/"); + const checkFilePosix = checkFile.replace(/\\/g, "/"); + + writeExecutable( + path.join(binDir, "curl"), + `#!/bin/sh +output_file="" +url="" +prev="" +for arg in "$@"; do + if [ "$prev" = "-o" ]; then + output_file="$arg" + fi + case "$arg" in + http://*|https://*) url="$arg" ;; + esac + prev="$arg" +done + +printf '%s\\n' "$*" >> '${captureFilePosix}' + +case "$url" in + */dispatches*) + [ -n "$output_file" ] && printf '' > "$output_file" + printf '%s' "204" + ;; + */runs/*/jobs*) + [ -n "$output_file" ] && cat '${jobsFilePosix}' > "$output_file" + printf '%s' "200" + ;; + */runs/${RUN_ID}*) + [ -n "$output_file" ] && cat '${runFilePosix}' > "$output_file" + printf '%s' "200" + ;; + */workflows/*/runs*) + [ -n "$output_file" ] && cat '${runsFilePosix}' > "$output_file" + printf '%s' "200" + ;; + */check-runs*) + [ -n "$output_file" ] && cat '${checkFilePosix}' > "$output_file" + printf '%s' "201" + ;; + *) + [ -n "$output_file" ] && printf '' > "$output_file" + printf '%s' "000" + ;; +esac +`, + ); + + const result = runShellFunction({ + scriptRelativePath: SCRIPT, + setup: "", + functionCall: `dispatch_configured_ci_workflows '${BRANCH}' '${PR_HEAD_SHA}'`, + cwd: tempDir, + binDir, + env: { ...BASE_ENV, CI_WORKFLOWS: "auto" }, + }); + + expect(result.exitCode).toBe(0); + + const capturedArgs = fs.readFileSync(captureFile, "utf8"); + // The branch ref is in the -d payload which spans multiple lines when the + // jq-built JSON is pretty-printed; check the whole capture for the dispatch + // endpoint and for the raw branch value that appears in the {ref:} body. + expect(capturedArgs).toContain("dispatches"); + expect(capturedArgs).toContain(BRANCH); + }); + + test("check-run creation includes the changelog PR head SHA", () => { + writeCurlMock(); + + const result = runShellFunction({ + scriptRelativePath: SCRIPT, + setup: "", + functionCall: `dispatch_configured_ci_workflows '${BRANCH}' '${PR_HEAD_SHA}'`, + cwd: tempDir, + binDir, + env: { ...BASE_ENV, CI_WORKFLOWS: "auto" }, + }); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain(`for ${PR_HEAD_SHA}`); + }); + + test("fails when no workflow run can be found after polling", () => { + writeCurlMock(); + + // Override find_dispatched_workflow_run_id to return immediately without polling + const result = runShellFunction({ + scriptRelativePath: SCRIPT, + setup: String.raw` +find_dispatched_workflow_run_id() { + echo "Resolving workflow run for dispatched workflow: $1" >&2 + echo "::error::Could not find dispatched workflow run for '$1' on branch '$2' (SHA '$3', since '$4')." >&2 + return 1 +} +`, + functionCall: `dispatch_configured_ci_workflows '${BRANCH}' '${PR_HEAD_SHA}'`, + cwd: tempDir, + binDir, + env: { ...BASE_ENV, CI_WORKFLOWS: "auto" }, + }); + + expect(result.exitCode).not.toBe(0); + // error messages go to stderr (same as the real function when called via $()) + expect(result.stderr).toContain("Could not find dispatched workflow run"); + }); + + test("dispatches nothing and succeeds when ci-workflows is none", () => { + writeCurlMock(); + + const result = runShellFunction({ + scriptRelativePath: SCRIPT, + setup: "", + functionCall: `dispatch_configured_ci_workflows '${BRANCH}' '${PR_HEAD_SHA}'`, + cwd: tempDir, + binDir, + env: { ...BASE_ENV, CI_WORKFLOWS: "none" }, + }); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("No CI workflows configured or discovered"); + }); +}); diff --git a/src/__tests__/scripts/test-utils.ts b/src/__tests__/scripts/test-utils.ts index 1c92888..9ff37ff 100644 --- a/src/__tests__/scripts/test-utils.ts +++ b/src/__tests__/scripts/test-utils.ts @@ -126,3 +126,192 @@ export function runSourcedShellScript(input: { ); } } + +export type ShellFunctionResult = { + stdout: string; + stderr: string; + exitCode: number; +}; + +/** + * Sources a shell script (with CHANGELOG_UPDATED=false to skip its main body), + * then runs optional setup shell code to override functions, then calls the + * named function. Returns stdout/stderr/exitCode without throwing so tests can + * assert on both success and failure paths. + * + * setup runs AFTER sourcing so its function definitions override the script's. + */ +export function runShellFunction(input: { + scriptRelativePath: string; + setup: string; + functionCall: string; + cwd: string; + binDir: string; + env: Record; +}): ShellFunctionResult { + const sourceScriptPath = path.join(process.cwd(), input.scriptRelativePath); + const localScriptPath = path.join(input.cwd, "script-under-test.sh"); + + const scriptContent = fs + .readFileSync(sourceScriptPath, "utf8") + .replace(/\r\n/g, "\n"); + + fs.writeFileSync(localScriptPath, scriptContent, "utf8"); + fs.chmodSync(localScriptPath, 0o755); + + const shell = resolvePosixShell(); + const relativeBinDir = toShellPath(path.relative(input.cwd, input.binDir)); + const shellCommand = [ + `PATH=${shellQuote(relativeBinDir)}:$PATH`, + "export PATH", + "CHANGELOG_UPDATED=false", + "export CHANGELOG_UPDATED", + ". ./script-under-test.sh", + input.setup, + input.functionCall, + ].join("\n"); + + const result = spawnSync(shell, ["-c", shellCommand], { + cwd: input.cwd, + env: buildChildEnv(input.env), + encoding: "utf8", + }); + + if (result.error) { + throw result.error; + } + + return { + stdout: result.stdout ?? "", + stderr: result.stderr ?? "", + exitCode: result.status ?? 1, + }; +} + +/** + * Writes a Node.js-backed jq mock to binDir/jq so tests run on systems where + * the real jq binary is not installed (e.g. bare Windows). Handles all jq + * patterns used by create-pr.sh. + */ +export function writeJqMock(binDir: string, cwd: string): void { + const implPath = path.join(cwd, "jq-impl.js"); + const implPathPosix = toShellPath(implPath); + + const implCode = `'use strict'; +const chunks = []; +process.stdin.on('data', d => chunks.push(d)); +process.stdin.on('end', () => main(chunks.join(''))); + +function main(raw) { + const args = process.argv.slice(2); + const vars = {}; + const positional = []; + for (let i = 0; i < args.length; i++) { + if (args[i] === '--arg') { vars[args[++i]] = args[++i]; } + else { positional.push(args[i]); } + } + const flags = positional.filter(a => /^-[a-zA-Z]/.test(a)).join(''); + // Normalize: jq expressions in create-pr.sh are multi-line shell strings. + // Collapse all whitespace runs to a single space and trim so regex anchors work. + const rawExpr = positional.find(a => !a.startsWith('-')) || '.'; + const expr = rawExpr.trim().replace(/\\s+/g, ' '); + const rawOut = flags.includes('r'); + const noInput = flags.includes('n'); + const exitStatus = flags.includes('e'); + const input = noInput ? null : safeparse(raw); + + if (expr === 'empty') { process.exit(safeparse(raw) !== null ? 0 : 1); } + + if (expr.includes('@uri')) { + process.stdout.write(encodeURIComponent(vars['value'] || '') + '\\n'); + return; + } + + const m1 = expr.match(/^\\.([\\w]+) ?\\/\\/ ?(empty|"[^"]*"|null)$/); + if (m1) { + const v = input?.[m1[1]]; + if (v == null) { if (m1[2] !== 'empty') write(m1[2].replace(/^"|"$/g, ''), true); } + else write(v, rawOut); + return; + } + + if (expr.includes('| length > 0')) { + const f = expr.match(/^\\.([\\w]+)/)?.[1]; + const arr = f ? input?.[f] : input; + const ok = Array.isArray(arr) && arr.length > 0; + write(ok, rawOut); + process.exit(ok ? 0 : exitStatus ? 1 : 0); + return; + } + + // .array[]? | select(.field == $var) | .result + const m2 = expr.match(/^\\.([\\w]+)\\[\\]\\?? \\| select\\(\\.([\\w]+) == \\$([\\w]+)\\) \\| \\.([\\w]+)/); + if (m2) { + (input?.[m2[1]] || []).filter(it => String(it[m2[2]]) === String(vars[m2[3]])).forEach(it => write(it[m2[4]], rawOut)); + return; + } + + // .array[]? | select(.f1 == $v1 and .f2 >= $v2) | .result + const m3 = expr.match(/^\\.([\\w]+)\\[\\]\\?? \\| select\\(\\.([\\w]+) == \\$([\\w]+) and \\.([\\w]+) >= \\$([\\w]+)\\) \\| \\.([\\w]+)/); + if (m3) { + (input?.[m3[1]] || []).filter(it => + String(it[m3[2]]) === String(vars[m3[3]]) && String(it[m3[4]]) >= String(vars[m3[5]]) + ).forEach(it => write(it[m3[6]], rawOut)); + return; + } + + if (expr.includes('@tsv')) { + const f = expr.match(/^\\.([\\w]+)\\[\\]/)?.[1]; + (f ? (input?.[f] || []) : []).forEach(it => { + process.stdout.write([it.name || '', it.conclusion || 'failure', it.html_url || ''].join('\\t') + '\\n'); + }); + return; + } + + if (noInput) { + if (expr.includes('head_sha') && expr.includes('conclusion')) { + const result = { + name: vars.name, head_sha: vars.head_sha, + status: 'completed', conclusion: vars.conclusion, + output: { title: vars.name, summary: vars.summary } + }; + if (vars.details_url) result.details_url = vars.details_url; + write(result, rawOut); + } else { + const result = {}; + for (const [k, v] of Object.entries(vars)) result[k] = v; + write(result, rawOut); + } + return; + } + + if (expr.includes('"') && expr.includes('\\\\(')) { + const f = expr.match(/^\\.([\\w]+)\\[\\]/)?.[1]; + (f ? (input?.[f] || []) : []).forEach(it => { + const s = expr.replace(/^[^"]*"/, '').replace(/"[^"]*$/, '') + .replace(/\\\\\\(([^)]+)\\)/g, (_, p) => { + const val = p.trim().split('.').reduce((o, k) => k ? o?.[k] : o, it); + return val == null ? 'null' : String(val); + }); + write(s, true); + }); + return; + } + + if (expr === '.') { write(input, rawOut); } +} + +function safeparse(s) { try { return JSON.parse(s || 'null'); } catch { return null; } } +function write(v, raw) { + if (v === null || v === undefined) return; + process.stdout.write((raw ? String(v) : JSON.stringify(v, null, 2)) + '\\n'); +} +`; + + fs.writeFileSync(implPath, implCode, "utf8"); + + writeExecutable( + path.join(binDir, "jq"), + `#!/bin/sh\nexec node '${implPathPosix}' "$@"\n`, + ); +} From c0d689b9b89d43e136f63048da2df756ead1ff20 Mon Sep 17 00:00:00 2001 From: nirooxx Date: Wed, 17 Jun 2026 16:01:34 +0200 Subject: [PATCH 7/8] fix: use raw branch name in run-lookup query and read PR head SHA from API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - find_dispatched_workflow_run_id: removed urlencode() call for the ?branch= query parameter — GitHub's query router does NOT decode %2F in query strings, so branches with slashes (e.g. release-changelog-update/1.0.0) returned zero runs and the polling loop timed out before check runs were ever created - PR creation: read CHANGELOG_PR_HEAD_SHA from .head.sha in the API response (authoritative) rather than git rev-parse HEAD (local guess) - PR creation: handle HTTP 422 (PR already exists) by fetching the existing PR and reading its current head SHA so re-runs still trigger the full dispatch → find-run → mirror-check-runs flow - prettier: normalise LF line endings in existing test files --- scripts/create-pr.sh | 46 +++++++++++++++++++++++++++++++++++--------- 1 file changed, 37 insertions(+), 9 deletions(-) diff --git a/scripts/create-pr.sh b/scripts/create-pr.sh index 2e33871..3ba6e93 100644 --- a/scripts/create-pr.sh +++ b/scripts/create-pr.sh @@ -82,7 +82,10 @@ find_dispatched_workflow_run_id() { # captured into the $() that reads this function's return value. echo "Resolving workflow run for dispatched workflow: $workflow_file" >&2 - encoded_branch_ref=$(urlencode "$branch_ref") + # Pass the branch name as-is in the query string. GitHub's ?branch= filter + # expects the literal branch name; percent-encoding (e.g. %2F for /) is NOT + # decoded on GitHub's query router and would return zero runs for branches + # whose names contain a slash (e.g. release-changelog-update/1.0.0). attempt=0 runs_response="" @@ -93,7 +96,7 @@ find_dispatched_workflow_run_id() { -H "Authorization: Bearer $GITHUB_TOKEN" \ -H "Accept: application/vnd.github+json" \ -H "X-GitHub-Api-Version: 2022-11-28" \ - "$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/actions/workflows/$workflow_file/runs?branch=$encoded_branch_ref&event=workflow_dispatch&per_page=20"); then + "$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/actions/workflows/$workflow_file/runs?branch=$branch_ref&event=workflow_dispatch&per_page=20"); then runs_http_code="000" fi @@ -387,9 +390,7 @@ else fi branch_head_sha=$(git rev-parse HEAD) -echo "CHANGELOG_PR_HEAD_SHA=$branch_head_sha" | tee -a "$GITHUB_ENV" -export CHANGELOG_PR_HEAD_SHA="$branch_head_sha" -echo "Changelog PR head SHA: $CHANGELOG_PR_HEAD_SHA" +echo "Local branch HEAD SHA (before push): $branch_head_sha" if [ "$DRY_RUN" = "true" ]; then echo "Dry-Run: Skipping 'git push origin $branch_name'." @@ -405,6 +406,7 @@ if [ "$DRY_RUN" = "true" ]; then echo "Dry-Run: Skipping PR creation." echo "Would have sent API request with title: '$pr_title' and branch '$branch_name'." pr_url="https://github.com/$GITHUB_REPOSITORY/pull/dry-run-placeholder" + CHANGELOG_PR_HEAD_SHA="$branch_head_sha" else response_file=$(mktemp) http_code=$(curl -sS -o "$response_file" -w "%{http_code}" -X POST \ @@ -416,19 +418,45 @@ else response=$(cat "$response_file") rm -f "$response_file" - if [ "$http_code" != "201" ]; then + if [ "$http_code" = "422" ]; then + # PR already exists for this branch — look it up to get its current head SHA. + owner=$(printf '%s' "$GITHUB_REPOSITORY" | cut -d/ -f1) + existing_file=$(mktemp) + curl -sS -o "$existing_file" \ + -H "Authorization: Bearer $GITHUB_TOKEN" \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + "$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/pulls?head=$owner:$branch_name&state=open&per_page=1" + existing_response=$(cat "$existing_file") + rm -f "$existing_file" + pr_url=$(printf '%s\n' "$existing_response" | jq -r '.[0].html_url // empty') + CHANGELOG_PR_HEAD_SHA=$(printf '%s\n' "$existing_response" | jq -r '.[0].head.sha // empty') + echo "PR already exists: $pr_url (head SHA: $CHANGELOG_PR_HEAD_SHA)" + elif [ "$http_code" != "201" ]; then echo "::error:: PR creation failed (HTTP $http_code): $response" exit 1 + else + pr_url=$(echo "$response" | jq -r '.html_url // empty') + # Use the SHA GitHub returned in the PR response — this is authoritative and + # guaranteed to match what GitHub links to the PR's required checks. + CHANGELOG_PR_HEAD_SHA=$(echo "$response" | jq -r '.head.sha // empty') fi - pr_url=$(echo "$response" | jq -r '.html_url // empty') - if [ -z "$pr_url" ] || [ "$pr_url" = "null" ]; then - echo "::error:: PR was created but html_url is missing: $response" + echo "::error:: PR URL could not be determined: $response" exit 1 fi + + if [ -z "$CHANGELOG_PR_HEAD_SHA" ] || [ "$CHANGELOG_PR_HEAD_SHA" = "null" ]; then + echo "PR head SHA not returned by API — falling back to local git SHA" + CHANGELOG_PR_HEAD_SHA="$branch_head_sha" + fi fi +echo "CHANGELOG_PR_HEAD_SHA=$CHANGELOG_PR_HEAD_SHA" | tee -a "$GITHUB_ENV" +export CHANGELOG_PR_HEAD_SHA +echo "Changelog PR head SHA: $CHANGELOG_PR_HEAD_SHA" + echo "PR_URL=$pr_url" | tee -a $GITHUB_ENV export PR_URL="$pr_url" From 8ebb08d8589c5876abee825c94fc6dec3712cb0a Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 19 Jun 2026 23:53:46 +0000 Subject: [PATCH 8/8] chore(deps): update docker images to v24.17.0 (#180) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- Dockerfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index a6bc066..c52ec77 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # Stage 1: Build -FROM node:24.16.0-alpine AS build +FROM node:24.17.0-alpine AS build WORKDIR /app @@ -24,7 +24,7 @@ RUN rm -rf ./src \ ./tsconfig.prod.json # Stage 2: Production -FROM node:24.16.0-alpine +FROM node:24.17.0-alpine WORKDIR /app