Skip to content
Draft
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
290 changes: 290 additions & 0 deletions .github/workflows/release-config.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,290 @@
name: Release Config

on:
push:
branches:
- develop
paths:
- "packages/config/**"
- ".github/workflows/release-config.yml"
# workflow_dispatch is the manual re-cut path, mirroring the CLI's Release
# workflow. Defaults to `true` so a stray "Run workflow" click can't
# accidentally publish — operators must consciously untick this.
#
# There is deliberately no `version` input: the publish job's registry probe
# skips versions that already exist on npm, so recovery from stale published
# bytes is "land a new (releasable) commit" — with no binary artifacts and a
# human approval in the loop, the CLI's cut-forward escape hatch isn't worth
# a second code path here.
workflow_dispatch:
inputs:
dry_run:
description: Dry run (skip actual publishing)
required: false
type: boolean
default: true

# A distinct group from the CLI Release workflow's (keyed on the workflow
# name, which differs) — a config release never queues behind a CLI release.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false

permissions:
contents: read

jobs:
plan:
name: Plan release
runs-on: ubuntu-latest
timeout-minutes: 20
outputs:
should_release: ${{ steps.plan.outputs.should_release }}
version: ${{ steps.plan.outputs.version }}
npm_tag: ${{ steps.plan.outputs.npm_tag }}
dry_run: ${{ steps.plan.outputs.dry_run }}
steps:
# semantic-release runs `git push --dry-run HEAD:<branch>` as part of
# verifyAuth even in `dry_run: true` mode, so the token must have push
# access to the protected `develop` branch. The default GITHUB_TOKEN
# doesn't, so we mint an App-installation token from the same App used
# by the CLI's release pipeline.
- id: app-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
client-id: ${{ vars.GH_APP_CLIENT_ID }}
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
permission-contents: write

# `persist-credentials: false` is required: otherwise checkout caches the
# default GITHUB_TOKEN as an `http.extraheader` in git config, and that
# Authorization header overrides the App token semantic-release puts in
# the push URL — making the dry-push identify as `github-actions[bot]`
# and get rejected by branch protection.
- uses: useblacksmith/checkout@6fd481652155169ed4d2f25ebaf97464f685175f # v1
with:
fetch-depth: 0
persist-credentials: false

# Unlike the CLI's plan job, the plan driver here runs from inside the
# workspace (turbo, semantic-release, effect, …), so it needs node_modules.
- name: Setup
uses: ./.github/actions/setup
with:
dependency-firewall-token: ${{ secrets.DF_FIREWALL_TOKEN }}

- id: plan
env:
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
EVENT: ${{ github.event_name }}
DISPATCH_DRY_RUN: ${{ inputs.dry_run }}
run: |
set -euo pipefail
# semantic-release echoes commit-derived text (messages, notes) to
# this step's log; a commit message line starting with `::` would
# otherwise be interpreted as a workflow command (e.g. `::add-mask::`
# could redact words from the gate output the approver reads later).
# Bracket the driver with a stop-commands token so none of that
# output can issue commands. ($GITHUB_OUTPUT is a file, unaffected.)
resume_token="$(openssl rand -hex 16)"
echo "::stop-commands::${resume_token}"
pnpm exec bun packages/config/scripts/release-plan.ts --notes-out "$RUNNER_TEMP/config-release-notes.md"
echo "::${resume_token}::"
# Push events are never dry; workflow_dispatch dry-runs unless the
# operator explicitly unticks the input.
if [[ "$EVENT" == "workflow_dispatch" && "$DISPATCH_DRY_RUN" == "true" ]]; then
echo "dry_run=true" >> "$GITHUB_OUTPUT"
else
echo "dry_run=false" >> "$GITHUB_OUTPUT"
fi

# The build, gate, and pack steps also run on private-blocked pushes
# (should_release=false, version set) — every config push rehearses the
# plan half of the release train while CLI-2169 hasn't flipped `private`
# yet. The publish half stays unexercised until then.
- name: Build @supabase/config
if: steps.plan.outputs.version != ''
run: pnpm exec turbo run @supabase/config#build

- name: Run type-surface release gate
if: steps.plan.outputs.version != ''
env:
VERSION: ${{ steps.plan.outputs.version }}
run: pnpm exec bun tools/config-release-gate.ts --version "$VERSION"

# Pack the exact tarball the approver's evidence (the gate summary
# above) describes. The publish job publishes THIS artifact rather than
# rebuilding: builds are not byte-reproducible across jobs (see
# release-shared.yml's brew/scoop cache-key comments for how that bit
# once before), and a rebuild would mean the approved bytes and the
# published bytes can differ.
- name: Pack the release tarball
if: steps.plan.outputs.version != ''
env:
VERSION: ${{ steps.plan.outputs.version }}
run: |
set -euo pipefail
mkdir -p "$RUNNER_TEMP/config-release-artifact"
cd packages/config
npm pkg set version="${VERSION}"
pnpm pack --pack-destination "$RUNNER_TEMP/config-release-artifact"
cp "$RUNNER_TEMP/config-release-notes.md" "$RUNNER_TEMP/config-release-artifact/"

