Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
154 changes: 88 additions & 66 deletions .github/workflows/_release.yml
Original file line number Diff line number Diff line change
@@ -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 <dist_repo>. 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<version>-<plugin>, and create a GitHub Release on the monorepo. After the
# distribution is deployed, its repo gets an unsuffixed v<version> 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 <dist_repo> from the selected source SHA.
# Production verifies the reviewed versions and release marker, then tags
# v<version>-<plugin> and creates a GitHub Release on the monorepo without
# changing its branches. After deployment, the distribution repo gets an
# unsuffixed v<version> tag and matching GitHub Release. Test runs stamp
# versions on the runner and skip both sets of release records.

name: _release

Expand All @@ -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:
Expand Down Expand Up @@ -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 <dist_repo>.
- name: Generate distribution-repo token
id: app-token
Expand All @@ -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 }}
Expand All @@ -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 }}
Expand Down
94 changes: 94 additions & 0 deletions .github/workflows/release-merged.yml
Original file line number Diff line number Diff line change
@@ -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/<plugin>/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
Loading
Loading