name: Stable release # Tags the newest green commit on main as v, publishes the GitHub # Release, and fast-forwards refs/heads/stable to it (docs/releases.md). Team # installs follow stable, so this is the only thing that moves them. Runs # twice a week, Monday and Thursday at 14:00 UTC (the file keeps its # weekly-release.yml name so its run history and every `gh workflow run` # reference stay intact). stable moves at most once per 72 hours: a cut inside # that floor is refused with REFUSED_FLOOR and NEXT_ALLOWED; a scheduled run # gets 3 hours of grace for cron start jitter, a hand-run cut none. Two # exceptions pass it, one per cut and each with a ref: a security cut (the # security input, naming a GHSA, CVE or # the script ties to the released # commits) and a regression cut (the regression input, naming a # regression-labelled # and the earlier stable release v the behavior # worked in). Run it by hand with a # ref for a hotfix, or with dry_run to see the plan and change nothing. A pull request that touches # the release tooling runs the dry run against main with a read-only token, # which is the only pre-merge exercise of the real workflow. The candidate's # gate must already be green (free-tests and main-backstop check runs # succeeded, every check run finished and none red, no active freeze): this # workflow never runs tests or paid evals. Dispatch it from main: the cut job # runs only there, with main's tooling. on: schedule: # Monday and Thursday 14:00 UTC (07:00 PDT, 06:00 PST): exactly 72 h, then # 96 h, apart; the floor's scheduled grace covers a late Monday start. - cron: '0 14 * * 1,4' workflow_dispatch: inputs: dry_run: description: 'Print the plan (candidate, tag, stable move, notes) and change nothing' type: boolean default: true ref: description: 'Hotfix: release this commit of main instead of the newest green one (full 40-hex SHA)' type: string default: '' security: description: 'Security cut (needs ref): the reason, naming the GHSA-…, CVE-… or # it fixes; passes the 72-hour floor once verified and lands in the release notes' type: string default: '' regression: description: 'Regression cut (needs ref): the reason, naming the regression-labelled # it fixes and the earlier stable release v it worked in; passes the 72-hour floor once verified and lands in the release notes' type: string default: '' pull_request: paths: - '.github/workflows/weekly-release.yml' - 'scripts/release-cut.ts' # One cut at a time; a second dispatch waits for the first. A pull request's # dry run is its own group. concurrency: group: weekly-release-${{ github.event.pull_request.number || 'cut' }} cancel-in-progress: false permissions: {} jobs: cut: name: Cut the release if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' runs-on: ubuntu-24.04 timeout-minutes: 20 permissions: # The tag, the stable branch and the release. contents: write # Reads the candidate's check runs. checks: read # A security or regression cut reads the label on the # its reason names. issues: read pull-requests: read steps: # The dispatched ref supplies the tooling; the release candidate always # comes from origin/main, which fetch-depth 0 brings along with every # branch. - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: fetch-depth: 0 - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: bun-version: 1.4.2 - name: Fetch stable and the tags run: | set -euo pipefail git fetch --quiet origin '+refs/heads/stable:refs/remotes/origin/stable' 2>/dev/null || echo "origin has no stable yet: this cut creates it" git fetch --quiet --tags origin git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - name: Plan and cut env: GH_TOKEN: ${{ github.token }} DRY_RUN: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run && 'true' || 'false' }} RELEASE_REF: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || '' }} SECURITY_REASON: ${{ github.event_name == 'workflow_dispatch' && inputs.security || '' }} REGRESSION_REASON: ${{ github.event_name == 'workflow_dispatch' && inputs.regression || '' }} run: | set -euo pipefail if [ -n "$RELEASE_REF" ] && ! [[ "$RELEASE_REF" =~ ^[0-9a-f]{40}$ ]]; then echo "::error::ref must be the full 40-hex SHA of a commit on main" exit 1 fi if [ -n "$SECURITY_REASON" ] && [ -z "$RELEASE_REF" ]; then echo "::error::a security cut needs ref: the full SHA of the fix commit on main" exit 1 fi if [ -n "$REGRESSION_REASON" ] && [ -z "$RELEASE_REF" ]; then echo "::error::a regression cut needs ref: the full SHA of the fix commit on main" exit 1 fi args=(--repo "$GITHUB_REPOSITORY" --summary "$GITHUB_STEP_SUMMARY") [ "$DRY_RUN" = true ] && args+=(--dry-run) [ -n "$RELEASE_REF" ] && args+=(--ref "$RELEASE_REF") [ -n "$SECURITY_REASON" ] && args+=(--security "$SECURITY_REASON") [ -n "$REGRESSION_REASON" ] && args+=(--regression "$REGRESSION_REASON") echo "## Stable release ($([ "$DRY_RUN" = true ] && echo 'dry run' || echo 'cut'), $GITHUB_EVENT_NAME)" >> "$GITHUB_STEP_SUMMARY" bun --no-env-file --config=/dev/null --no-install run scripts/release-cut.ts "${args[@]}" dry-run: name: Dry run against main if: github.event_name == 'pull_request' runs-on: ubuntu-24.04 timeout-minutes: 20 permissions: contents: read checks: read steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: fetch-depth: 0 persist-credentials: false - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: bun-version: 1.4.2 - name: Plan only env: GH_TOKEN: ${{ github.token }} run: | set -euo pipefail git fetch --quiet origin '+refs/heads/stable:refs/remotes/origin/stable' 2>/dev/null || echo "origin has no stable yet" git fetch --quiet --tags origin echo "## Stable release (dry run from PR #${{ github.event.pull_request.number }})" >> "$GITHUB_STEP_SUMMARY" bun --no-env-file --config=/dev/null --no-install run scripts/release-cut.ts --repo "$GITHUB_REPOSITORY" --summary "$GITHUB_STEP_SUMMARY" --dry-run