Skip to content
Open
98 changes: 98 additions & 0 deletions .github/workflows/apply-release-notes.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
name: Apply release notes

# Approval-based publish. When a member of the supabase/cli team approves a
# release-notes PR (head ref `release-notes/v<VERSION>`), this workflow pushes
# the proposed notes to the GitHub Release body for the corresponding tag,
# comments the release URL on the PR, and closes the PR without merging. The
# release-notes file never lands on `main`.
#
# Mirrors the fast-forward job in release.yml, which already gates on a
# `pull_request_review` + `approved` event.

on:
pull_request_review:
types: [submitted]

permissions:
contents: read

jobs:
apply:
# `state == 'open'` makes re-approvals on an already-closed PR a no-op
# (a reviewer can re-approve from the GitHub UI even after close).
if: |
github.event.review.state == 'approved' &&
startsWith(github.event.pull_request.head.ref, 'release-notes/') &&
github.event.pull_request.base.ref == 'main' &&
github.event.pull_request.state == 'open'
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
# App token: needs `orgs/.../teams/.../memberships` read (the org-installed
# App has it), repo write to edit the release, and PR write to comment
# and close. Matches release.yml's fast-forward step.
- id: app-token
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}

- name: Authorize approver against supabase/cli team
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
APPROVER: ${{ github.event.review.user.login }}
PR_NUMBER: ${{ github.event.pull_request.number }}
# Fail closed: any response other than an active membership means the
# approval is ignored. We post a comment so the reviewer sees why their
# approval didn't apply, then exit 0 so the workflow isn't flagged red.
run: |
set -euo pipefail
status=$(gh api \
-H "Accept: application/vnd.github+json" \
"orgs/supabase/teams/cli/memberships/${APPROVER}" \
--jq '.state' 2>/dev/null || true)
if [ "$status" != "active" ]; then
echo "Approver @${APPROVER} is not an active supabase/cli team member (state='${status:-none}'); ignoring approval." >&2
gh pr comment "$PR_NUMBER" --repo "${{ github.repository }}" --body \
"@${APPROVER} is not an active \`supabase/cli\` team member, so this approval was ignored. Ask a team member to approve to publish the notes."
exit 0
fi
echo "AUTHORIZED=true" >> "$GITHUB_ENV"

# Checkout the PR head so any reviewer edits made in the GitHub UI before
# approval are captured. apply-release-notes.ts reads from the working
# tree.
- if: env.AUTHORIZED == 'true'
uses: useblacksmith/checkout@41cdeedae8edb2e684ba22896a5fd2a3cb85db6b # v1
with:
ref: ${{ github.event.pull_request.head.sha }}
fetch-depth: 1
persist-credentials: false

- if: env.AUTHORIZED == 'true'
uses: ./.github/actions/setup

