From 9cecae4b250ef775e08a12f0a44a1dfeb0671a2e Mon Sep 17 00:00:00 2001 From: Andrew Kent Date: Wed, 9 Sep 2026 22:12:13 -0600 Subject: [PATCH] use PRs to play nice with branch protection rules have releasers merge a PR to bump version json for plugins that require it --- .github/workflows/_release.yml | 154 +++++++++++++++------------ .github/workflows/release-merged.yml | 94 ++++++++++++++++ .github/workflows/release.yml | 149 ++++++++++++++++++++++---- .github/workflows/test-release.yml | 1 + AGENTS.md | 40 +++++-- 5 files changed, 342 insertions(+), 96 deletions(-) create mode 100644 .github/workflows/release-merged.yml diff --git a/.github/workflows/_release.yml b/.github/workflows/_release.yml index 41cac4a..9f35b68 100644 --- a/.github/workflows/_release.yml +++ b/.github/workflows/_release.yml @@ -1,15 +1,13 @@ # Reusable release implementation. Not dispatchable on its own — called by -# release.yml (production repos) and test-release.yml (the shared test repo), -# which supply `dist_repo` and `record`. +# release-merged.yml (production repos) and test-release.yml (the shared test +# repo), which supply `source_sha`, `dist_repo`, and `record`. # -# Always: build and `make publish` to . Claude, Codex, and Grok -# releases also bump each plugin manifest version. Antigravity's native -# manifest has no version field, so its repository tag is the release version. -# When record=true (production): commit the bump to main when needed, tag -# v-, and create a GitHub Release on the monorepo. After the -# distribution is deployed, its repo gets an unsuffixed v tag and -# matching GitHub Release. Test runs (record=false) skip both sets of release -# records so they leave no trace and can be re-run with the same version. +# Always: build and `make publish` to from the selected source SHA. +# Production verifies the reviewed versions and release marker, then tags +# v- and creates a GitHub Release on the monorepo without +# changing its branches. After deployment, the distribution repo gets an +# unsuffixed v tag and matching GitHub Release. Test runs stamp +# versions on the runner and skip both sets of release records. name: _release @@ -22,11 +20,14 @@ on: plugin: required: true type: string + source_sha: + required: true + type: string dist_repo: required: true type: string record: - description: "Commit+tag+release on the monorepo (production) vs. deploy-only (test)." + description: "Tag+release the approved monorepo commit (production) vs. deploy-only (test)." required: true type: boolean secrets: @@ -55,42 +56,65 @@ jobs: - name: Checkout monorepo uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: + ref: ${{ inputs.source_sha }} fetch-depth: 0 - name: Validate inputs id: vars + env: + VERSION: ${{ inputs.version }} + PLUGIN: ${{ inputs.plugin }} + SOURCE_SHA: ${{ inputs.source_sha }} + DIST_REPO: ${{ inputs.dist_repo }} + RECORD: ${{ inputs.record }} + EVENT_NAME: ${{ github.event_name }} + SOURCE_REPO: ${{ github.repository }} + PR_MERGED: ${{ github.event.pull_request.merged }} + PR_HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }} + PR_BASE_REF: ${{ github.event.pull_request.base.ref }} + PR_MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }} run: | set -euo pipefail - version="${{ inputs.version }}"; version="${version#v}" + version="${VERSION#v}" if ! printf '%s' "$version" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then - echo "::error::version '${{ inputs.version }}' is not semver (MAJOR.MINOR.PATCH)."; exit 1 + echo "::error::version '$VERSION' is not semver (MAJOR.MINOR.PATCH)."; exit 1 + fi + case "$PLUGIN" in + antigravity|claude|codex|grok) ;; + *) echo "::error::Unsupported plugin '$PLUGIN'."; exit 1 ;; + esac + if ! printf '%s' "$SOURCE_SHA" | grep -Eq '^[0-9a-f]{40}$'; then + echo "::error::source_sha must be a full lowercase commit SHA."; exit 1 fi - tag="v$version-${{ inputs.plugin }}" + if [ "$(git rev-parse HEAD)" != "$SOURCE_SHA" ]; then + echo "::error::Checkout does not match source_sha."; exit 1 + fi + tag="v$version-$PLUGIN" dist_tag="v$version" - # Production releases must come from main. Existing monorepo release - # state is checked against HEAD below so an interrupted release can - # safely resume without moving a tag. - if [ "${{ inputs.record }}" = "true" ]; then - if [ "${{ github.ref }}" != "refs/heads/main" ]; then - echo "::error::Production releases must run on main (got '${{ github.ref }}')."; exit 1 + if [ "$RECORD" = "true" ]; then + if [ "$EVENT_NAME" != "pull_request" ] || + [ "$PR_MERGED" != "true" ] || + [ "$PR_HEAD_REPO" != "$SOURCE_REPO" ] || + [ "$PR_BASE_REF" != "main" ] || + [ "$PR_MERGE_SHA" != "$SOURCE_SHA" ]; then + echo "::error::Production releases require a merged same-repository PR targeting main at source_sha."; exit 1 fi git fetch --tags --quiet fi echo "version=$version" >> "$GITHUB_OUTPUT" echo "tag=$tag" >> "$GITHUB_OUTPUT" echo "dist_tag=$dist_tag" >> "$GITHUB_OUTPUT" - dist_repo="${{ inputs.dist_repo }}" - if ! printf '%s' "$dist_repo" | grep -Eq '^[^/[:space:]]+/[^/[:space:]]+$'; then - echo "::error::dist_repo '$dist_repo' must be owner/name."; exit 1 + if ! printf '%s' "$DIST_REPO" | grep -Eq '^[^/[:space:]]+/[^/[:space:]]+$'; then + echo "::error::dist_repo '$DIST_REPO' must be owner/name."; exit 1 fi - echo "dist_owner=${dist_repo%%/*}" >> "$GITHUB_OUTPUT" - echo "dist_name=${dist_repo#*/}" >> "$GITHUB_OUTPUT" - echo "Releasing $tag -> ${{ inputs.dist_repo }}@$dist_tag (record=${{ inputs.record }})" + echo "dist_owner=${DIST_REPO%%/*}" >> "$GITHUB_OUTPUT" + echo "dist_name=${DIST_REPO#*/}" >> "$GITHUB_OUTPUT" + echo "Releasing $tag -> $DIST_REPO@$dist_tag (record=$RECORD)" # Cross-repo work runs as the braintrust-bot GitHub App instead of a # long-lived PAT: the token is minted per run, expires in an hour, and is - # scoped to contents:write on the one distribution repo. Monorepo commits - # and tags still use the built-in GITHUB_TOKEN. This step fails loudly if + # scoped to contents:write on the one distribution repo. Monorepo tags + # still use the built-in GITHUB_TOKEN. This step fails loudly if # braintrust-bot is not installed on . - name: Generate distribution-repo token id: app-token @@ -103,6 +127,7 @@ jobs: permission-contents: write - name: Validate distribution release tag + id: dist-preflight if: ${{ inputs.record }} env: GH_TOKEN: ${{ steps.app-token.outputs.token }} @@ -118,66 +143,63 @@ jobs: exit 1 fi - - name: Bump plugin manifest versions - if: ${{ inputs.plugin != 'antigravity' }} - run: python3 scripts/set-plugin-version.py "${{ inputs.plugin }}" "${{ steps.vars.outputs.version }}" + - name: Stamp or verify reviewed release versions + id: stamp + env: + PLUGIN: ${{ inputs.plugin }} + VERSION: ${{ steps.vars.outputs.version }} + RECORD: ${{ inputs.record }} + run: | + set -euo pipefail + if [ "$PLUGIN" != "antigravity" ]; then + python3 scripts/set-plugin-version.py "$PLUGIN" "$VERSION" + if [ "$RECORD" = "true" ]; then + git diff --exit-code -- "src/plugins/$PLUGIN" + fi + fi + if [ "$RECORD" = "true" ]; then + if ! printf '%s\n' "$VERSION" | cmp -s - ".github/release-versions/$PLUGIN"; then + echo "::error::Release marker must contain exactly $VERSION followed by a newline."; exit 1 + fi + fi - name: Record monorepo release + id: source-release if: ${{ inputs.record }} env: GH_TOKEN: ${{ github.token }} + TAG: ${{ steps.vars.outputs.tag }} + SOURCE_SHA: ${{ inputs.source_sha }} run: | set -euo pipefail - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add -A - tag="${{ steps.vars.outputs.tag }}" - tag_exists=false - if git rev-parse -q --verify "refs/tags/$tag" >/dev/null; then - tag_exists=true - if ! git diff --cached --quiet; then - echo "::error::tag $tag already exists, but the requested version would change committed manifests." + if git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then + tag_sha="$(git rev-list -n 1 "refs/tags/$TAG")" + if [ "$tag_sha" != "$SOURCE_SHA" ]; then + echo "::error::tag $TAG points to $tag_sha, not the approved source SHA $SOURCE_SHA." exit 1 fi - fi - if ! git diff --cached --quiet; then - git commit -m "chore(${{ inputs.plugin }}): release v${{ steps.vars.outputs.version }}" - git push origin HEAD:main - else - echo "Plugin manifests already contain v${{ steps.vars.outputs.version }}; tagging the committed version." - fi - if [ "$tag_exists" = "true" ]; then - tag_sha="$(git rev-list -n 1 "$tag")" - head_sha="$(git rev-parse HEAD)" - if [ "$tag_sha" != "$head_sha" ]; then - plugin_dir="src/plugins/${{ inputs.plugin }}" - if ! git diff --quiet "$tag" HEAD -- "$plugin_dir"; then - echo "::error::tag $tag points to $tag_sha and $plugin_dir has changed at $head_sha." - exit 1 - fi - echo "Monorepo tag $tag points to $tag_sha; $plugin_dir is unchanged at $head_sha, so the interrupted release can resume." - else - echo "Monorepo tag $tag already points to the selected release commit; resuming." - fi + echo "Monorepo tag $TAG already points to the selected release commit; resuming." else - git tag "$tag" - git push origin "$tag" + git tag "$TAG" "$SOURCE_SHA" + git push origin "refs/tags/$TAG" fi - if gh release view "$tag" >/dev/null 2>&1; then - echo "Monorepo release $tag already exists; resuming." + if gh release view "$TAG" >/dev/null 2>&1; then + echo "Monorepo release $TAG already exists; resuming." else - gh release create "$tag" \ - --title "$tag" \ + gh release create "$TAG" \ + --title "$TAG" \ --generate-notes fi - name: Deploy to distribution repo + id: publish env: PUBLISH_TARGETS: "${{ inputs.plugin }}:${{ inputs.dist_repo }}" GH_TOKEN: ${{ steps.app-token.outputs.token }} run: make publish - name: Tag and release distribution repo + id: dist-release if: ${{ inputs.record }} env: GH_TOKEN: ${{ steps.app-token.outputs.token }} diff --git a/.github/workflows/release-merged.yml b/.github/workflows/release-merged.yml new file mode 100644 index 0000000..b6c108f --- /dev/null +++ b/.github/workflows/release-merged.yml @@ -0,0 +1,94 @@ +# A same-repository release PR merged into main is the immutable production +# release request. Never publish from the current main tip or a deleted branch. +name: Publish merged plugin release + +on: + pull_request: + types: [closed] + branches: [main] + +permissions: + contents: write + +jobs: + resolve: + if: >- + github.event.pull_request.merged == true && + github.event.pull_request.head.repo.full_name == github.repository && + startsWith(github.event.pull_request.head.ref, 'release/') + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: + contents: read + outputs: + plugin: ${{ steps.vars.outputs.plugin }} + version: ${{ steps.vars.outputs.version }} + source_sha: ${{ steps.vars.outputs.source_sha }} + dist_repo: ${{ steps.vars.outputs.dist_repo }} + steps: + - name: Resolve merged release request + id: vars + env: + PR_MERGED: ${{ github.event.pull_request.merged }} + PR_HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }} + SOURCE_REPO: ${{ github.repository }} + PR_BASE_REF: ${{ github.event.pull_request.base.ref }} + RELEASE_BRANCH: ${{ github.event.pull_request.head.ref }} + SOURCE_SHA: ${{ github.event.pull_request.merge_commit_sha }} + run: | + set -euo pipefail + if [ "$PR_MERGED" != "true" ] || [ "$PR_HEAD_REPO" != "$SOURCE_REPO" ] || [ "$PR_BASE_REF" != "main" ]; then + echo "::error::Production releases require a merged same-repository PR targeting main." + exit 1 + fi + if [[ ! "$RELEASE_BRANCH" =~ ^release/(antigravity|claude|codex|grok)/v([0-9]+\.[0-9]+\.[0-9]+)$ ]]; then + echo "::error::Malformed release branch; expected release//vMAJOR.MINOR.PATCH." + exit 1 + fi + plugin="${BASH_REMATCH[1]}" + version="${BASH_REMATCH[2]}" + if [[ ! "$SOURCE_SHA" =~ ^[0-9a-f]{40}$ ]]; then + echo "::error::The merged release must have a full lowercase commit SHA." + exit 1 + fi + case "$plugin" in + antigravity) dist_repo=braintrustdata/braintrust-antigravity-plugin ;; + claude) dist_repo=braintrustdata/braintrust-claude-plugin ;; + codex) dist_repo=braintrustdata/braintrust-codex-plugin ;; + grok) dist_repo=braintrustdata/braintrust-grok-plugin ;; + esac + { + printf 'plugin=%s\n' "$plugin" + printf 'version=%s\n' "$version" + printf 'source_sha=%s\n' "$SOURCE_SHA" + printf 'dist_repo=%s\n' "$dist_repo" + } >> "$GITHUB_OUTPUT" + + - name: Checkout approved merge commit + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + ref: ${{ steps.vars.outputs.source_sha }} + fetch-depth: 0 + + - name: Verify approved release marker + id: marker + env: + PLUGIN: ${{ steps.vars.outputs.plugin }} + VERSION: ${{ steps.vars.outputs.version }} + run: | + set -euo pipefail + if ! cmp -s <(printf '%s\n' "$VERSION") ".github/release-versions/$PLUGIN"; then + echo "::error::The approved release marker must contain exactly the requested version and a newline." + exit 1 + fi + + release: + needs: resolve + uses: ./.github/workflows/_release.yml + with: + plugin: ${{ needs.resolve.outputs.plugin }} + version: ${{ needs.resolve.outputs.version }} + source_sha: ${{ needs.resolve.outputs.source_sha }} + dist_repo: ${{ needs.resolve.outputs.dist_repo }} + record: true + secrets: inherit diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 483ff9d..ecdec23 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,16 +1,7 @@ -# Release a plugin to its PRODUCTION distribution repo: -# antigravity -> braintrustdata/braintrust-antigravity-plugin -# claude -> braintrustdata/braintrust-claude-plugin -# codex -> braintrustdata/braintrust-codex-plugin -# grok -> braintrustdata/braintrust-grok-plugin -# -# Full flow: stamp manifest versions where the agent format supports them -> -# commit to main when needed -> monorepo tag/release using -# v- -> deploy -> distribution-repo tag/release using -# v. For a safe dry run, use test-release.yml -# (deploys to the sandbox repo and skips all commits, tags, and releases). - -name: Release plugin +# Prepare a reviewable release PR. Human merge authorizes release-merged.yml +# to tag the approved commit and publish the production distribution. +# For deploy-only sandbox runs, use test-release.yml. +name: Prepare plugin release on: workflow_dispatch: @@ -26,14 +17,128 @@ on: options: [antigravity, claude, codex, grok] permissions: - contents: write + contents: read + +concurrency: + group: prepare-release-${{ inputs.plugin }} + cancel-in-progress: false jobs: - release: - uses: ./.github/workflows/_release.yml - with: - version: ${{ inputs.version }} - plugin: ${{ inputs.plugin }} - dist_repo: ${{ inputs.plugin == 'antigravity' && 'braintrustdata/braintrust-antigravity-plugin' || inputs.plugin == 'codex' && 'braintrustdata/braintrust-codex-plugin' || inputs.plugin == 'grok' && 'braintrustdata/braintrust-grok-plugin' || 'braintrustdata/braintrust-claude-plugin' }} - record: true - secrets: inherit + prepare: + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - name: Validate release request + id: vars + env: + REQUESTED_VERSION: ${{ inputs.version }} + PLUGIN: ${{ inputs.plugin }} + SOURCE_REF: ${{ github.ref }} + run: | + set -euo pipefail + if [ "$SOURCE_REF" != "refs/heads/main" ]; then + echo "::error::Release preparation must run on main." + exit 1 + fi + version="${REQUESTED_VERSION#v}" + if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::version must be semver (MAJOR.MINOR.PATCH)." + exit 1 + fi + case "$PLUGIN" in + antigravity) dist_repo=braintrustdata/braintrust-antigravity-plugin ;; + claude) dist_repo=braintrustdata/braintrust-claude-plugin ;; + codex) dist_repo=braintrustdata/braintrust-codex-plugin ;; + grok) dist_repo=braintrustdata/braintrust-grok-plugin ;; + *) echo "::error::Unsupported release plugin."; exit 1 ;; + esac + { + printf 'version=%s\n' "$version" + printf 'tag=v%s-%s\n' "$version" "$PLUGIN" + printf 'dist_repo=%s\n' "$dist_repo" + printf 'paths<> "$GITHUB_OUTPUT" + + - name: Checkout main + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + ref: main + fetch-depth: 0 + + - name: Reject existing release requests + id: preflight + env: + PLUGIN: ${{ inputs.plugin }} + VERSION: ${{ steps.vars.outputs.version }} + TAG: ${{ steps.vars.outputs.tag }} + run: | + set -euo pipefail + if cmp -s <(printf '%s\n' "$VERSION") ".github/release-versions/$PLUGIN"; then + echo "::error::Release version is already merged; rerun its post-merge release workflow." + exit 1 + fi + if git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then + echo "::error::Source release tag $TAG already exists." + exit 1 + fi + + # The App must be installed on this monorepo with both permissions. + # Its short-lived token lets the release PR trigger normal pull-request CI. + - name: Generate monorepo token + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + app-id: ${{ secrets.BRAINTRUST_BOT_APP_ID }} + private-key: ${{ secrets.BRAINTRUST_BOT_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: ${{ github.event.repository.name }} + permission-contents: write + permission-pull-requests: write + + - name: Prepare release files + id: prepare + env: + PLUGIN: ${{ inputs.plugin }} + VERSION: ${{ steps.vars.outputs.version }} + run: | + set -euo pipefail + if [ "$PLUGIN" != "antigravity" ]; then + python3 scripts/set-plugin-version.py "$PLUGIN" "$VERSION" + fi + mkdir -p .github/release-versions + printf '%s\n' "$VERSION" > ".github/release-versions/$PLUGIN" + + - name: Create release pull request + id: release-pr + uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 + with: + token: ${{ steps.app-token.outputs.token }} + base: main + branch: release/${{ inputs.plugin }}/v${{ steps.vars.outputs.version }} + add-paths: ${{ steps.vars.outputs.paths }} + commit-message: "chore(${{ inputs.plugin }}): release v${{ steps.vars.outputs.version }}" + title: "chore(${{ inputs.plugin }}): release v${{ steps.vars.outputs.version }}" + body: | + Prepare `${{ inputs.plugin }}` version `${{ steps.vars.outputs.version }}`. + + Production destination: `${{ steps.vars.outputs.dist_repo }}`. + + Human approval and merge into `main` authorize tagging the merge commit and publishing this version to the production destination. This preparation does not tag or deploy anything. + + - name: Summarize release pull request + id: summary + env: + PR_URL: ${{ steps.release-pr.outputs.pull-request-url }} + run: | + set -euo pipefail + if [ -z "$PR_URL" ]; then + echo "::error::No release pull request was created or updated." + exit 1 + fi + printf 'Release pull request: %s\n' "$PR_URL" >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/test-release.yml b/.github/workflows/test-release.yml index 8067b06..7b11210 100644 --- a/.github/workflows/test-release.yml +++ b/.github/workflows/test-release.yml @@ -30,6 +30,7 @@ jobs: with: version: ${{ inputs.version }} plugin: ${{ inputs.plugin }} + source_sha: ${{ github.sha }} dist_repo: braintrustdata/test-coding-agent-dist record: false secrets: inherit diff --git a/AGENTS.md b/AGENTS.md index e3dcb89..8f909e8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -71,10 +71,31 @@ Cross-repository pushes use `GH_TOKEN` or ambient Git credentials. ## Releasing -The manual `release.yml` workflow deploys a production release, records the -version bump on `main`, tags it, and creates a GitHub Release. The manual -`test-release.yml` workflow exercises the same deployment against the test -repository without committing or tagging. Both call `_release.yml`. +Run **Prepare plugin release** (`release.yml`) on `main` with a plugin and +version. It opens or updates `release//v` using Braintrust Bot, +with the manifest changes and a monorepo-only +`.github/release-versions/` approval record. The record gives Antigravity +a reviewable diff without adding a native manifest version. Preparation never +tags or deploys. + +A human approves and merges the PR under the existing branch protection rules. +`release-merged.yml` then calls `_release.yml` with that PR's exact merge SHA, +verifies the reviewed versions, creates `v-` and its GitHub +Release, deploys the distribution, and creates its `v` tag/release. +It never pushes source changes to `main`. CI runs on release PRs; the current +rules require one approval but do not require passing CI. + +If publishing is interrupted, rerun the post-merge workflow rather than +preparing the merged version again. An existing source tag must match the +approved merge SHA; an existing distribution tag still rejects publication. +An unchanged Antigravity artifact may reuse its previous distribution commit +under the new version tag. + +The manual `test-release.yml` workflow calls `_release.yml` at the selected +commit, stamps versions only on the runner, and **overwrites the shared test +distribution repository** without committing source changes or creating tags +or releases. It does not require a PR. Pi and OpenCode retain their separate +npm workflows, dispatched against an already-merged version-bump SHA. A Codex deployment can run `smoke-codex.yml`, which installs the deployed plugin and runs a real Codex session through the daemon when @@ -87,10 +108,13 @@ branch revision are cancelled. ## Secrets - `BRAINTRUST_BOT_APP_ID` / `BRAINTRUST_BOT_PRIVATE_KEY` are the - `braintrustdata/braintrust-bot` GitHub App credentials. Release workflows mint - a short-lived installation token from them, scoped to `contents:write` on the - one distribution repository being deployed. The app must be installed on every - distribution repository, or the token step fails. + `braintrustdata/braintrust-bot` GitHub App credentials. Preparation mints a + monorepo-scoped token with `contents:write` and `pull_requests:write` so + bot-created PRs trigger CI. The app must be installed on this monorepo with + both permissions. Deployment separately mints a token scoped to + `contents:write` on the one distribution repository being deployed; the app + must also be installed there. Missing installation or permissions fail the + token step; do not bypass branch protection. Source tags use `GITHUB_TOKEN`. - `OPENAI_API_KEY` enables the optional real Codex smoke test. Braintrust authentication is deliberately not stored in plugin or daemon