Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 43 additions & 1 deletion .github/scripts/advance-action-tags/advance-action-tags.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down Expand Up @@ -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" ]
}
16 changes: 10 additions & 6 deletions .github/workflows/advance-action-tags.yaml
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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."
Loading