- name: Apply notes, comment, and close
if: env.AUTHORIZED == 'true'
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
HEAD_REF: ${{ github.event.pull_request.head.ref }}
PR_NUMBER: ${{ github.event.pull_request.number }}
APPROVER: ${{ github.event.review.user.login }}
# The branch is named `release-notes/v<VERSION>`, so the tag is just
# the basename. apply-release-notes.ts validates the file's existence.
run: |
set -euo pipefail
tag="${HEAD_REF##release-notes/}"
if [[ ! "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-(beta|alpha)\.[0-9]+)?$ ]]; then
echo "Unexpected head ref '$HEAD_REF'; cannot derive tag." >&2
exit 1
fi
echo "==> Applying notes for $tag"
pnpm exec bun apps/cli/scripts/apply-release-notes.ts --tag "$tag"
release_url="https://github.com/${{ github.repository }}/releases/tag/${tag}"
gh pr comment "$PR_NUMBER" --repo "${{ github.repository }}" --body \
"Applied to [${tag}](${release_url}) after approval by @${APPROVER}."
gh pr close "$PR_NUMBER" --repo "${{ github.repository }}" --delete-branch
75 changes: 75 additions & 0 deletions .github/workflows/propose-release-notes.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
name: Propose release notes

# Runs after backfill-release-notes lands the raw semantic-release block in the
# GitHub Release body. Re-derives that block, asks Claude to rewrite it into
# user-centric notes per tools/release/release-notes-prompt.md, and opens a PR
# adding release-notes/v<VERSION>.md. Merging the PR triggers
# apply-release-notes.yml, which pushes the file's contents to the GH Release.
#
# Stable releases only on the automatic release pipeline — prerelease tags
# (-beta./-alpha.) keep the raw body unless this workflow is triggered
# manually from the Actions tab (workflow_dispatch).

on:
workflow_call:
inputs:
tag:
description: Release tag to propose notes for (e.g. v2.101.0)
required: true
type: string
non_blocking:
description: Do not fail the workflow run when proposing fails (release pipeline)
required: false
type: boolean
default: false
workflow_dispatch:
inputs:
tag:
description: Release tag to propose notes for (e.g. v2.101.0 or v2.99.0-beta.1)
required: true
type: string

permissions:
contents: read

jobs:
propose:
# workflow_call (release pipeline): skip prereleases. workflow_dispatch:
# allow any tag so reviewers can opt in for beta/alpha from the Actions tab.
if: ${{ github.event_name == 'workflow_dispatch' || (!contains(inputs.tag, '-beta.') && !contains(inputs.tag, '-alpha.')) }}
runs-on: ubuntu-latest
continue-on-error: ${{ inputs.non_blocking }}
permissions:
contents: write
pull-requests: write
env:
TAG: ${{ inputs.tag }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
steps:
# App token gets us push to a protected default branch *and* PR creation
# under the App identity, matching the rest of release.yml.
- id: app-token
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}

- uses: useblacksmith/checkout@41cdeedae8edb2e684ba22896a5fd2a3cb85db6b # v1
with:
# Full history + tags so backfill-release-notes.ts can reach the
# commit graph it needs (semantic-release walks notes back to the
# last release on the channel).
fetch-depth: 0
token: ${{ steps.app-token.outputs.token }}

- uses: ./.github/actions/setup

- name: Configure git identity
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"

- name: Propose release notes
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
run: pnpm exec bun apps/cli/scripts/propose-release-notes.ts --tag "${TAG}" --apply
14 changes: 14 additions & 0 deletions .github/workflows/release-shared.yml
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,20 @@ jobs:
apply: true
non_blocking: true

# Once the raw semantic-release block is in the release body, ask Claude to
# rewrite it into user-centric notes and open a PR for human approval. Stable
# releases only on this path — prereleases keep the raw body. Non-blocking so
# an LLM hiccup never gates a published release; reviewers can propose beta/
# alpha notes manually from the Actions tab (workflow_dispatch).
propose-release-notes:
uses: ./.github/workflows/propose-release-notes.yml
needs: backfill-release-notes
if: ${{ !inputs.dry_run && !inputs.prerelease && needs.backfill-release-notes.result == 'success' }}
with:
tag: v${{ inputs.version }}
non_blocking: true
secrets: inherit

publish-homebrew:
needs: publish
if: ${{ !inputs.dry_run && inputs.publish_brew_scoop }}
Expand Down
8 changes: 7 additions & 1 deletion apps/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,13 @@
"fix:all": "nx run-many -t lint:fix fmt:fix knip:fix --projects=$npm_package_name"
},
"devDependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.3.146",
"@anthropic-ai/sdk": "^0.97.1",
"@clack/prompts": "^1.4.0",
"@effect/atom-react": "catalog:",
"@effect/platform-bun": "catalog:",
"@effect/vitest": "catalog:",
"@modelcontextprotocol/sdk": "^1.29.0",
"@napi-rs/keyring": "^1.3.0",
"@parcel/watcher": "^2.5.6",
"@supabase/api": "workspace:*",
Expand Down Expand Up @@ -120,7 +123,10 @@
"oxfmt",
"oxlint",
"oxlint-tsgolint",
"semantic-release"
"semantic-release",
"@anthropic-ai/claude-agent-sdk",
"@anthropic-ai/sdk",
"@modelcontextprotocol/sdk"
]
},
"nx": {
Expand Down
37 changes: 37 additions & 0 deletions apps/cli/scripts/apply-release-notes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
#!/usr/bin/env bun
// Push the contents of release-notes/v<VERSION>.md to the GitHub Release body
// for tag v<VERSION>. Invoked from apply-release-notes.yml after a
// release-notes PR is merged to main.
//
// Usage:
// bun apps/cli/scripts/apply-release-notes.ts --tag v2.101.0
import { $ } from "bun";
import { existsSync } from "node:fs";
import path from "node:path";
import process from "node:process";
import { parseArgs } from "node:util";

const { values } = parseArgs({
options: {
tag: { type: "string" },
},
strict: true,
});

const tag = values.tag;
if (!tag) {
console.error("--tag is required (e.g. --tag v2.101.0)");
process.exit(2);
}
const version = tag.replace(/^v/, "");

const repoRoot = (await $`git rev-parse --show-toplevel`.text()).trim();
const notesPath = path.join(repoRoot, "release-notes", `v${version}.md`);
if (!existsSync(notesPath)) {
console.error(`No notes file at ${path.relative(repoRoot, notesPath)}`);
process.exit(1);
}

console.error(`==> Updating GitHub Release body for ${tag}`);
await $`gh release edit ${tag} --notes-file ${notesPath}`.cwd(repoRoot);
console.error(`==> Done`);
Loading