gbd 1.9.0

The Beads agent workflow on GitHub Issues and Projects
Documentation
name: release

# Release-on-merge, stage one: every push to `main` computes a semver bump
# from conventional commits since the last release. If one is warranted,
# this workflow bumps Cargo.toml/Cargo.lock, renders the changelog, commits,
# tags, pushes, and dispatches `build-release.yml` on that tag, which builds
# the tarballs, signs and attests them, and publishes the GitHub Release. If
# no commit in range warrants a release (docs/chore-only merges), the run
# skips quietly.
#
# Why two workflows: tags pushed with the default GITHUB_TOKEN do not start
# tag-triggered workflows (GitHub's recursion guard), so the build cannot
# simply listen for `v*` tags. workflow_dispatch is the documented exception
# to that guard, and dispatching stage two ON the tag makes its `github.sha`
# the release commit, which is what the build-provenance attestation records
# and what `gbd --version` embeds. Building here instead would attest the
# pre-bump merge commit. The release commit pushed back to `main` cannot
# re-trigger this workflow: no loop.
#
# Pushing the release commit to a protected `main` needs a credential the
# ruleset's bypass list covers. The workflow's own GITHUB_TOKEN is rejected
# ("Changes must be made through the merge queue"), and GitHub Actions
# cannot be added as a bypass actor unless it is an installed app. So the
# version job pushes with RELEASE_TOKEN: a fine-grained personal access
# token (Contents: read and write, this repository only) belonging to a
# repository admin, which the ruleset's admin bypass already allows. The
# release commit carries [skip ci] so that push does not start another
# run of this workflow or of CI.
#
# First release: a repo with no `v*` tags treats the last version as v0.0.0,
# so a `feat:` squash-merge becomes v0.1.0.

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

# Releases must QUEUE, not cancel each other. This lock is held for the
# whole release: the version job waits for the stage-two run it dispatches,
# so builds and publishes happen in tag order even when merges are quick.
# GitHub keeps at most one pending run per group; a pending stage-one run
# that gets superseded is harmless, because the newer run computes its bump
# from main HEAD and folds the superseded commits into one version before
# any tag exists.
concurrency:
  group: release-main
  cancel-in-progress: false

# Default to read; jobs opt up to write where they actually need it.
permissions:
  contents: read

env:
  CARGO_TERM_COLOR: always
  RUST_BACKTRACE: 1
  # GitHub Actions deprecates Node.js 20 in 2026: forced default flips to Node 24
  # on June 2 2026 and Node 20 is removed on September 16 2026. Opt into Node 24
  # now so the deprecation warnings stop.
  FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"

jobs:
  version:
    name: version
    runs-on: ubuntu-22.04
    permissions:
      contents: write
      actions: write # to dispatch build-release.yml
    outputs:
      tag: ${{ steps.compute.outputs.tag }}
      release: ${{ steps.compute.outputs.release }}
    steps:
      # Check out `main` (not github.sha): a queued run picks up release
      # commits pushed by the run before it. No credential is persisted:
      # RELEASE_TOKEN can rewrite protected main, so it is handed to git
      # only inside the push step, never left in the checkout for the tool
      # installers or anything else in this job to read.
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        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 # v2.87.19
        with:
          tool: cargo-edit

      - name: Install git-cliff
        if: steps.compute.outputs.release == 'true'
        uses: taiki-e/install-action@7623a79cdfecb99d681017af368ca353d9f49bb5 # v2.87.19
        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"

      # The only step that can see RELEASE_TOKEN. The credential helper reads
      # it from this step's environment, so it is never written to disk, to
      # git config, or to a URL.
      - 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