name: release
on:
push:
branches: [main]
workflow_dispatch:
inputs:
force_bump:
description: 'Force a release at this bump level regardless of commit history'
type: choice
default: auto
options:
- auto
- patch
- minor
- major
concurrency:
group: release-main
cancel-in-progress: false
permissions:
contents: read
env:
CARGO_TERM_COLOR: always
RUST_BACKTRACE: 1
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
jobs:
version:
name: version
runs-on: ubuntu-22.04
permissions:
contents: write
actions: write outputs:
tag: ${{ steps.compute.outputs.tag }}
release: ${{ steps.compute.outputs.release }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with:
ref: main
fetch-depth: 0
fetch-tags: true
persist-credentials: false
- name: Compute next version
id: compute
env:
FORCE_BUMP: ${{ inputs.force_bump }}
run: |
set -euo pipefail
# Idempotency guard: if HEAD is already tagged, this is a re-run
# (e.g. a retried build/smoke/release leg) — reuse that tag rather
# than computing (and re-pushing) a new one. The bump step below
# re-checks this and no-ops if so.
if TAG=$(git describe --tags --exact-match --match 'v*' HEAD 2>/dev/null); then
echo "HEAD is already tagged $TAG — treating as a re-run"
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "release=true" >> "$GITHUB_OUTPUT"
exit 0
fi
if LAST=$(git describe --tags --abbrev=0 --match 'v*' 2>/dev/null); then
RANGE="${LAST}..HEAD"
else
# No v* tags yet. Seed 0.0.0 and scan the whole reachable history.
LAST="v0.0.0"
RANGE="HEAD"
fi
LAST_VERSION="${LAST#v}"
if [ -n "${FORCE_BUMP:-}" ] && [ "$FORCE_BUMP" != "auto" ]; then
IFS='.' read -r major minor patch <<< "$LAST_VERSION"
case "$FORCE_BUMP" in
major) NEXT="$((major + 1)).0.0" ;;
minor) NEXT="${major}.$((minor + 1)).0" ;;
patch) NEXT="${major}.${minor}.$((patch + 1))" ;;
*) echo "unknown force_bump: $FORCE_BUMP" >&2; exit 1 ;;
esac
else
NEXT=$(bash scripts/next-version.sh "$LAST_VERSION" "$RANGE")
fi
if [ -z "$NEXT" ]; then
echo "no releasable commits since $LAST — skipping release"
echo "release=false" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "next version: $NEXT (from $LAST via $RANGE)"
echo "tag=v$NEXT" >> "$GITHUB_OUTPUT"
echo "release=true" >> "$GITHUB_OUTPUT"
- name: Require RELEASE_TOKEN
if: steps.compute.outputs.release == 'true'
env:
HAVE_TOKEN: ${{ secrets.RELEASE_TOKEN != '' }}
run: |
if [ "$HAVE_TOKEN" != "true" ]; then
cat >&2 <<'MSG'
RELEASE_TOKEN is not set, so the release commit cannot be pushed to the
protected main branch (the workflow token is rejected by the ruleset).
Create a fine-grained personal access token as a repository admin:
Settings → Developer settings → Fine-grained tokens → Generate new token
Resource owner: the organization; Repository access: only this repo;
Permissions → Repository → Contents: Read and write.
Then: gh secret set RELEASE_TOKEN --repo <owner>/<repo>
and re-run this workflow (Actions → release → Run workflow).
MSG
exit 1
fi
- name: Install cargo-edit
if: steps.compute.outputs.release == 'true'
uses: taiki-e/install-action@7623a79cdfecb99d681017af368ca353d9f49bb5 with:
tool: cargo-edit
- name: Install git-cliff
if: steps.compute.outputs.release == 'true'
uses: taiki-e/install-action@7623a79cdfecb99d681017af368ca353d9f49bb5 with:
tool: git-cliff
- name: Bump, render changelog, tag, and push
if: steps.compute.outputs.release == 'true'
env:
TAG: ${{ steps.compute.outputs.tag }}
run: |
set -euo pipefail
# If the idempotency guard above already found this exact tag at
# HEAD, there is nothing left to bump — this is a re-run of a
# downstream leg (build/smoke/release), not a fresh version.
if git rev-parse -q --verify "refs/tags/$TAG" >/dev/null 2>&1 \
&& [ "$(git rev-parse "$TAG")" = "$(git rev-parse HEAD)" ]; then
echo "$TAG already exists at HEAD — nothing to bump"
exit 0
fi
NEXT="${TAG#v}"
# `cargo set-version` updates Cargo.toml only. `cargo update --workspace`
# rewrites the root package version in Cargo.lock so `cargo build
# --locked` in the build job does not fail on a stale lockfile.
cargo set-version "$NEXT"
cargo update --workspace
git cliff --tag "$TAG" -o CHANGELOG.md
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add Cargo.toml Cargo.lock CHANGELOG.md
# [skip ci]: this push must not start another release run or CI.
git commit -m "chore(release): $TAG [skip ci]"
git tag "$TAG"
- name: Push release commit and tag
if: steps.compute.outputs.release == 'true'
env:
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
TAG: ${{ steps.compute.outputs.tag }}
run: |
set -euo pipefail
if git rev-parse -q --verify "refs/tags/$TAG" >/dev/null 2>&1 \
&& git ls-remote --exit-code --tags origin "refs/tags/$TAG" >/dev/null 2>&1; then
echo "$TAG is already on origin — nothing to push"
exit 0
fi
# --atomic is load-bearing: without it, a raced push can PARTIALLY
# succeed — main rejected as non-fast-forward while the tag still
# lands — leaving a stray tag pointing at a commit that never
# reached main.
git -c credential.helper= \
-c credential.helper='!f() { echo "username=x-access-token"; echo "password=$RELEASE_TOKEN"; }; f' \
push --atomic origin main "$TAG"
- name: Dispatch the build on the tag and wait for it
if: steps.compute.outputs.release == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ steps.compute.outputs.tag }}
run: |
set -euo pipefail
SINCE=$(date -u +%Y-%m-%dT%H:%M:%SZ)
gh workflow run build-release.yml --ref "$TAG" -f tag="$TAG"
echo "dispatched build-release.yml on $TAG; waiting for the run to appear"
RUN=""
for _ in $(seq 1 30); do
RUN=$(gh run list --workflow build-release.yml --branch "$TAG" --event workflow_dispatch \
--created ">=$SINCE" --limit 1 --json databaseId --jq '.[0].databaseId // empty')
[ -n "$RUN" ] && break
sleep 5
done
if [ -z "$RUN" ]; then
echo "build-release.yml run for $TAG did not appear; re-dispatch by hand:" >&2
echo " gh workflow run build-release.yml --ref $TAG -f tag=$TAG" >&2
exit 1
fi
echo "watching run $RUN (this job holds the release lock until it finishes)"
gh run watch "$RUN" --exit-status --interval 30