name: Release # Publishes a GitHub release for every VERSION bump that lands on master: # tag + title `v`, notes from that version's CHANGELOG.md entry, # compiled binaries attached (#3521). # # Why every bump: `gbrain check-update` resolves the latest version from the # VERSION file on master, but binary self-update # (src/core/binary-self-update.ts) downloads assets from `releases/latest`. # If releases lag VERSION, binary installs are told an upgrade exists that # self-update cannot apply. Keeping releases/latest == VERSION closes that gap. # # Idempotent: the `version` job skips build+release when a release for # v already exists WITH all expected assets. A half-published release # (tag exists / assets incomplete) is repaired on the next run — softprops # updates the existing release in place. Historical 3-segment tags are never # touched; a new 4-segment VERSION always mints a new tag. # # The asset names are a contract with expectedAssetName() in # src/core/binary-self-update.ts, pinned by test/release-workflow.test.ts. on: push: branches: [master] paths: [VERSION] workflow_dispatch: {} # manual first run / backfill of the current VERSION permissions: contents: read concurrency: group: release cancel-in-progress: false jobs: version: runs-on: ubuntu-latest outputs: version: ${{ steps.v.outputs.version }} exists: ${{ steps.v.outputs.exists }} template_tree_hash: ${{ steps.v.outputs.template_tree_hash }} steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - id: v name: Read VERSION and check for an existing complete release env: GH_TOKEN: ${{ github.token }} run: | version="$(tr -d '[:space:]' < VERSION)" echo "version=$version" >> "$GITHUB_OUTPUT" # [C3] Record the vendored template tree's git hash alongside the # asset completeness check. The publish-template job embeds it in # the template repo's commit message, so template-repo HEAD can be # audited back to the exact reviewed tree in THIS repo. Empty until # templates/bootstrap/template-repo/ is vendored. tree_hash="$(git rev-parse "HEAD:templates/bootstrap/template-repo" 2>/dev/null || true)" echo "template_tree_hash=$tree_hash" >> "$GITHUB_OUTPUT" # Complete = release exists AND carries every asset the self-updater # can request. A partial release must NOT short-circuit, so a re-run # can repair it. assets="$(gh release view "v$version" --repo "$GITHUB_REPOSITORY" \ --json assets --jq '[.assets[].name] | sort | join(",")' 2>/dev/null || true)" if [ "$assets" = "gbrain-darwin-arm64,gbrain-linux-x64" ]; then echo "exists=true" >> "$GITHUB_OUTPUT" echo "Release v$version already published with all assets — nothing to do." else echo "exists=false" >> "$GITHUB_OUTPUT" fi build: needs: version if: needs.version.outputs.exists == 'false' strategy: matrix: include: - os: macos-latest target: bun-darwin-arm64 artifact: gbrain-darwin-arm64 - os: ubuntu-latest target: bun-linux-x64 artifact: gbrain-linux-x64 runs-on: ${{ matrix.os }} permissions: contents: read id-token: write # for attest-build-provenance (Sigstore OIDC) attestations: write # for attest-build-provenance steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: bun-version: 1.3.13 - run: bun install # No test re-run here: the Test workflow already gated this exact SHA at # merge (10 shards + E2E). Re-running the whole suite serially on the # release runner is a flakier duplicate gate — it blocked the first # release on ambient-env tests (run 30698650484). The build job's gate # is the artifact itself: compile, then smoke-test the binary. - run: bun build --compile --target=${{ matrix.target }} --outfile bin/${{ matrix.artifact }} src/cli.ts - name: Smoke-test the compiled binary run: | chmod +x bin/${{ matrix.artifact }} out="$(./bin/${{ matrix.artifact }} --version)" echo "binary reports: $out" v="$(tr -d '[:space:]' < VERSION)" case "$out" in *"$v"*) echo "version matches VERSION file" ;; *) echo "binary version '$out' does not contain '$v'" >&2; exit 1 ;; esac - name: Attest build provenance uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 with: subject-path: bin/${{ matrix.artifact }} - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: ${{ matrix.artifact }} path: bin/${{ matrix.artifact }} release: needs: [version, build] if: needs.version.outputs.exists == 'false' runs-on: ubuntu-latest permissions: contents: write # create the tag + release (scoped to this job only) steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: path: artifacts - name: Extract CHANGELOG entry for release notes # env-bound, not inlined into the script: VERSION comes from master so # it isn't attacker-reachable today, but a `${{ }}` inside `run:` is # shell injection by construction if that ever changes. env: RELEASE_VERSION: ${{ needs.version.outputs.version }} run: | v="$RELEASE_VERSION" if ! bash scripts/changelog-entry.sh "$v" > /tmp/release-notes.md || ! [ -s /tmp/release-notes.md ]; then echo "See [CHANGELOG.md](https://github.com/${GITHUB_REPOSITORY}/blob/master/CHANGELOG.md) for v$v." > /tmp/release-notes.md fi - name: Create release uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2 with: tag_name: v${{ needs.version.outputs.version }} name: v${{ needs.version.outputs.version }} target_commitish: ${{ github.sha }} body_path: /tmp/release-notes.md fail_on_unmatched_files: true files: | artifacts/gbrain-darwin-arm64/gbrain-darwin-arm64 artifacts/gbrain-linux-x64/gbrain-linux-x64 # [C1 = D6-A] `latest-stable` is the single sanctioned distribution ref: # the README paste block, the BOOTSTRAP_FOR_AGENTS.md fetch URL, and # `bun install -g github:garrytan/gbrain#latest-stable` all reference it # permanently, so paste blocks copied into the wild never rot and there # is no 404 window between VERSION landing and assets publishing. It is # a maintainer-controlled tag advanced ONLY here — the FINAL step of the # release job, after binaries + provenance attestation have fully # published — so a half-built release never moves it. The force-move # (`+`) is intentional: latest-stable tracks the newest verified release; # per-release history lives in the immutable v tags. # scripts/check-bootstrap-tag.sh keeps the entry docs pinned to this ref. # # If this step ALONE fails, re-advance by hand (a full re-run would skip: # the release already exists with all assets): # git push origin "+refs/tags/v^{commit}:refs/tags/latest-stable" - name: Advance latest-stable to this release commit run: git push origin "+${GITHUB_SHA}:refs/tags/latest-stable" # [G7/S3#4] Publishes the rendered agent-workspace template repo (the # GitHub "Use this template" door) from CI ONLY — no human pushes it by # hand, so what adopters clone is exactly what this repo reviewed. Guarded # three ways: # 1. The release above fully published (needs: release + the exists gate). # 2. The vendored tree templates/bootstrap/template-repo/ exists — the # generator/doors work may not have landed yet; skip, never fail. # 3. The TEMPLATE_REPO_PAT secret is configured. Secrets are not readable # in job-level `if:` expressions, so the secret is bound to env (the # same env-indirection pattern as the release-notes step) and checked # by the gate step's shell. # # TEMPLATE_REPO_PAT scope: a fine-grained PAT with `contents: write` on the # template repository ONLY — no other repositories, no other permissions. # Documented in docs/RELEASING.md. publish-template: needs: [version, release] if: needs.version.outputs.exists == 'false' runs-on: ubuntu-latest permissions: contents: read env: TEMPLATE_REPO_PAT: ${{ secrets.TEMPLATE_REPO_PAT }} # owner/name of the template repo; override via repository variable. TEMPLATE_REPO: ${{ vars.TEMPLATE_REPO || 'garrytan/gbrain-agent-template' }} steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - id: gate name: Gate on PAT + vendored template tree run: | if [ -z "$TEMPLATE_REPO_PAT" ]; then echo "publish=false" >> "$GITHUB_OUTPUT" echo "SKIP: TEMPLATE_REPO_PAT secret not configured — template-repo publishing disabled." elif [ ! -d templates/bootstrap/template-repo ]; then echo "publish=false" >> "$GITHUB_OUTPUT" echo "SKIP: templates/bootstrap/template-repo/ not vendored yet — nothing to publish." else echo "publish=true" >> "$GITHUB_OUTPUT" fi - if: steps.gate.outputs.publish == 'true' uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: bun-version: 1.3.13 - if: steps.gate.outputs.publish == 'true' run: bun install - if: steps.gate.outputs.publish == 'true' name: Generate template tree and byte-diff against the vendored copy run: | bun run scripts/generate-template-repo.ts --out /tmp/template-tree # [A1] Publish gate: fresh generator output must equal the vendored # tree byte-for-byte. A mismatch means the vendored tree is stale — # regenerate + commit it (scripts/check-bootstrap-templates.sh runs # this same diff offline in `bun run verify`). diff -r /tmp/template-tree templates/bootstrap/template-repo - if: steps.gate.outputs.publish == 'true' name: Force-push the template repo env: RELEASE_VERSION: ${{ needs.version.outputs.version }} TEMPLATE_TREE_HASH: ${{ needs.version.outputs.template_tree_hash }} run: | set -euo pipefail cd /tmp/template-tree git init -q -b main git config user.name "gbrain-release-bot" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git add -A # History-less by design: each release force-publishes one commit # whose message binds (gbrain version, vendored tree hash) [C3]. git commit -q -m "gbrain v${RELEASE_VERSION} template (tree ${TEMPLATE_TREE_HASH:-unvendored})" # Out-of-band credential: a one-shot GIT_ASKPASS script reads the # PAT from env at prompt time, so the token never rides argv (where # `ps`, runner traces, and error messages echoing the remote URL # could surface it). ASKPASS="$(mktemp)" # shellcheck disable=SC2016 # $1/$TEMPLATE_REPO_PAT are literal on # purpose — they must expand when /bin/sh runs the askpass script at # git's credential prompt, not when this outer shell writes the file. printf '%s\n' '#!/bin/sh' \ 'case "$1" in Username*) printf "x-access-token\n" ;; *) printf "%s\n" "$TEMPLATE_REPO_PAT" ;; esac' \ > "$ASKPASS" chmod +x "$ASKPASS" GIT_ASKPASS="$ASKPASS" GIT_TERMINAL_PROMPT=0 \ git push --force "https://github.com/${TEMPLATE_REPO}.git" HEAD:main rm -f "$ASKPASS"