From f6caac58c7421ef2e03b0b4c20a69ddc22908c17 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Mon, 9 Nov 2026 16:16:52 +0000 Subject: [PATCH 1/6] ci(release): Release only green builds and clear stale release branches Co-Authored-By: BYK <126780+BYK@users.noreply.github.com> --- .github/scripts/release-preflight.sh | 107 +++++++++++++++++++++++++++ .github/workflows/release.yml | 40 ++++++++-- 2 files changed, 141 insertions(+), 6 deletions(-) create mode 100755 .github/scripts/release-preflight.sh diff --git a/.github/scripts/release-preflight.sh b/.github/scripts/release-preflight.sh new file mode 100755 index 00000000..e1a151a0 --- /dev/null +++ b/.github/scripts/release-preflight.sh @@ -0,0 +1,107 @@ +#!/bin/sh +# Decides whether the Release workflow runs `craft prepare`, and clears release +# branches whose publish request can no longer finish, so a failed release does +# not block every later one. +# +# Writes proceed=true or proceed=false to $GITHUB_OUTPUT. +# +# Environment: +# RELEASE_TRIGGER github.event_name. "workflow_run" means an automatic +# release of HEAD after its Build passed; manual releases +# skip the checks that only make sense for those. +# GH_TOKEN Token that can delete release branches here and read, comment +# on, and close issues in the publish repository. +# GITHUB_OUTPUT, GITHUB_REPOSITORY, GITHUB_REPOSITORY_OWNER, GITHUB_RUN_ID and +# GITHUB_SERVER_URL come from GitHub Actions. +set -eu + +remote=origin +# Craft files publish requests in /publish by default. +publish_repo="$GITHUB_REPOSITORY_OWNER/publish" +run_url="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" +head=$(git rev-parse HEAD) + +if [ "$RELEASE_TRIGGER" = workflow_run ]; then + # Builds can finish out of order. A newer commit's release may already + # include this one. + released_in=$(git tag --contains HEAD) + if [ -n "$released_in" ]; then + printf '::notice::%s is already released. Nothing to do.\n' "$head" + printf 'proceed=false\n' >>"$GITHUB_OUTPUT" + exit 0 + fi +fi + +package=$(jq -er .name package.json) +npm_versions=$(npm view "$package" versions --json) +branches=$(git ls-remote --heads "$remote" 'refs/heads/release/*') +open_requests=$(gh issue list -R "$publish_repo" --state open --limit 1000 --json number,title,labels) +pending= + +while read -r sha ref; do + [ -n "$ref" ] || continue + branch=${ref#refs/heads/} + version=${branch#release/} + + published=$(jq -r --arg v "$version" 'if type == "array" then . else [.] end | index($v) != null' <>"$GITHUB_OUTPUT" + exit 0 +fi + +printf 'proceed=true\n' >>"$GITHUB_OUTPUT" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bbc24ce9..027fe089 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,7 +1,13 @@ name: Release on: - push: + # Release only commits whose Build passed on main, so a broken build cannot + # cut a release branch whose CI then fails. + workflow_run: + workflows: + - Build + types: + - completed branches: - main @@ -28,18 +34,23 @@ jobs: release: runs-on: ubuntu-latest name: Release a new version + # Only commits whose Build passed on main, pushed to this repository. # Craft merges release branches by SHA, producing "Merge commit '...'". if: >- github.event_name == 'workflow_dispatch' || ( - !startsWith(github.event.head_commit.message, 'Merge branch ''release/') && - !(github.actor == 'sentry-release-bot[bot]' && - startsWith(github.event.head_commit.message, 'Merge commit ')) + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event == 'push' && + github.event.workflow_run.head_repository.full_name == github.repository && + !startsWith(github.event.workflow_run.head_commit.message, 'Merge branch ''release/') && + !(github.event.workflow_run.actor.login == 'sentry-release-bot[bot]' && + startsWith(github.event.workflow_run.head_commit.message, 'Merge commit ')) ) - # Apply concurrency after the guard so skipped merges cannot cancel a release. + # Apply concurrency after the guard so skipped runs cannot take the queue. + # Never cancel a running release: it may have pushed its branch already. concurrency: group: release - cancel-in-progress: true + cancel-in-progress: false steps: - name: Get auth token id: token @@ -52,13 +63,30 @@ jobs: with: token: ${{ steps.token.outputs.token }} fetch-depth: 0 + # The commit whose Build passed. Manual runs use the ref they were + # dispatched on. + ref: ${{ github.event.workflow_run.head_sha }} + + # Craft prepares from the current branch and expects the default branch, + # so put a local main on the commit that Build checked. + - name: Put main on the built commit + if: github.event_name == 'workflow_run' + run: git checkout -B main - name: Setup Node.js uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: 24 + - name: Check what to release + id: preflight + env: + GH_TOKEN: ${{ steps.token.outputs.token }} + RELEASE_TRIGGER: ${{ github.event_name }} + run: .github/scripts/release-preflight.sh + - name: Prepare release + if: steps.preflight.outputs.proceed == 'true' uses: getsentry/craft@2e29b0819113d334c15da3d8cac2138cb9dc111a # 3.9.0 env: GITHUB_TOKEN: ${{ steps.token.outputs.token }} From e8f6c8ad037f7d616d8eb2401297a27e0a69ae87 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Mon, 9 Nov 2026 16:43:03 +0000 Subject: [PATCH 2/6] ci(release): Release commits that landed while a release was pending Co-Authored-By: BYK <126780+BYK@users.noreply.github.com> --- .github/scripts/release-preflight.sh | 38 ++++++++++++++++++++-------- .github/workflows/release.yml | 8 +++--- 2 files changed, 31 insertions(+), 15 deletions(-) diff --git a/.github/scripts/release-preflight.sh b/.github/scripts/release-preflight.sh index e1a151a0..3aaa8f9b 100755 --- a/.github/scripts/release-preflight.sh +++ b/.github/scripts/release-preflight.sh @@ -9,8 +9,8 @@ # RELEASE_TRIGGER github.event_name. "workflow_run" means an automatic # release of HEAD after its Build passed; manual releases # skip the checks that only make sense for those. -# GH_TOKEN Token that can delete release branches here and read, comment -# on, and close issues in the publish repository. +# GH_TOKEN Token that can delete release branches here and read, +# comment on, and close issues in the publish repository. # GITHUB_OUTPUT, GITHUB_REPOSITORY, GITHUB_REPOSITORY_OWNER, GITHUB_RUN_ID and # GITHUB_SERVER_URL come from GitHub Actions. set -eu @@ -20,16 +20,33 @@ remote=origin publish_repo="$GITHUB_REPOSITORY_OWNER/publish" run_url="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" head=$(git rev-parse HEAD) +newline=' +' if [ "$RELEASE_TRIGGER" = workflow_run ]; then - # Builds can finish out of order. A newer commit's release may already - # include this one. - released_in=$(git tag --contains HEAD) - if [ -n "$released_in" ]; then - printf '::notice::%s is already released. Nothing to do.\n' "$head" + # Craft picks the next version from the latest tag in HEAD's history. If a + # newer release exists outside it, HEAD predates that release's merge into + # main (Builds can finish out of order, and a release is tagged before it is + # merged). The Release run for that merge covers this commit instead. + base=$(git describe --tags --abbrev=0) + tags=$(git tag --sort=-version:refname) + newest=${tags%%"$newline"*} + if [ "$newest" != "$base" ]; then + printf '::notice::Release %s is not in the history of %s yet. The Release run after its merge into main releases whatever is left.\n' "$newest" "$head" printf 'proceed=false\n' >>"$GITHUB_OUTPUT" exit 0 fi + + # Release merges and the release commits they bring in are part of $base. + # Anything else is unreleased, including commits that landed while the + # previous release was pending. + unreleased=$(git rev-list --no-merges --count "$base..HEAD") + if [ "$unreleased" -eq 0 ]; then + printf '::notice::Nothing to release: no commits since %s.\n' "$base" + printf 'proceed=false\n' >>"$GITHUB_OUTPUT" + exit 0 + fi + printf '%s commit(s) since %s to release.\n' "$unreleased" "$base" fi package=$(jq -er .name package.json) @@ -97,9 +114,10 @@ $branches EOF if [ "$RELEASE_TRIGGER" = workflow_run ] && [ -n "$pending" ]; then - # Craft would refuse with "release branch already exists". The daily - # staleness check reports this commit if it stays unreleased. - printf '::notice::Release%s is still pending. A later release will include %s.\n' "$pending" "$head" + # Craft would refuse with "release branch already exists". Merging the + # pending release into main triggers Build and then Release again, and that + # run releases this commit. + printf '::notice::Release%s is still pending. The Release run after its merge into main will release %s.\n' "$pending" "$head" printf 'proceed=false\n' >>"$GITHUB_OUTPUT" exit 0 fi diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 027fe089..c96a24e7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -35,16 +35,14 @@ jobs: runs-on: ubuntu-latest name: Release a new version # Only commits whose Build passed on main, pushed to this repository. - # Craft merges release branches by SHA, producing "Merge commit '...'". + # Release merges run too: preflight releases the commits that landed while + # that release was pending, and does nothing when there are none. if: >- github.event_name == 'workflow_dispatch' || ( github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push' && - github.event.workflow_run.head_repository.full_name == github.repository && - !startsWith(github.event.workflow_run.head_commit.message, 'Merge branch ''release/') && - !(github.event.workflow_run.actor.login == 'sentry-release-bot[bot]' && - startsWith(github.event.workflow_run.head_commit.message, 'Merge commit ')) + github.event.workflow_run.head_repository.full_name == github.repository ) # Apply concurrency after the guard so skipped runs cannot take the queue. # Never cancel a running release: it may have pushed its branch already. From c24715fa0bd9e9367251d06582a6520c67f0b8a1 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Mon, 9 Nov 2026 17:40:59 +0000 Subject: [PATCH 3/6] ci(release): Drop a release as soon as its Build fails Move the stale-branch cleanup out of Release preflight and into the release pipeline health workflow: when Build fails on a release branch, close its publish request and delete the branch. Preflight now only checks the release tag, unreleased commits, and whether a release is pending. --- .github/scripts/drop-failed-release.sh | 56 +++++++ .github/scripts/release-preflight.sh | 146 +++++------------- .github/workflows/release-pipeline-health.yml | 55 +++++-- .github/workflows/release.yml | 6 +- 4 files changed, 139 insertions(+), 124 deletions(-) create mode 100755 .github/scripts/drop-failed-release.sh mode change 100755 => 100644 .github/scripts/release-preflight.sh diff --git a/.github/scripts/drop-failed-release.sh b/.github/scripts/drop-failed-release.sh new file mode 100755 index 00000000..4af70d71 --- /dev/null +++ b/.github/scripts/drop-failed-release.sh @@ -0,0 +1,56 @@ +#!/bin/sh +# Drops a release whose Build failed: closes its publish request and deletes its +# release branch, so the next push to main cuts a fresh release instead of +# failing on "release branch already exists". Nobody fixes release branches by +# hand in this auto-release repository. +# +# Environment: +# BRANCH Release branch whose Build failed, such as release/1.2.3. +# SHA Commit that Build ran on. The branch is deleted only while it +# still points there. +# RUN_URL The failed Build run. +# GH_TOKEN Token that can delete branches here and comment on and close +# issues in the publish repository. +# GITHUB_REPOSITORY and GITHUB_REPOSITORY_OWNER come from GitHub Actions. +set -eu + +remote=origin +# Craft files publish requests in /publish by default. +publish_repo="$GITHUB_REPOSITORY_OWNER/publish" +ref="refs/heads/$BRANCH" +version=${BRANCH#release/} + +current=$(git ls-remote "$remote" "$ref") +if [ -z "$current" ]; then + printf '::notice::%s is already gone.\n' "$BRANCH" + exit 0 +fi +if [ "${current%%[[:space:]]*}" != "$SHA" ]; then + printf '::notice::%s no longer points at %s. Leaving it alone.\n' "$BRANCH" "$SHA" + exit 0 +fi + +# Craft tags a release when it publishes it, so a tag means it is out already. +tag=$(git ls-remote --tags "$remote" "refs/tags/$version") +if [ -n "$tag" ]; then + printf '::warning::%s is already tagged. Leaving %s alone.\n' "$version" "$BRANCH" + exit 0 +fi + +title="publish: $GITHUB_REPOSITORY@$version" +open_requests=$(gh issue list -R "$publish_repo" --state open --limit 1000 --json number,title) +number=$(jq -r --arg t "$title" '[.[] | select(.title == $t)] | first | .number // empty' </publish by default. -publish_repo="$GITHUB_REPOSITORY_OWNER/publish" -run_url="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" -head=$(git rev-parse HEAD) newline=' ' +head=$(git rev-parse HEAD) -if [ "$RELEASE_TRIGGER" = workflow_run ]; then - # Craft picks the next version from the latest tag in HEAD's history. If a - # newer release exists outside it, HEAD predates that release's merge into - # main (Builds can finish out of order, and a release is tagged before it is - # merged). The Release run for that merge covers this commit instead. - base=$(git describe --tags --abbrev=0) - tags=$(git tag --sort=-version:refname) - newest=${tags%%"$newline"*} - if [ "$newest" != "$base" ]; then - printf '::notice::Release %s is not in the history of %s yet. The Release run after its merge into main releases whatever is left.\n' "$newest" "$head" - printf 'proceed=false\n' >>"$GITHUB_OUTPUT" - exit 0 - fi - - # Release merges and the release commits they bring in are part of $base. - # Anything else is unreleased, including commits that landed while the - # previous release was pending. - unreleased=$(git rev-list --no-merges --count "$base..HEAD") - if [ "$unreleased" -eq 0 ]; then - printf '::notice::Nothing to release: no commits since %s.\n' "$base" - printf 'proceed=false\n' >>"$GITHUB_OUTPUT" - exit 0 - fi - printf '%s commit(s) since %s to release.\n' "$unreleased" "$base" +skip() { + printf '::notice::%s\n' "$1" + printf 'proceed=false\n' >>"$GITHUB_OUTPUT" + exit 0 +} + +# Craft picks the next version from the latest tag in HEAD's history. If a +# newer release exists outside it, HEAD predates that release's merge into main +# (Builds can finish out of order, and a release is tagged before it is +# merged). The Release run for that merge covers whatever is left. +base=$(git describe --tags --abbrev=0) +tags=$(git tag --sort=-version:refname) +newest=${tags%%"$newline"*} +if [ "$newest" != "$base" ]; then + skip "Release $newest is not in the history of $head yet. The Release run after its merge into main releases whatever is left." fi -package=$(jq -er .name package.json) -npm_versions=$(npm view "$package" versions --json) -branches=$(git ls-remote --heads "$remote" 'refs/heads/release/*') -open_requests=$(gh issue list -R "$publish_repo" --state open --limit 1000 --json number,title,labels) -pending= - -while read -r sha ref; do - [ -n "$ref" ] || continue - branch=${ref#refs/heads/} - version=${branch#release/} - - published=$(jq -r --arg v "$version" 'if type == "array" then . else [.] end | index($v) != null' <>"$GITHUB_OUTPUT" - exit 0 +# Craft would refuse with "release branch already exists". Merging the pending +# release into main triggers Build and then Release again, and that run +# releases this commit. +pending=$(git ls-remote --heads "$remote" 'refs/heads/release/*') +if [ -n "$pending" ]; then + skip "A release is still pending. The Release run after its merge into main will release $head." fi +printf '%s commit(s) since %s to release.\n' "$unreleased" "$base" printf 'proceed=true\n' >>"$GITHUB_OUTPUT" diff --git a/.github/workflows/release-pipeline-health.yml b/.github/workflows/release-pipeline-health.yml index 5ed5c465..80134990 100644 --- a/.github/workflows/release-pipeline-health.yml +++ b/.github/workflows/release-pipeline-health.yml @@ -9,6 +9,7 @@ on: - completed branches: - main + - release/** schedule: - cron: "23 8 * * *" workflow_dispatch: @@ -17,14 +18,9 @@ permissions: contents: read issues: write -# One writer at a time, so two failures cannot open two issues. -concurrency: - group: release-pipeline-health - cancel-in-progress: false - jobs: - # Build and Release on main: report failures, and close the issue once a - # Release succeeds. + # Build and Release on main and release branches: report failures, and close + # the issue once a Release succeeds. report-run: name: Report ${{ github.event.workflow_run.name }} result if: >- @@ -35,6 +31,12 @@ jobs: github.event.workflow_run.conclusion == 'failure' || (github.event.workflow_run.name == 'Release' && github.event.workflow_run.conclusion == 'success') ) + # One issue writer at a time, so two failures cannot open two issues. Only + # the issue writers share this group: a queued run replaces the one waiting + # before it, and that must never drop a failed release. + concurrency: + group: release-pipeline-issue + cancel-in-progress: false runs-on: ubuntu-latest env: GH_TOKEN: ${{ github.token }} @@ -46,20 +48,55 @@ jobs: - name: Report the failure if: github.event.workflow_run.conclusion == 'failure' env: - MESSAGE: ${{ env.RUN_LINK }} failed on `main` at ${{ env.COMMIT_LINK }}. + MESSAGE: ${{ env.RUN_LINK }} failed on `${{ github.event.workflow_run.head_branch }}` at ${{ env.COMMIT_LINK }}. run: .github/scripts/pipeline-issue.sh report - name: Close the issue if: github.event.workflow_run.conclusion == 'success' env: - MESSAGE: ${{ env.RUN_LINK }} succeeded on `main` at ${{ env.COMMIT_LINK }}. + MESSAGE: ${{ env.RUN_LINK }} succeeded on `${{ github.event.workflow_run.head_branch }}` at ${{ env.COMMIT_LINK }}. run: .github/scripts/pipeline-issue.sh resolve + # A release branch whose Build failed can never publish, and craft refuses to + # cut that version again while the branch exists. Drop it so the next push + # to main releases again. + drop-failed-release: + name: Drop the failed release + if: >- + github.event_name == 'workflow_run' && + github.event.workflow_run.name == 'Build' && + github.event.workflow_run.event == 'push' && + github.event.workflow_run.head_repository.full_name == github.repository && + github.event.workflow_run.conclusion == 'failure' && + startsWith(github.event.workflow_run.head_branch, 'release/') + runs-on: ubuntu-latest + steps: + - name: Get auth token + id: token + uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0 + with: + app-id: ${{ vars.SENTRY_RELEASE_BOT_CLIENT_ID }} + private-key: ${{ secrets.SENTRY_RELEASE_BOT_PRIVATE_KEY }} + + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + token: ${{ steps.token.outputs.token }} + + - run: .github/scripts/drop-failed-release.sh + env: + GH_TOKEN: ${{ steps.token.outputs.token }} + BRANCH: ${{ github.event.workflow_run.head_branch }} + SHA: ${{ github.event.workflow_run.head_sha }} + RUN_URL: ${{ github.event.workflow_run.html_url }} + # Catch releases that are stuck without a failing run, such as a release # branch that never merged. check-staleness: name: Check for a stuck release if: github.event_name != 'workflow_run' + concurrency: + group: release-pipeline-issue + cancel-in-progress: false runs-on: ubuntu-latest env: GH_TOKEN: ${{ github.token }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c96a24e7..6c520eb6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -78,13 +78,11 @@ jobs: - name: Check what to release id: preflight - env: - GH_TOKEN: ${{ steps.token.outputs.token }} - RELEASE_TRIGGER: ${{ github.event_name }} + if: github.event_name == 'workflow_run' run: .github/scripts/release-preflight.sh - name: Prepare release - if: steps.preflight.outputs.proceed == 'true' + if: github.event_name == 'workflow_dispatch' || steps.preflight.outputs.proceed == 'true' uses: getsentry/craft@2e29b0819113d334c15da3d8cac2138cb9dc111a # 3.9.0 env: GITHUB_TOKEN: ${{ steps.token.outputs.token }} From 3c8e84d70959d5794da5266325c9d4990e5c44cf Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Mon, 9 Nov 2026 17:52:29 +0000 Subject: [PATCH 4/6] ci: Close the pipeline issue only when Release prepares a release A Release run also succeeds when preflight skips, so treating any success as recovery closed alerts while nothing was releasing. --- .github/scripts/pipeline-issue.sh | 22 ++++++++++++++++--- .github/workflows/release-pipeline-health.yml | 7 ++++-- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/.github/scripts/pipeline-issue.sh b/.github/scripts/pipeline-issue.sh index 5fcc59b6..ef8ecfa0 100755 --- a/.github/scripts/pipeline-issue.sh +++ b/.github/scripts/pipeline-issue.sh @@ -1,13 +1,15 @@ #!/bin/sh # Keeps one issue open while the @sentry/api release pipeline is broken, so a # stuck release gets noticed. `report` opens the issue, or comments on it when -# it is already open. `resolve` closes it. +# it is already open. `resolve` closes it once the Release run RUN_ID has +# prepared a release. # # Usage: pipeline-issue.sh report|resolve # # Environment: # MESSAGE Markdown for the new issue, comment, or closing comment. -# GH_TOKEN Token with issues: write on this repository. +# RUN_ID For `resolve`: the Release run that succeeded. +# GH_TOKEN Token with issues: write and actions: read on this repository. # GITHUB_REPOSITORY comes from GitHub Actions. set -eu @@ -24,6 +26,20 @@ case "$action" in ;; esac +if [ "$action" = resolve ]; then + # Release also succeeds when preflight finds nothing to release or a release + # still pending, and neither shows that the pipeline works again. + jobs=$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/$RUN_ID/jobs") + prepared=$(jq '[.jobs[].steps[]? | select(.name == "Prepare release" and .conclusion == "success")] | length' <- @@ -54,7 +56,8 @@ jobs: - name: Close the issue if: github.event.workflow_run.conclusion == 'success' env: - MESSAGE: ${{ env.RUN_LINK }} succeeded on `${{ github.event.workflow_run.head_branch }}` at ${{ env.COMMIT_LINK }}. + MESSAGE: ${{ env.RUN_LINK }} prepared a release from `${{ github.event.workflow_run.head_branch }}` at ${{ env.COMMIT_LINK }}. + RUN_ID: ${{ github.event.workflow_run.id }} run: .github/scripts/pipeline-issue.sh resolve # A release branch whose Build failed can never publish, and craft refuses to From 4f930a2ef7340bea22731da3b5c60fb98149a473 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Mon, 9 Nov 2026 18:11:18 +0000 Subject: [PATCH 5/6] ci(release): Trim preflight and the failed-release drop Preflight keeps only the three cases where craft prepare would fail. The drop deletes the branch first and treats closing the publish request as best effort, since the release bot may not be allowed to close issues there. The tag check goes: craft merges and deletes a release branch once the release is out, so a published version never has a branch left to drop. --- .github/scripts/drop-failed-release.sh | 55 ++++++++------------------ .github/scripts/release-preflight.sh | 51 +++++++++--------------- 2 files changed, 36 insertions(+), 70 deletions(-) mode change 100755 => 100644 .github/scripts/drop-failed-release.sh diff --git a/.github/scripts/drop-failed-release.sh b/.github/scripts/drop-failed-release.sh old mode 100755 new mode 100644 index 4af70d71..208dbda6 --- a/.github/scripts/drop-failed-release.sh +++ b/.github/scripts/drop-failed-release.sh @@ -1,56 +1,35 @@ #!/bin/sh -# Drops a release whose Build failed: closes its publish request and deletes its -# release branch, so the next push to main cuts a fresh release instead of -# failing on "release branch already exists". Nobody fixes release branches by -# hand in this auto-release repository. +# Drops a release whose Build failed: deletes its release branch, so the next +# push to main cuts a fresh release instead of failing on "release branch +# already exists", then tries to close its publish request. # # Environment: # BRANCH Release branch whose Build failed, such as release/1.2.3. # SHA Commit that Build ran on. The branch is deleted only while it # still points there. # RUN_URL The failed Build run. -# GH_TOKEN Token that can delete branches here and comment on and close -# issues in the publish repository. +# GH_TOKEN Token that can delete branches here, and ideally close issues in +# the publish repository. # GITHUB_REPOSITORY and GITHUB_REPOSITORY_OWNER come from GitHub Actions. set -eu -remote=origin -# Craft files publish requests in /publish by default. -publish_repo="$GITHUB_REPOSITORY_OWNER/publish" ref="refs/heads/$BRANCH" -version=${BRANCH#release/} - -current=$(git ls-remote "$remote" "$ref") -if [ -z "$current" ]; then - printf '::notice::%s is already gone.\n' "$BRANCH" - exit 0 -fi -if [ "${current%%[[:space:]]*}" != "$SHA" ]; then - printf '::notice::%s no longer points at %s. Leaving it alone.\n' "$BRANCH" "$SHA" - exit 0 -fi +git push --force-with-lease="$ref:$SHA" origin ":$ref" -# Craft tags a release when it publishes it, so a tag means it is out already. -tag=$(git ls-remote --tags "$remote" "refs/tags/$version") -if [ -n "$tag" ]; then - printf '::warning::%s is already tagged. Leaving %s alone.\n' "$version" "$BRANCH" +# Craft files publish requests in /publish by default. Closing one is +# tidying up: an open request whose branch is gone cannot publish anything. +publish_repo="$GITHUB_REPOSITORY_OWNER/publish" +title="publish: $GITHUB_REPOSITORY@${BRANCH#release/}" +comment="Superseded: [Build failed]($RUN_URL) on \`$BRANCH\`, so its branch was deleted. The next push to \`main\` prepares a new release." +# List open requests instead of searching: search lags behind new issues. +if ! open_requests=$(gh issue list -R "$publish_repo" --state open --limit 1000 --json number,title); then + printf '::warning::Could not list publish requests. Close the one for %s by hand.\n' "$BRANCH" exit 0 fi - -title="publish: $GITHUB_REPOSITORY@$version" -open_requests=$(gh issue list -R "$publish_repo" --state open --limit 1000 --json number,title) -number=$(jq -r --arg t "$title" '[.[] | select(.title == $t)] | first | .number // empty' <>"$GITHUB_OUTPUT" exit 0 } -# Craft picks the next version from the latest tag in HEAD's history. If a -# newer release exists outside it, HEAD predates that release's merge into main -# (Builds can finish out of order, and a release is tagged before it is -# merged). The Release run for that merge covers whatever is left. +# "Release branch already exists". Once that release merges into main, the +# Release run for the merge commit releases whatever landed meanwhile. +pending=$(git ls-remote --heads origin 'refs/heads/release/*') +if [ -n "$pending" ]; then + skip 'A release is still pending.' +fi + +# Craft takes the next version from the latest tag in HEAD's history. A newer +# tag outside it means HEAD predates a release, and craft would cut that +# version again. base=$(git describe --tags --abbrev=0) tags=$(git tag --sort=-version:refname) -newest=${tags%%"$newline"*} +newest=${tags%%[[:space:]]*} if [ "$newest" != "$base" ]; then - skip "Release $newest is not in the history of $head yet. The Release run after its merge into main releases whatever is left." + skip "HEAD predates release $newest." fi -# Release merges and the release commits they bring in are part of $base. -# Anything else is unreleased, including commits that landed while the -# previous release was pending. -unreleased=$(git rev-list --no-merges --count "$base..HEAD") -if [ "$unreleased" -eq 0 ]; then - skip "Nothing to release: no commits since $base." -fi - -# Craft would refuse with "release branch already exists". Merging the pending -# release into main triggers Build and then Release again, and that run -# releases this commit. -pending=$(git ls-remote --heads "$remote" 'refs/heads/release/*') -if [ -n "$pending" ]; then - skip "A release is still pending. The Release run after its merge into main will release $head." +# "No commits found since the last release". Release merges count as released. +unreleased=$(git rev-list --no-merges "$base..HEAD") +if [ -z "$unreleased" ]; then + skip "Nothing to release since $base." fi -printf '%s commit(s) since %s to release.\n' "$unreleased" "$base" printf 'proceed=true\n' >>"$GITHUB_OUTPUT" From f62189b7be16aea414e1c70f8c143fc84912b2c6 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Mon, 9 Nov 2026 18:18:18 +0000 Subject: [PATCH 6/6] ci: Treat a gone or moved release branch as nothing to drop A rerun, or a branch that advanced after the failed Build, made the lease fail and the job go red. --- .github/scripts/drop-failed-release.sh | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/.github/scripts/drop-failed-release.sh b/.github/scripts/drop-failed-release.sh index 208dbda6..9c5916d4 100644 --- a/.github/scripts/drop-failed-release.sh +++ b/.github/scripts/drop-failed-release.sh @@ -14,7 +14,16 @@ set -eu ref="refs/heads/$BRANCH" -git push --force-with-lease="$ref:$SHA" origin ":$ref" +if ! git push --force-with-lease="$ref:$SHA" origin ":$ref"; then + # The lease fails when the branch is already gone (a rerun) or has moved on. + # Either way there is nothing to drop. Any other failure is a real one. + current=$(git ls-remote origin "$ref") + case "$current" in + "$SHA"*) exit 1 ;; + esac + printf '::notice::%s no longer points at %s. Nothing to drop.\n' "$BRANCH" "$SHA" + exit 0 +fi # Craft files publish requests in /publish by default. Closing one is # tidying up: an open request whose branch is gone cannot publish anything.