From d4babd6f7af6d519596269ce1999d166049cd562 Mon Sep 17 00:00:00 2001 From: Piotr Zaniewski Date: Mon, 9 Nov 2026 10:07:05 +0100 Subject: [PATCH] fix(ci): advance action tags on push instead of pull_request_target GitHub blocks pull_request_target by default in public repositories from 2 December 2026, with brownouts on 9 and 16 November. Advance action tags (dca6bd4) landed after the DEVOPS-1803 inventory and still listened for pull_request_target closed, so tag advancement would stop on the cutoff and during each brownout, and fixes merged to main would silently stay unshipped. The workflow now runs on push to main. The script resolves the merged pull request whose merge commit is the pushed head through the API and feeds the existing logic the same pull_request shape, so the major-release, binary action, file count and lease checks are unchanged. A push that is not a PR merge warns and skips; several matching PRs or malformed API data fail closed. Refs: DEVOPS-1803 Claude-Session: https://claude.ai/code/session_017edg9i7XSWRkNCSfeYYZzw --- .../advance-action-tags.sh | 44 ++++++++- .../test/advance-action-tags.bats | 90 ++++++++++++++++++- .github/workflows/advance-action-tags.yaml | 16 ++-- 3 files changed, 141 insertions(+), 9 deletions(-) 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."