- name: Upload release artifact
if: steps.plan.outputs.version != ''
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: config-release
path: ${{ runner.temp }}/config-release-artifact/
if-no-files-found: error
retention-days: 7

# The `config-release` environment's required-reviewers rule is repo
# configuration, not code: an environment referenced by a workflow is
# auto-created WITHOUT protection rules, in which case the publish job
# would run straight through unreviewed. Fail closed here — before a
# real (non-dry) release can reach the publish job — if the rule is
# missing or unreadable. Private-blocked rehearsals (should_release
# false) are unaffected, so this only bites once CLI-2169 flips
# `private`, which is exactly when it must.
- name: Assert the release approval gate is armed
if: steps.plan.outputs.should_release == 'true' && steps.plan.outputs.dry_run != 'true'
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
rules="$(gh api "repos/${GITHUB_REPOSITORY}/environments/config-release" \
--jq '[.protection_rules[].type] | join(",")' 2>/dev/null || echo "")"
case "$rules" in
*required_reviewers*) echo "config-release gate armed: ${rules}" ;;
*)
echo "The config-release environment has no required_reviewers rule (found: '${rules:-none}')." >&2
echo "Configure required reviewers in repo settings before releasing — see packages/config/AGENTS.md." >&2
exit 1
;;
esac

publish:
name: Publish
needs: plan
if: needs.plan.outputs.should_release == 'true' && needs.plan.outputs.dry_run != 'true'
# npm provenance verification rejects non-GitHub-hosted runners with
# E422 ("Unsupported GitHub Actions runner environment: self-hosted").
# Blacksmith runners count as self-hosted from sigstore's POV, so the
# publish job must stay on a github-hosted runner. The job is short and
# not compute-bound, so the wall-clock cost is negligible.
runs-on: ubuntu-latest
timeout-minutes: 30
environment:
name: config-release
# This environment must be configured with required reviewers in repo
# settings (asserted by the plan job above). The approver reviews the
# plan job's step summary (release notes + type-surface gate diff)
# before approving — that approval IS the hard semver gate, and the
# tarball published below is byte-identical to the one that evidence
# was generated from.
url: https://www.npmjs.com/package/@supabase/config/v/${{ needs.plan.outputs.version }}
# OIDC trusted publishing + provenance — same as release-shared.yml; no
# NPM_TOKEN anywhere. This job deliberately runs NO dependency install and
# NO build: the only repo code it executes is this workflow file, keeping
# arbitrary package code away from the job that holds id-token: write.
permissions:
contents: write
id-token: write
env:
VERSION: ${{ needs.plan.outputs.version }}
NPM_TAG: ${{ needs.plan.outputs.npm_tag }}
steps:
- name: Generate release repository token
id: app-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
client-id: ${{ vars.GH_APP_CLIENT_ID }}
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
permission-contents: write

# Needed for the tag push and for mise.toml; the default depth-1 fetch
# of the triggering commit is enough for both.
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: true
token: ${{ steps.app-token.outputs.token }}

# Toolchains only (pnpm for the publish) — no `pnpm install`.
- name: Install toolchains
uses: jdx/mise-action@e6a8b3978addb5a52f2b4cd9d91eafa7f0ab959d # v4
with:
version: 2026.7.0

- name: Download the reviewed release artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: config-release
path: ${{ runner.temp }}/config-release

- name: Verify and extract the tarball
working-directory: ${{ runner.temp }}/config-release
run: |
set -euo pipefail
tar -xzf "supabase-config-${VERSION}.tgz" --no-same-owner --no-same-permissions
# The root .gitignore's bare `dist` line once pruned dist/ from the
# packlist entirely (the reason packages/config/.npmignore exists) —
# never publish a tarball without its compiled entrypoint.
test -f package/dist/index.js
[[ "$(jq -r .name package/package.json)" == "@supabase/config" ]]
[[ "$(jq -r .version package/package.json)" == "${VERSION}" ]]
if [[ "$(jq -r .private package/package.json)" == "true" ]]; then
echo "packages/config is still private: true — flip it under CLI-2169 before publishing." >&2
exit 1
fi

# Idempotent, mirroring publish.ts's registry-probe intent: a re-run
# after a post-publish failure must not die on EPUBLISHCONFLICT.
# Publishing from the extracted artifact keeps the published content
# identical to what the approver reviewed.
- name: Publish to npm
working-directory: ${{ runner.temp }}/config-release/package
run: |
set -euo pipefail
if npm view "@supabase/config@${VERSION}" version >/dev/null 2>&1; then
echo "@supabase/config@${VERSION} already on npm; skipping publish."
else
pnpm publish --provenance --tag "${NPM_TAG}" --no-git-checks
fi

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

