diff --git a/.github/scripts/drop-failed-release.sh b/.github/scripts/drop-failed-release.sh new file mode 100644 index 0000000..9c5916d --- /dev/null +++ b/.github/scripts/drop-failed-release.sh @@ -0,0 +1,44 @@ +#!/bin/sh +# 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 ideally close issues in +# the publish repository. +# GITHUB_REPOSITORY and GITHUB_REPOSITORY_OWNER come from GitHub Actions. +set -eu + +ref="refs/heads/$BRANCH" +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. +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 +number=$(jq -r --arg t "$title" 'map(select(.title == $t)) | first | .number // empty' <>"$GITHUB_OUTPUT" + exit 0 +} + +# "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%%[[:space:]]*} +if [ "$newest" != "$base" ]; then + skip "HEAD predates release $newest." +fi + +# "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 'proceed=true\n' >>"$GITHUB_OUTPUT" diff --git a/.github/workflows/release-pipeline-health.yml b/.github/workflows/release-pipeline-health.yml index 5ed5c46..cd86236 100644 --- a/.github/workflows/release-pipeline-health.yml +++ b/.github/workflows/release-pipeline-health.yml @@ -9,22 +9,20 @@ on: - completed branches: - main + - release/** schedule: - cron: "23 8 * * *" workflow_dispatch: permissions: + # Read the steps of a Release run, to tell a prepared release from a skip. + actions: read 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 run prepares a release. report-run: name: Report ${{ github.event.workflow_run.name }} result if: >- @@ -35,6 +33,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 +50,56 @@ 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 }} 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 + # 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 bbc24ce..6c520eb 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,21 @@ jobs: release: runs-on: ubuntu-latest name: Release a new version - # Craft merges release branches by SHA, producing "Merge commit '...'". + # Only commits whose Build passed on main, pushed to this repository. + # 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' || ( - !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 ) - # 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 +61,28 @@ 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 + if: github.event_name == 'workflow_run' + run: .github/scripts/release-preflight.sh + - name: Prepare release + 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 }}