diff --git a/.github/scripts/advance-action-tags/advance-action-tags.sh b/.github/scripts/advance-action-tags/advance-action-tags.sh index 52f4b94f..7a3ed1b7 100755 --- a/.github/scripts/advance-action-tags/advance-action-tags.sh +++ b/.github/scripts/advance-action-tags/advance-action-tags.sh @@ -2,7 +2,8 @@ set -euo pipefail # Advance existing action major tags to a merged PR commit. This script is -# invoked by the post-merge workflow with a full checkout and GH_TOKEN. +# invoked by the workflow on each push to main, with a full checkout and +# GH_TOKEN. It also accepts a pull_request closed event payload. fail() { echo "::error::$*" >&2 @@ -15,6 +16,43 @@ command -v jq >/dev/null || fail "jq is required to read the pull request event" : "${GITHUB_EVENT_PATH:?GITHUB_EVENT_PATH is required}" : "${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}" +resolve_attempts=${ADVANCE_TAGS_RESOLVE_ATTEMPTS:-3} +resolve_delay=${ADVANCE_TAGS_RESOLVE_DELAY:-5} + +# A push to main carries no pull request. Find the merged PR whose merge commit +# is the pushed head, read it from the API, and hand the rest of the script the +# same pull_request event shape a PR close event provides. +resolve_push_event() { + local head_sha pulls_json matches count=0 attempt pr_json event_file + head_sha=$(jq -er '.after | select(type == "string" and length > 0)' "$GITHUB_EVENT_PATH") || fail "Push event has no head commit" + + # The commit-to-PR association can trail the push by a few seconds. + for (( attempt = 1; attempt <= resolve_attempts; attempt++ )); do + pulls_json=$(gh api "repos/$GITHUB_REPOSITORY/commits/$head_sha/pulls") || fail "Could not list pull requests for commit $head_sha" + matches=$(jq -ce --arg sha "$head_sha" \ + 'if type == "array" then [.[] | select(.merged_at != null and .merge_commit_sha == $sha and .base.ref == "main") | .number] else error("not an array") end' \ + <<< "$pulls_json") || fail "GitHub returned malformed pull request data for commit $head_sha" + count=$(jq 'length' <<< "$matches") + (( count == 0 )) || break + (( attempt == resolve_attempts )) || sleep "$resolve_delay" + done + + if (( count == 0 )); then + echo "::warning::Commit $head_sha on main is not the merge commit of a pull request, so no action tags were advanced. If it changed an action, move that action's major tag by hand." + exit 0 + fi + (( count == 1 )) || fail "Commit $head_sha is the merge commit of several pull requests ($matches); tags were not changed" + + pr_json=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$(jq -r '.[0]' <<< "$matches")") || fail "Could not read the pull request merged as $head_sha" + event_file=$(mktemp) + jq -e '{pull_request: .}' <<< "$pr_json" > "$event_file" || fail "GitHub returned malformed pull request data for commit $head_sha" + GITHUB_EVENT_PATH=$event_file +} + +if [[ "${GITHUB_EVENT_NAME:-}" == push ]]; then + resolve_push_event +fi + git_authenticated() { local token=${GH_TOKEN:-${GITHUB_TOKEN:-}} if [[ -n "$token" ]]; then @@ -30,6 +68,10 @@ git_authenticated() { } pr_number=$(jq -er '.pull_request.number' "$GITHUB_EVENT_PATH") || fail "Event has no pull request number" +# The workflow's failure report comments on this PR. +if [[ -n "${GITHUB_OUTPUT:-}" ]]; then + echo "pr-number=$pr_number" >> "$GITHUB_OUTPUT" +fi merged=$(jq -r '.pull_request.merged // false' "$GITHUB_EVENT_PATH") [[ "$merged" == true ]] || fail "Pull request #$pr_number is not merged" merge_sha=$(jq -er '.pull_request.merge_commit_sha | select(type == "string" and length > 0)' "$GITHUB_EVENT_PATH") || fail "Merged pull request has no merge commit SHA" diff --git a/.github/scripts/advance-action-tags/test/advance-action-tags.bats b/.github/scripts/advance-action-tags/test/advance-action-tags.bats index 52693c40..781b361b 100644 --- a/.github/scripts/advance-action-tags/test/advance-action-tags.bats +++ b/.github/scripts/advance-action-tags/test/advance-action-tags.bats @@ -35,15 +35,39 @@ setup() { export GITHUB_EVENT_PATH="$TEST_ROOT/event.json" export GITHUB_REPOSITORY=owner/repo export PATH="$MOCK:$PATH" + export ADVANCE_TAGS_RESOLVE_DELAY=0 + export GH_CALLS="$TEST_ROOT/gh-calls" cat > "$MOCK/gh" <<'MOCK_GH' #!/usr/bin/env bash -[[ "$*" == *"repos/owner/repo/pulls/17/files"* ]] || exit 2 -printf '%s\n' "$GH_FILES_JSON" +printf '%s\n' "$*" >> "$GH_CALLS" +case "$*" in + *"repos/owner/repo/pulls/17/files"*) printf '%s\n' "$GH_FILES_JSON" ;; + *"repos/owner/repo/commits/$MERGE_SHA/pulls"*) printf '%s\n' "$GH_COMMIT_PULLS_JSON" ;; + *"repos/owner/repo/pulls/17") printf '%s\n' "$GH_PR_JSON" ;; + *) exit 2 ;; +esac MOCK_GH chmod +x "$MOCK/gh" + export MERGE_SHA write_event '[]' } +# Simulates the push to main that a merge of PR 17 produces. +use_push_event() { + export GITHUB_EVENT_NAME=push + jq -n --arg sha "$MERGE_SHA" '{ref:"refs/heads/main",after:$sha}' > "$GITHUB_EVENT_PATH" + GH_COMMIT_PULLS_JSON=$(jq -cn --arg sha "$MERGE_SHA" \ + '[{number:17,merged_at:"2026-11-09T10:00:00Z",merge_commit_sha:$sha,base:{ref:"main"}}]') + export GH_COMMIT_PULLS_JSON + set_pr_labels '[]' +} + +set_pr_labels() { + GH_PR_JSON=$(jq -cn --arg sha "$MERGE_SHA" --argjson labels "$1" \ + '{number:17,merged:true,merge_commit_sha:$sha,changed_files:1,labels:$labels}') + export GH_PR_JSON +} + teardown() { rm -rf "$TEST_ROOT" } @@ -146,3 +170,65 @@ UPDATE_HOOK [ "$(git --git-dir="$REMOTE" rev-parse 'refs/tags/example-action/v4^{commit}')" = "$OLD_SHA" ] [ "$(git --git-dir="$REMOTE" rev-parse refs/tags/second-action/v1)" = "$OLD_SHA" ] } + +@test "push event resolves the merged pull request and moves its action tag" { + use_push_event + export GITHUB_OUTPUT="$TEST_ROOT/output" + run bash -c "cd '$WORK' && bash '$SCRIPT'" + [ "$status" -eq 0 ] + [[ "$output" == *"Advanced example-action/v4 from $OLD_SHA to $MERGE_SHA."* ]] + [ "$(git --git-dir="$REMOTE" rev-parse 'refs/tags/example-action/v4^{commit}')" = "$MERGE_SHA" ] + grep -qx 'pr-number=17' "$GITHUB_OUTPUT" +} + +@test "push event reads the major-release label from the API and moves no tag" { + use_push_event + set_pr_labels '[{"name":"major-release"}]' + run bash -c "cd '$WORK' && bash '$SCRIPT'" + [ "$status" -ne 0 ] + [[ "$output" == *"declares major-release"* ]] + [ "$(git --git-dir="$REMOTE" rev-parse refs/tags/example-action/v4)" = "$OLD_V4_OID" ] +} + +@test "push with no merged pull request warns, retries and moves no tag" { + use_push_event + export GH_COMMIT_PULLS_JSON='[]' + run bash -c "cd '$WORK' && bash '$SCRIPT'" + [ "$status" -eq 0 ] + [[ "$output" == *"::warning::Commit $MERGE_SHA on main is not the merge commit of a pull request"* ]] + [ "$(grep -c "commits/$MERGE_SHA/pulls" "$GH_CALLS")" -eq 3 ] + [ "$(git --git-dir="$REMOTE" rev-parse 'refs/tags/example-action/v4^{commit}')" = "$OLD_SHA" ] +} + +@test "push ignores a pull request that only contains the commit" { + use_push_event + GH_COMMIT_PULLS_JSON=$(jq -cn --arg sha "$MERGE_SHA" \ + '[{number:18,merged_at:null,merge_commit_sha:"0000000000000000000000000000000000000000",base:{ref:"main"}}, + {number:19,merged_at:"2026-11-09T10:00:00Z",merge_commit_sha:$sha,base:{ref:"release-1"}}]') + export GH_COMMIT_PULLS_JSON + run bash -c "cd '$WORK' && bash '$SCRIPT'" + [ "$status" -eq 0 ] + [[ "$output" == *"is not the merge commit of a pull request"* ]] + [ "$(git --git-dir="$REMOTE" rev-parse 'refs/tags/example-action/v4^{commit}')" = "$OLD_SHA" ] +} + +@test "push merged by several pull requests fails closed" { + use_push_event + GH_COMMIT_PULLS_JSON=$(jq -cn --arg sha "$MERGE_SHA" \ + '[{number:17,merged_at:"2026-11-09T10:00:00Z",merge_commit_sha:$sha,base:{ref:"main"}}, + {number:20,merged_at:"2026-11-09T10:00:00Z",merge_commit_sha:$sha,base:{ref:"main"}}]') + export GH_COMMIT_PULLS_JSON + run bash -c "cd '$WORK' && bash '$SCRIPT'" + [ "$status" -ne 0 ] + [[ "$output" == *"merge commit of several pull requests"* ]] + [ "$(git --git-dir="$REMOTE" rev-parse 'refs/tags/example-action/v4^{commit}')" = "$OLD_SHA" ] +} + +@test "push with malformed commit pull request data fails closed" { + use_push_event + export GH_COMMIT_PULLS_JSON='{"message":"Not Found"}' + run bash -c "cd '$WORK' && bash '$SCRIPT'" + [ "$status" -ne 0 ] + [[ "$output" == *"malformed pull request data"* ]] + [ "$(git --git-dir="$REMOTE" rev-parse 'refs/tags/example-action/v4^{commit}')" = "$OLD_SHA" ] +} diff --git a/.github/workflows/advance-action-tags.yaml b/.github/workflows/advance-action-tags.yaml index 10cf72b8..435b0be1 100644 --- a/.github/workflows/advance-action-tags.yaml +++ b/.github/workflows/advance-action-tags.yaml @@ -1,32 +1,36 @@ name: Advance action tags +# A push to main is the merge itself. The script resolves the merged pull +# request through the API, so this needs no pull_request_target event, which +# GitHub blocks by default in public repositories from 2 December 2026. on: - pull_request_target: # zizmor: ignore[dangerous-triggers] -- runs only after merge, and checks out the merged main commit - types: [closed] + push: branches: [main] permissions: {} jobs: advance: - if: github.event.pull_request.merged == true runs-on: ubuntu-latest permissions: contents: write pull-requests: read + outputs: + pr-number: ${{ steps.advance.outputs.pr-number }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - ref: ${{ github.event.pull_request.merge_commit_sha }} + ref: ${{ github.sha }} fetch-depth: 0 persist-credentials: false - name: Advance tags + id: advance env: GH_TOKEN: ${{ github.token }} run: bash .github/scripts/advance-action-tags/advance-action-tags.sh report-failure: - if: always() && needs.advance.result == 'failure' + if: always() && needs.advance.result == 'failure' && needs.advance.outputs.pr-number != '' needs: advance runs-on: ubuntu-latest permissions: @@ -36,6 +40,6 @@ jobs: env: GH_TOKEN: ${{ github.token }} GH_REPO: ${{ github.repository }} - PR_NUMBER: ${{ github.event.pull_request.number }} + PR_NUMBER: ${{ needs.advance.outputs.pr-number }} RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} run: gh pr comment "$PR_NUMBER" --body "Action tags were not fully advanced. Check the [workflow log]($RUN_URL), review the release requirements and callers, then publish or recover the affected tags manually. A major-release label requires a new major tag."