# Push the tag to origin as soon as npm has the bytes, before any
# downstream step that can fail. Without this, a failure in the GH
# release step leaves origin with no tag for the version that is now
# live on npm — and a subsequent plan would recompute the same version
# against stale bytes. Idempotent: skips push if the tag is already on
# origin (e.g. a re-run of a job that previously got past this step).
- name: Push version tag
run: |
set -euo pipefail
tag="config-v${VERSION}"
if git ls-remote --tags origin "refs/tags/${tag}" | grep -q .; then
echo "Tag ${tag} already on origin; skipping push."
else
git tag -a "${tag}" -m "Release ${tag}"
git push origin "${tag}"
fi

- name: Create GitHub Release
uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2
with:
token: ${{ github.token }}
tag_name: config-v${{ needs.plan.outputs.version }}
name: "@supabase/config v${{ needs.plan.outputs.version }}"
body_path: ${{ runner.temp }}/config-release/config-release-notes.md
draft: false
prerelease: false
# The CLI's install scripts and setup-cli resolve
# releases/latest/download/..., so a config release must never
# become the repo's "latest" release.
make_latest: "false"
2 changes: 1 addition & 1 deletion apps/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@
"prettier": "3.9.6",
"react": "^19.2.8",
"react-devtools-core": "^7.0.1",
"semantic-release": "^25.0.9",
"semantic-release": "catalog:",
"smol-toml": "^1.8.0",
"tldts": "catalog:",
"typescript": "catalog:",
Expand Down
47 changes: 45 additions & 2 deletions packages/config/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,8 +100,9 @@ surface must update that test deliberately — it is not meant to be a silent pa
`dist/` is gitignored and rebuilt on demand — no build output is checked in. The public type
surface is instead enforced per-PR by export snapshots and purity walkers (see "Testing" below)
plus the repo-root `pnpm check:config-api` (`tools/config-api-compare.ts`), which diffs this
package's declaration output between the PR base and head commits and is advisory at PR time. A
release-time tarball diff is planned under CLI-2233 as the hard gate.
package's declaration output between the PR base and head commits and is advisory at PR time. The
hard gate is a release-time tarball diff — `tools/config-release-gate.ts`, run by the `plan` job in
`.github/workflows/release-config.yml` — see "Releases" below.

### Publishing the tarball (CLI-2234)

Expand Down Expand Up @@ -132,3 +133,45 @@ own guarantees and must stay green after any entrypoint or type-surface change:
- `scripts/json-schema-postprocess.unit.test.ts` / `scripts/build-artifacts.unit.test.ts` — the
JSON Schema post-processing `renderJsonSchema` applies (non-finite-number `anyOf` collapse,
`$id`/`title`/`description`), the second against the real generated documents.

## Releases (CLI-2233)

This package has its own release train, independent of the CLI's — a `fix:`/`feat:` commit
elsewhere in the monorepo never releases `@supabase/config`, and vice versa.

- **Path-filtered conventional commits.** `semantic-release` computes the next version from commits
scoped to `packages/config/` via `scripts/semantic-release-path-filter.ts`.
- **Tag format:** `config-v<version>` — never collides with the CLI's `v<version>` tags.
- **Stable-only, from `develop`.** No beta/alpha channel; every release publishes to npm under the
`latest` dist-tag.
- **Workflow:** `.github/workflows/release-config.yml` — a `plan` job computes the version, runs
the type-surface gate, and packs the release tarball; a human approves the `config-release`
GitHub environment (reviewing the plan job's step summary: release notes + type-surface diff);
then an OIDC/provenance publish job publishes **that exact tarball** (no rebuild — the approved
bytes are the published bytes).
- **`package.json`'s `version` field is never committed.** It is set at publish time from the
computed version — never hand-bump it, and never hand-push a `config-v*` tag.
- **Local dry runs:** `scripts/release-plan.ts` runs the plan locally without publishing;
`tools/config-release-gate.ts --tarball` rehearses the type-surface gate locally.

### One-time setup (tracked under CLI-2169)

Four things must be settled before the first real publish:

1. The `config-release` GitHub environment needs required reviewers configured in repo settings. An
environment referenced by a workflow is auto-created WITHOUT protection rules — the plan job
asserts the rule exists and refuses to plan a real release until it does, so the first release
attempt fails closed rather than publishing unreviewed.
2. npm trusted publishing must be configured for the package, which requires the package to exist
first. The very first publish is a manual bootstrap — use a granular, single-package,
short-expiry token and revoke it as soon as the trusted publisher is configured (repo
`supabase/cli`, workflow `release-config.yml`, environment `config-release`).
3. Push a baseline `config-v*` tag (e.g. `config-v0.1.0`) on a `develop` commit. This is required,
not optional: with no baseline, semantic-release would cut `1.0.0` with release notes generated
from the entire monorepo history — a whole-history changelog as both the approval artifact and
the public GH release body. `scripts/release-plan.ts` refuses to plan without a baseline tag
(escape hatch: `CONFIG_RELEASE_ALLOW_NO_BASELINE=1`). This is the single exception to the
"never hand-push a `config-v*` tag" rule above.
4. Add a repository tag ruleset protecting `config-v*` (alongside `v*`), restricted to the release
App. The last `config-v*` tag is the version oracle: a stray hand-pushed tag permanently skews
versioning, and a deleted tag makes the next plan re-cut an already-published version.
Loading