name: Auto release
on:
push:
branches: [main]
schedule:
- cron: "31 4 * * 5"
workflow_dispatch:
inputs:
force:
description: "Open the bump PR even when the recommendation is 'none'"
type: boolean
default: false
permissions:
contents: read
concurrency:
group: auto-release
cancel-in-progress: false
jobs:
bump:
name: open the version-bump PR
runs-on: ubuntu-26.04
timeout-minutes: 15
permissions:
contents: write
pull-requests: write
actions: write
steps:
- uses: actions/checkout@v7
timeout-minutes: 2
with:
fetch-depth: 0
fetch-tags: true
- uses: actions/setup-node@v7
timeout-minutes: 5
with:
node-version: "26"
- name: Compute the next version
id: version
timeout-minutes: 5
run: |
OUT="$(node scripts/generate-changelog.ts 2>&1)"
echo "$OUT" | grep -E '^changelog:' || true
BUMP="$(echo "$OUT" | grep -E 'changelog:recommended-bump=' | head -1 | cut -d= -f2)"
NEXT="$(echo "$OUT" | grep -E 'changelog:recommended-version=' | head -1 | cut -d= -f2)"
CURRENT="$(grep -m1 '^version' Cargo.toml | sed 's/version = "\(.*\)"/\1/')"
# Race guard: tag-release cuts the tag for this same push, so the
# recommendation can name the version main is ALREADY at (next
# v0.8.0, current 0.8.0) — the bump PR would carry an empty diff.
# Nothing to do; the tag workflow owns this version.
if [ "${NEXT#v}" = "$CURRENT" ]; then
echo "::notice::main is already at ${NEXT#v}; the tag workflow owns this version."
BUMP="none"
fi
echo "bump=${BUMP:-none}" >> "$GITHUB_OUTPUT"
echo "next=${NEXT:-$CURRENT}" >> "$GITHUB_OUTPUT"
echo "current=$CURRENT" >> "$GITHUB_OUTPUT"
echo "recommendation: bump=${BUMP:-none} next=${NEXT:-$CURRENT} current=$CURRENT"
- name: Bump Cargo.toml
if: steps.version.outputs.bump != 'none' || inputs.force == true
timeout-minutes: 2
env:
NEXT: ${{ steps.version.outputs.next }}
run: |
# NEXT arrives with a leading "v" (v0.7.0) because that is how the
# generator names a version. Cargo wants a bare semver: `version =
# "v0.7.0"` is a hard parse error, and it is precisely what produced
# the orphan `vv0.6.0` tag, because tag-release.yml builds its tag as
# "v$VERSION" and the "v" in the manifest became a second one.
BARE="${NEXT#v}"
sed -i "s/^version = \".*\"/version = \"$BARE\"/" Cargo.toml
# Fail here, loudly, rather than letting a malformed manifest reach
# the tag step and the release three workflows downstream.
if ! grep -q "^version = \"$BARE\"$" Cargo.toml; then
echo "::error::Cargo.toml version did not land as '$BARE'"
exit 1
fi
grep -m1 '^version' Cargo.toml
- name: Verify the other generated artifacts are current
if: steps.version.outputs.bump != 'none' || inputs.force == true
timeout-minutes: 5
run: |
node scripts/generate-commands.ts --check
node scripts/generate-skills.ts --check
node scripts/generate-coverage.ts --check
- name: Open the bump PR with auto-merge
if: steps.version.outputs.bump != 'none' || inputs.force == true
timeout-minutes: 5
env:
NEXT: ${{ steps.version.outputs.next }}
GH_TOKEN: ${{ github.token }}
run: |
# NEXT already carries the leading "v" (v0.7.0); prefixing another
# one produced the malformed chore/release-vv0.7.0.
BRANCH="chore/release-$NEXT"
# A push that lands while the bump PR is still open must not
# force-push the branch out from under its own CI run: every
# recreate restarts the merge clock. If a PR for this exact
# version is already open, there is nothing to do.
if gh pr list --state open --head "$BRANCH" --json number --jq 'length' | grep -q '^[1-9]'; then
echo "::notice::$BRANCH already has an open PR; leaving it to merge."
exit 0
fi
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git checkout -b "$BRANCH"
# A previous run for this same version can leave the throwaway
# branch behind (an abandoned PR). Drop it first so the push is a
# clean create instead of a non-fast-forward rejection, which would
# otherwise make the weekly release unretryable.
git push origin --delete "$BRANCH" 2>/dev/null || true
# Cargo.toml only: the changelog files belong to the changelog
# workflow (see the compute step), and the lock is untracked.
git add Cargo.toml
git commit -m "chore(release): grr $NEXT"
git push origin "$BRANCH"
# No `|| true` here: the open-PR guard above already exits when this
# exact branch has a PR, so an `already exists` failure cannot be the
# benign case — any failure now is real and must be visible.
gh pr create \
--title "chore(release): grr $NEXT" \
--body "Automated version bump — computed by the changelog generator from the conventional-commit history; opened by auto-release.yml. Merging publishes $NEXT: tag-release.yml tags, release-binaries.yml builds the 5 platforms, publish.yml ships to crates.io." \
--base main --head "$BRANCH" --label dependencies
# Auto-merge: the GITHUB_TOKEN cannot push to protected main, but
# it can enable GitHub's native auto-merge, which completes when
# required checks pass.
gh pr merge "$BRANCH" --squash --auto || true
# The poller's cron adds up to 10 minutes before it approves the
# bump PR's CI runs (token-authored PRs land in action_required);
# waking it now keeps the release chain snappy. Idempotent.
gh workflow run automation-merge.yml --ref main || echo "::warning::poller dispatch failed"
gh pr view "$BRANCH" --json url --jq .url
- name: Nothing further
if: ${{ steps.version.outputs.bump == 'none' && inputs.force != true }}
run: 'echo "Nothing to release (recommendation: none)."'