Skip to content
44 changes: 44 additions & 0 deletions .github/scripts/drop-failed-release.sh
Original file line number Diff line number Diff line change
@@ -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 <owner>/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' <<EOF
$open_requests
EOF
)
if [ -n "$number" ] && ! gh issue close "$number" -R "$publish_repo" --comment "$comment"; then
printf '::warning::Could not close %s#%s. Close it by hand.\n' "$publish_repo" "$number"
fi
22 changes: 19 additions & 3 deletions .github/scripts/pipeline-issue.sh
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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' <<EOF
$jobs
EOF
)
if [ "$prepared" -eq 0 ]; then
printf 'Release run %s did not prepare a release. Leaving the issue as it is.\n' "$RUN_ID"
exit 0
fi
fi

open_issues=$(gh issue list -R "$GITHUB_REPOSITORY" --state open --limit 1000 --json number,title)
number=$(jq -r --arg t "$title" '[.[] | select(.title == $t)] | first | .number // empty' <<EOF
$open_issues
Expand All @@ -46,7 +62,7 @@ if [ -n "$number" ]; then
exit 0
fi

body="The \`@sentry/api\` release pipeline needs attention. New failures are added here as comments, and the next successful Release run closes this issue.
body="The \`@sentry/api\` release pipeline needs attention. New failures are added here as comments, and the next Release run that prepares a release closes this issue.

$MESSAGE

Expand Down
36 changes: 36 additions & 0 deletions .github/scripts/release-preflight.sh
Comment thread
sentry-junior[bot] marked this conversation as resolved.
Resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
#!/bin/sh
# Decides whether an automatic Release run cuts a release from HEAD, a commit
# whose Build passed on main. Writes proceed=true or proceed=false to
# $GITHUB_OUTPUT. Each skip is a case where `craft prepare` would fail.
set -eu

skip() {
printf '::notice::%s\n' "$1"
printf 'proceed=false\n' >>"$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"
58 changes: 49 additions & 9 deletions .github/workflows/release-pipeline-health.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: >-
Expand All @@ -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 }}
Expand All @@ -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 }}
Expand Down
38 changes: 31 additions & 7 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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
Expand All @@ -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'
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
uses: getsentry/craft@2e29b0819113d334c15da3d8cac2138cb9dc111a # 3.9.0
env:
GITHUB_TOKEN: ${{ steps.token.outputs.token }}
Expand Down
Loading