diff --git a/.github/workflows/release-config.yml b/.github/workflows/release-config.yml new file mode 100644 index 0000000000..6a6f3de930 --- /dev/null +++ b/.github/workflows/release-config.yml @@ -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:` 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" diff --git a/apps/cli/package.json b/apps/cli/package.json index 39fa7ee7b1..348f384c6d 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -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:", diff --git a/packages/config/AGENTS.md b/packages/config/AGENTS.md index 4ea94eb8b2..e1f9c2506a 100644 --- a/packages/config/AGENTS.md +++ b/packages/config/AGENTS.md @@ -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) @@ -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` — never collides with the CLI's `v` 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. diff --git a/packages/config/README.md b/packages/config/README.md index 22fac21442..9aaa14562b 100644 --- a/packages/config/README.md +++ b/packages/config/README.md @@ -15,6 +15,41 @@ It owns: - JSON Schema generation for both shapes, at `@supabase/config/schema.json` and `@supabase/config/project-schema.json` +## Installing + +```sh +npm install @supabase/config effect@rc +``` + +```ts +import { CliConfigSchema, toProjectConfig } from "@supabase/config"; + +const projectConfig = toProjectConfig({ cliConfig: someCliConfig }); +``` + +This package is not yet published (`private: true`; publishing is tracked separately). Once it +is, install it alongside the peers your runtime needs. + +This package requires Effect 4.x, currently only published under the `rc` dist-tag — `effect@latest` +still resolves to 3.x, which will not satisfy this package's peer range. + +`effect` is a required peer dependency. `@effect/platform-bun` and `@effect/platform-node` are +optional peers — install exactly one, matching your runtime, if you use `./io` or `./effect`'s +file-IO programs: + +| Consumer | Required peers | +| ---------------------------------------------- | --------------------------------- | +| Pure / browser / edge (`.` only, no file IO) | `effect` | +| Node (`./io` or `./effect`'s file-IO programs) | `effect`, `@effect/platform-node` | +| Bun (`./io` or `./effect`'s file-IO programs) | `effect`, `@effect/platform-bun` | + +Under the `node`/`bun` export conditions, the matching platform peer is imported eagerly at module +load. A missing peer surfaces as a raw module-resolution error (e.g. `Cannot find package +'@effect/platform-node'`) the first time something imports `./io` or `./effect` — not a curated +message — so install the peer for your runtime before importing either subpath. The `browser` +condition is the one exception: it needs no platform peer, since it resolves to a stub that throws +its own curated error only when invoked (see "Entrypoints" above). + ## Naming - `CliConfig` — the config _document_ (`supabase/config.toml`/`.json`) — the full local superset @@ -30,7 +65,8 @@ Use the `Cli*` prefix for the local checkout side and a bare `Project*` name for Supabase project. Config-value helpers follow the config family regardless of their inputs (`resolveCliConfigValue`). See [ADR 0020](https://github.com/supabase/cli/blob/develop/docs/adr/0020-config-naming-vocabulary.md) -and [docs/cli-config-loading.md](./docs/cli-config-loading.md) for the full vocabulary. +and [docs/cli-config-loading.md](https://github.com/supabase/cli/blob/develop/packages/config/docs/cli-config-loading.md) +for the full vocabulary. ## Entrypoints @@ -145,35 +181,6 @@ Every runtime and type export of the pure `.` entrypoint, grouped by category: | -------------------------------------------------------------------------------------------------- | ---------------------------------------- | | `edgeFunctionDenoConfigFileName` / `edgeFunctionEntrypointFileName` / `edgeFunctionsDirectoryName` | Edge Functions on-disk layout filenames. | -## Installing - -This package is not yet published (`private: true`; publishing is tracked separately). Once it -is, install it alongside the peers your runtime needs: - -```sh -npm install @supabase/config effect@rc -``` - -This package requires Effect 4.x, currently only published under the `rc` dist-tag — `effect@latest` -still resolves to 3.x, which will not satisfy this package's peer range. - -`effect` is a required peer dependency. `@effect/platform-bun` and `@effect/platform-node` are -optional peers — install exactly one, matching your runtime, if you use `./io` or `./effect`'s -file-IO programs: - -| Consumer | Required peers | -| ---------------------------------------------- | --------------------------------- | -| Pure / browser / edge (`.` only, no file IO) | `effect` | -| Node (`./io` or `./effect`'s file-IO programs) | `effect`, `@effect/platform-node` | -| Bun (`./io` or `./effect`'s file-IO programs) | `effect`, `@effect/platform-bun` | - -Under the `node`/`bun` export conditions, the matching platform peer is imported eagerly at module -load. A missing peer surfaces as a raw module-resolution error (e.g. `Cannot find package -'@effect/platform-node'`) the first time something imports `./io` or `./effect` — not a curated -message — so install the peer for your runtime before importing either subpath. The `browser` -condition is the one exception: it needs no platform peer, since it resolves to a stub that throws -its own curated error only when invoked (see "Entrypoints" above). - ## ProjectConfig: producing and validating hosted-project values The hosted-project subset — `ProjectConfig` — and its converters live on the pure entrypoint @@ -329,7 +336,10 @@ The runtime export surface of `.`, `./io`, and `./effect`, plus the two generate artifacts (`./schema.json`, `./project-schema.json`), is this package's published contract. `./internal` carries no such guarantee. See [AGENTS.md](https://github.com/supabase/cli/blob/develop/packages/config/AGENTS.md) for how that contract is enforced (export-surface snapshots, purity walkers, and a base-vs-head type-surface diff advisory -at PR time — a release-time tarball diff hard gate is planned under CLI-2233). +at PR time). Releases themselves are cut by an independent pipeline: conventional commits scoped to +`packages/config/` compute the next version, published to npm under the `latest` dist-tag and +tagged `config-v`, and every publish is human-approved against a type-surface diff of the +previously published version. ## Usage @@ -371,9 +381,12 @@ preserve the existing format when possible and default new config files to JSON. ## Architecture Docs -- [CLI config loading](./docs/cli-config-loading.md) +- [CLI config loading](https://github.com/supabase/cli/blob/develop/packages/config/docs/cli-config-loading.md) -## Development +## Development (contributors) + +This section is for contributors to the supabase/cli monorepo, not consumers of the published +package. Repo-wide quality checks run from the repository root: @@ -391,3 +404,7 @@ pnpm run build # Compile dist/, generate schema.json/project-schema.json ``` See [AGENTS.md](https://github.com/supabase/cli/blob/develop/packages/config/AGENTS.md) for the build pipeline and contract-enforcement details. + +## License + +MIT — see the bundled [LICENSE](https://github.com/supabase/cli/blob/develop/packages/config/LICENSE) file. diff --git a/packages/config/package.json b/packages/config/package.json index b68df5b39a..0d05cf2f90 100644 --- a/packages/config/package.json +++ b/packages/config/package.json @@ -79,10 +79,13 @@ "devDependencies": { "@effect/platform-bun": "catalog:", "@effect/platform-node": "catalog:", + "@semantic-release/commit-analyzer": "^13.0.1", + "@semantic-release/release-notes-generator": "^14.1.1", "@tsconfig/bun": "catalog:", "@types/bun": "catalog:", "@vitest/coverage-istanbul": "catalog:", "effect": "catalog:", + "semantic-release": "catalog:", "typescript": "catalog:", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "vitest": "catalog:" diff --git a/packages/config/scripts/release-plan.ts b/packages/config/scripts/release-plan.ts new file mode 100644 index 0000000000..06ca0574e2 --- /dev/null +++ b/packages/config/scripts/release-plan.ts @@ -0,0 +1,230 @@ +/** + * Computes the `@supabase/config` release plan via semantic-release's + * dry-run JS API (CLI-2233) — the version-computation half of an otherwise + * independent release pipeline for this package. Actual publishing (npm + * publish, tag push, GitHub release) happens in later, separate workflow + * steps; see `.github/workflows/release-config.yml`. + * + * Commit analysis and release-notes generation are scoped to this package's + * own history via `./semantic-release-path-filter.ts` (see that file for + * why a plain `@semantic-release/commit-analyzer` run over the whole + * monorepo history would be wrong here). + * + * Always exits 0 once semantic-release itself completes, whether or not a + * release is due — a non-zero exit means this script itself failed to run + * the plan, not that no release was found. + */ + +import { appendFile } from "node:fs/promises"; +import path from "node:path"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; +import { parseArgs } from "node:util"; + +import { PACKAGE_PATH_PREFIX } from "./semantic-release-path-filter.ts"; + +// Not `import.meta.dir`: that Bun-ism doesn't survive vitest's module +// transform, and this module is imported by its unit test. +const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +interface ConfigPackageJson { + readonly name: string; + readonly private?: boolean; +} + +export interface ReleaseDuePlan { + readonly due: true; + readonly version: string; + readonly bumpType: string; + readonly notes: string; + readonly isPrivate: boolean; +} + +export interface NoReleasePlan { + readonly due: false; +} + +export type ReleasePlan = ReleaseDuePlan | NoReleasePlan; + +async function readPackageJson(): Promise { + return JSON.parse( + await Bun.file(path.join(packageRoot, "package.json")).text(), + ) as ConfigPackageJson; +} + +/** + * Runs semantic-release in dry-run mode against this package's own history. + * `result === false` means no releasable commits were found since the last + * `config-v*` tag (semantic-release already logs why); otherwise + * `result.nextRelease` carries the computed version, bump type, and notes. + */ +async function computeReleasePlan(isPrivate: boolean): Promise { + const { default: semanticRelease } = await import("semantic-release"); + const result = await semanticRelease( + { + branches: ["develop"], + tagFormat: "config-v${version}", + dryRun: true, + plugins: ["./scripts/semantic-release-path-filter.ts"], + }, + { cwd: packageRoot, env: process.env }, + ); + + if (result === false) { + return { due: false }; + } + + // With no config-v* tag on the branch, semantic-release would cut 1.0.0 + // analyzed from the ENTIRE monorepo history — the release notes (the human + // approver's artifact and the public GH release body) would be a changelog + // of every commit that ever touched packages/config/. Refuse until a + // baseline tag exists (see AGENTS.md "One-time setup"); the escape hatch is + // for a deliberate, eyes-open first cut. + if (!result.lastRelease.gitTag && !process.env.CONFIG_RELEASE_ALLOW_NO_BASELINE) { + throw new Error( + "no config-v* baseline tag found on this branch: semantic-release would release " + + `${result.nextRelease.version} with notes generated from the entire monorepo history. ` + + "Push a baseline tag first (e.g. config-v0.1.0 — see packages/config/AGENTS.md), or set " + + "CONFIG_RELEASE_ALLOW_NO_BASELINE=1 to proceed deliberately.", + ); + } + + const version = result.nextRelease.version; + // The version flows into `npm pkg set`, a git tag name, and a GH release + // title — refuse anything that isn't the plain stable x.y.z this + // stable-only train can produce. + if (!/^\d+\.\d+\.\d+$/.test(version)) { + throw new Error(`computed version "${version}" is not a plain x.y.z stable version.`); + } + + return { + due: true, + version, + bumpType: result.nextRelease.type, + notes: result.nextRelease.notes ?? "", + isPrivate, + }; +} + +async function appendGithubOutput(lines: readonly string[]): Promise { + const outputPath = process.env.GITHUB_OUTPUT; + if (!outputPath) { + return; + } + await appendFile(outputPath, `${lines.join("\n")}\n`); +} + +async function appendStepSummary(markdown: string): Promise { + const summaryPath = process.env.GITHUB_STEP_SUMMARY; + if (!summaryPath) { + return; + } + await appendFile(summaryPath, `${markdown}\n`); +} + +/** + * A fence long enough that no backtick run inside `content` can close it — + * the notes are commit-message-derived (squash-merge messages sourced from PR + * titles/bodies, including external contributors'), and this summary is the + * approver's evidence: rendering them as live markdown would let a crafted + * commit message forge parts of it. + */ +function fenceFor(content: string): string { + const longestRun = Math.max(0, ...[...content.matchAll(/`+/g)].map((match) => match[0].length)); + return "`".repeat(Math.max(3, longestRun + 1)); +} + +export function toGithubOutputLines(plan: ReleasePlan): string[] { + const shouldRelease = plan.due && !plan.isPrivate; + const blockedOnPrivate = plan.due && plan.isPrivate; + return [ + `should_release=${shouldRelease}`, + `version=${plan.due ? plan.version : ""}`, + `npm_tag=latest`, + `blocked_on_private=${blockedOnPrivate}`, + ]; +} + +export function renderStepSummary(plan: ReleasePlan): string { + const lines: string[] = ["## @supabase/config release plan", ""]; + + if (!plan.due) { + lines.push( + `No release: no releasable commits touching \`${PACKAGE_PATH_PREFIX}\` since the last ` + + "`config-v*` tag.", + ); + return lines.join("\n"); + } + + lines.push(`**${plan.version}** (\`${plan.bumpType}\` release).`, ""); + + if (plan.isPrivate) { + lines.push( + "> [!WARNING]", + "> `packages/config` is still `private: true`, so publishing is blocked — flip it under " + + "CLI-2169. This run validated the release pipeline only; nothing will be published.", + "", + ); + } + + if (plan.notes) { + const notes = plan.notes.trim(); + const fence = fenceFor(notes); + lines.push( + "
Release notes (markdown source)", + "", + `${fence}markdown`, + notes, + fence, + "", + "
", + ); + } + + return lines.join("\n"); +} + +function renderLocalPlan(plan: ReleasePlan): string { + if (!plan.due) { + return ( + `[release-plan] no release due for @supabase/config (no commits touching ` + + `${PACKAGE_PATH_PREFIX} since the last config-v* tag).` + ); + } + const privateNote = plan.isPrivate ? " (blocked: packages/config is still private: true)" : ""; + return `[release-plan] @supabase/config would release ${plan.version} (${plan.bumpType})${privateNote}.`; +} + +async function main(): Promise { + const { values } = parseArgs({ options: { "notes-out": { type: "string" } } }); + const notesOutPath = values["notes-out"]; + + const packageJson = await readPackageJson(); + const isPrivate = packageJson.private === true; + + const plan = await computeReleasePlan(isPrivate); + + if (plan.due && notesOutPath) { + // Guarantee the trailing newline: the notes end up as a GH release + // body_path file, and a missing final newline is the kind of upstream + // formatting detail nothing else pins. + await Bun.write(notesOutPath, plan.notes.endsWith("\n") ? plan.notes : `${plan.notes}\n`); + } + + if (process.env.GITHUB_OUTPUT) { + await appendGithubOutput(toGithubOutputLines(plan)); + } else { + console.log(renderLocalPlan(plan)); + } + + await appendStepSummary(renderStepSummary(plan)); +} + +if (import.meta.main) { + try { + await main(); + } catch (error) { + console.error(`[release-plan] ${error instanceof Error ? error.message : String(error)}`); + process.exit(1); + } +} diff --git a/packages/config/scripts/release-plan.unit.test.ts b/packages/config/scripts/release-plan.unit.test.ts new file mode 100644 index 0000000000..7da6096665 --- /dev/null +++ b/packages/config/scripts/release-plan.unit.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, test } from "vitest"; +import { renderStepSummary, toGithubOutputLines, type ReleasePlan } from "./release-plan.ts"; + +const duePublicPlan: ReleasePlan = { + due: true, + version: "0.2.0", + bumpType: "minor", + notes: "### Features\n\n* **config:** add a thing\n", + isPrivate: false, +}; + +describe("toGithubOutputLines", () => { + test("a due release on a public package releases under the latest dist-tag", () => { + expect(toGithubOutputLines(duePublicPlan)).toEqual([ + "should_release=true", + "version=0.2.0", + "npm_tag=latest", + "blocked_on_private=false", + ]); + }); + + test("a due release on a still-private package keeps the version but blocks the release", () => { + expect(toGithubOutputLines({ ...duePublicPlan, isPrivate: true })).toEqual([ + "should_release=false", + "version=0.2.0", + "npm_tag=latest", + "blocked_on_private=true", + ]); + }); + + test("no due release emits an empty version sentinel the workflow's if: guards key off", () => { + expect(toGithubOutputLines({ due: false })).toEqual([ + "should_release=false", + "version=", + "npm_tag=latest", + "blocked_on_private=false", + ]); + }); +}); + +describe("renderStepSummary", () => { + test("reports when no releasable commits touched the package", () => { + const summary = renderStepSummary({ due: false }); + + expect(summary).toContain("## @supabase/config release plan"); + expect(summary).toContain("No release:"); + }); + + test("warns prominently when the package is still private", () => { + const summary = renderStepSummary({ ...duePublicPlan, isPrivate: true }); + + expect(summary).toContain("**0.2.0**"); + expect(summary).toContain("> [!WARNING]"); + expect(summary).toContain("private: true"); + }); + + test("fences the commit-derived notes so they cannot render as live markdown", () => { + const summary = renderStepSummary(duePublicPlan); + + expect(summary).toContain("```markdown"); + expect(summary).toContain("* **config:** add a thing"); + }); + + test("a backtick fence inside the notes cannot close the summary's fence", () => { + const notesWithFence = 'feat: docs with an example\n\n```ts\nconst x = "y";\n```\n'; + const summary = renderStepSummary({ ...duePublicPlan, notes: notesWithFence }); + + expect(summary).toContain("````markdown"); + expect(summary).not.toMatch(/^```markdown/m); + }); +}); diff --git a/packages/config/scripts/semantic-release-path-filter.ts b/packages/config/scripts/semantic-release-path-filter.ts new file mode 100644 index 0000000000..2d75ee2fe1 --- /dev/null +++ b/packages/config/scripts/semantic-release-path-filter.ts @@ -0,0 +1,150 @@ +/** + * An in-repo replacement for the unmaintained `semantic-release-monorepo` + * wrapper (CLI-2233). This repo runs `@supabase/config`'s release from the + * monorepo root's git history: without filtering, a `fix:` commit anywhere + * in `apps/cli` (or any other workspace) would be analyzed as if it touched + * `packages/config/` and falsely trigger a config release. + * + * {@link filterCommitsToPackage} narrows `context.commits` down to the ones + * whose diff actually touches a path under {@link PACKAGE_PATH_PREFIX}; + * {@link analyzeCommits}/{@link generateNotes} apply that filter and then + * delegate to the real `@semantic-release/commit-analyzer` and + * `@semantic-release/release-notes-generator` plugins, so this package still + * gets the standard Angular commit-analysis and changelog rendering — just + * scoped to its own history. + */ + +import process from "node:process"; + +import type { AnalyzeCommitsContext, GenerateNotesContext } from "semantic-release"; + +/** + * `@semantic-release/commit-analyzer` and `@semantic-release/release-notes-generator` + * ship no `.d.ts` of their own (only `semantic-release` itself does), and + * TypeScript refuses a `declare module` augmentation for a specifier that + * already resolves to a real, untyped file (TS2665) from anywhere but a + * genuinely global, import/export-free `.d.ts` — not an option for a + * single-file plugin. `require()`'s return type is an explicit `any` (not an + * implicit one, so this doesn't trip `noImplicitAny`), and its result is + * narrowed into these two locally declared structural interfaces via typed + * `const` bindings immediately below — no `any`/`as` leaks past that point. + */ +type PluginConfig = Record; + +interface CommitAnalyzerPlugin { + readonly analyzeCommits: ( + pluginConfig: PluginConfig, + context: AnalyzeCommitsContext, + ) => Promise; +} + +interface ReleaseNotesGeneratorPlugin { + readonly generateNotes: ( + pluginConfig: PluginConfig, + context: GenerateNotesContext, + ) => Promise; +} + +const commitAnalyzer: CommitAnalyzerPlugin = require("@semantic-release/commit-analyzer"); +const releaseNotesGenerator: ReleaseNotesGeneratorPlugin = require("@semantic-release/release-notes-generator"); + +export const PACKAGE_PATH_PREFIX = "packages/config/"; + +/** + * Resolves which of `commits` touch a path under {@link PACKAGE_PATH_PREFIX}, + * using ONE batched `git diff-tree --stdin -r --root --name-only -z` + * subprocess rather than one per commit — the first release analyzes the + * repo's entire history (thousands of commits). + * + * Output format (verified empirically against this repo, including a merge + * commit): with `-z`, every element — each echoed input hash and each of its + * changed paths (repo-root-relative) — is NUL-terminated, with no other + * separators. `-z` matters for correctness, not just parsing convenience: + * without it, `core.quotePath` (default true) C-quotes any path with + * non-ASCII or special bytes (`"packages/config/caf\303\251.ts"`), which + * would silently fail the prefix match and drop a genuinely releasable + * commit. A merge commit prints nothing at all here (no hash, no paths) + * because `-m` is deliberately omitted: this trunk is squash-merged, so a + * merge commit carries no analyzable change of its own, and dropping it out + * of the result is intended, not a parsing gap. A root commit would print + * nothing too — `--root` closes that gap by diffing it against the empty + * tree (merge behavior is unaffected). + */ +export async function filterCommitsToPackage( + commits: readonly T[], + cwd: string, +): Promise { + if (commits.length === 0) { + return []; + } + + const hashes = commits.map((commit) => commit.hash); + const knownHashes = new Set(hashes); + + const proc = Bun.spawn(["git", "diff-tree", "--stdin", "-r", "--root", "--name-only", "-z"], { + cwd, + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + }); + // Start draining stdout/stderr before writing stdin: Bun buffers subprocess + // output eagerly so this can't deadlock today, but the classic full-pipe + // deadlock (git blocked writing stdout while we're blocked writing stdin) + // is one runtime port away — don't rely on the buffering behavior. + const stdoutText = new Response(proc.stdout).text(); + const stderrText = new Response(proc.stderr).text(); + await proc.stdin.write(`${hashes.join("\n")}\n`); + await proc.stdin.end(); + + const [exitCode, stdout, stderr] = await Promise.all([proc.exited, stdoutText, stderrText]); + if (exitCode !== 0) { + throw new Error(`git diff-tree --stdin failed with exit code ${exitCode}: ${stderr.trim()}`); + } + + const touchedHashes = new Set(); + let currentHash: string | null = null; + for (const element of stdout.split("\0")) { + if (element.length === 0) { + continue; + } + if (knownHashes.has(element)) { + currentHash = element; + continue; + } + if (currentHash !== null && element.startsWith(PACKAGE_PATH_PREFIX)) { + touchedHashes.add(currentHash); + } + } + + return commits.filter((commit) => touchedHashes.has(commit.hash)); +} + +async function withFilteredCommits( + context: C, + step: string, + delegate: (filteredContext: C) => Promise, +): Promise { + const filtered = await filterCommitsToPackage(context.commits, context.cwd ?? process.cwd()); + context.logger.log( + `${step}: ${filtered.length} of ${context.commits.length} commits touch ${PACKAGE_PATH_PREFIX}`, + ); + return delegate({ ...context, commits: filtered }); +} + +export async function analyzeCommits( + pluginConfig: PluginConfig, + context: AnalyzeCommitsContext, +): Promise { + return withFilteredCommits(context, "analyzeCommits", (filteredContext) => + commitAnalyzer.analyzeCommits(pluginConfig, filteredContext), + ); +} + +export async function generateNotes( + pluginConfig: PluginConfig, + context: GenerateNotesContext, +): Promise { + return withFilteredCommits(context, "generateNotes", (filteredContext) => + releaseNotesGenerator.generateNotes(pluginConfig, filteredContext), + ); +} diff --git a/packages/config/scripts/semantic-release-path-filter.unit.test.ts b/packages/config/scripts/semantic-release-path-filter.unit.test.ts new file mode 100644 index 0000000000..f2d0d3f99e --- /dev/null +++ b/packages/config/scripts/semantic-release-path-filter.unit.test.ts @@ -0,0 +1,311 @@ +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import process from "node:process"; +import type { AnalyzeCommitsContext, Commit, GenerateNotesContext } from "semantic-release"; +import { afterAll, beforeAll, describe, expect, test } from "vitest"; +import { + PACKAGE_PATH_PREFIX, + analyzeCommits, + filterCommitsToPackage, + generateNotes, +} from "./semantic-release-path-filter.ts"; + +// Hermetic git identity/signing: the developer's global gitconfig may require +// commit signing, which would hang or fail these commits otherwise. +const GIT_HERMETIC_CONFIG = [ + "-c", + "user.name=t", + "-c", + "user.email=t@t", + "-c", + "commit.gpgsign=false", +]; + +async function git(cwd: string, args: string[]): Promise { + const proc = Bun.spawn(["git", ...GIT_HERMETIC_CONFIG, ...args], { + cwd, + stdout: "pipe", + stderr: "pipe", + }); + const [exitCode, stdout, stderr] = await Promise.all([ + proc.exited, + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + ]); + if (exitCode !== 0) { + throw new Error(`git ${args.join(" ")} failed with exit code ${exitCode}: ${stderr.trim()}`); + } + return stdout; +} + +async function commitFiles( + cwd: string, + files: Record, + message: string, +): Promise { + for (const [relativePath, content] of Object.entries(files)) { + const fullPath = join(cwd, relativePath); + await mkdir(dirname(fullPath), { recursive: true }); + await writeFile(fullPath, content); + } + await git(cwd, ["add", ...Object.keys(files)]); + await git(cwd, ["commit", "-m", message]); + return (await git(cwd, ["rev-parse", "HEAD"])).trim(); +} + +function fakeCommit(hash: string, message: string): Commit { + return { + commit: { long: hash, short: hash.slice(0, 7) }, + tree: { long: hash, short: hash.slice(0, 7) }, + author: { name: "t", email: "t@t", short: "2024-01-01" }, + committer: { name: "t", email: "t@t", short: "2024-01-01" }, + subject: message.split("\n")[0] ?? message, + body: "", + message, + hash, + committerDate: "2024-01-01", + }; +} + +// AnalyzeCommitsContext pulls in every field of semantic-release's +// VerifyConditionsContext/BaseContext, but analyzeCommits() (ours and the +// real commit-analyzer it delegates to) only reads commits/cwd/logger.log; +// the rest is dummy filler required purely to satisfy the type. +function fakeAnalyzeCommitsContext(commits: Commit[], cwd: string): AnalyzeCommitsContext { + return { + commits, + cwd, + releases: [], + lastRelease: { + version: "0.0.0", + gitTag: "v0.0.0", + channels: [], + gitHead: "0".repeat(40), + name: "v0.0.0", + }, + stdout: process.stdout, + stderr: process.stderr, + env: {}, + envCi: { isCi: false, commit: "", branch: "main" }, + branch: { name: "main" }, + branches: [{ name: "main" }], + options: {}, + logger: { log: () => {} }, + }; +} + +describe("semantic-release-path-filter", () => { + let repoDir: string; + let hashConfigOnly: string; + let hashCliOnly: string; + let hashBoth: string; + let hashPrefixTrap: string; + let hashNonAsciiPath: string; + let hashMerge: string; + + beforeAll(async () => { + repoDir = await mkdtemp(join(tmpdir(), "semantic-release-path-filter-")); + await git(repoDir, ["init", "-b", "main", "-q"]); + + // Seed a plain root commit so the main scenario commits all exercise the + // ordinary (parented) diff path; the root-commit case (`--root`) has its + // own dedicated test below. + await commitFiles(repoDir, { "README.md": "seed\n" }, "chore: seed repo root commit"); + + hashConfigOnly = await commitFiles( + repoDir, + { "packages/config/src/foo.ts": "export const foo = 1;\n" }, + "chore: seed packages/config/src/foo.ts", + ); + hashCliOnly = await commitFiles( + repoDir, + { "apps/cli/src/bar.ts": "export const bar = 1;\n" }, + "chore: seed apps/cli/src/bar.ts", + ); + hashBoth = await commitFiles( + repoDir, + { + "packages/config/src/foo2.ts": "export const foo2 = 1;\n", + "apps/cli/src/bar2.ts": "export const bar2 = 1;\n", + }, + "chore: seed both packages/config and apps/cli files", + ); + hashPrefixTrap = await commitFiles( + repoDir, + { "packages/config-other/x.ts": "export const x = 1;\n" }, + "chore: seed packages/config-other, a prefix-adjacent trap", + ); + hashNonAsciiPath = await commitFiles( + repoDir, + { "packages/config/src/café.ts": "export const café = 1;\n" }, + "chore: seed a non-ASCII path under packages/config", + ); + + await git(repoDir, ["checkout", "-b", "feature", "-q"]); + await commitFiles( + repoDir, + { "packages/config/src/on-branch.ts": "export const onBranch = 1;\n" }, + "chore: seed a commit on the feature branch", + ); + await git(repoDir, ["checkout", "main", "-q"]); + await git(repoDir, ["merge", "--no-ff", "feature", "-m", "merge: merge feature branch"]); + hashMerge = (await git(repoDir, ["rev-parse", "HEAD"])).trim(); + }); + + afterAll(async () => { + await rm(repoDir, { recursive: true, force: true }); + }); + + test("PACKAGE_PATH_PREFIX is the packages/config/ prefix the release train filters commits against", () => { + expect(PACKAGE_PATH_PREFIX).toBe("packages/config/"); + }); + + describe("filterCommitsToPackage", () => { + test("keeps only commits whose diff touches packages/config/**, preserving the input's order", async () => { + const shuffledInput = [hashCliOnly, hashBoth, hashPrefixTrap, hashMerge, hashConfigOnly].map( + (hash) => ({ + hash, + }), + ); + + const result = await filterCommitsToPackage(shuffledInput, repoDir); + + expect(result).toEqual([{ hash: hashBoth }, { hash: hashConfigOnly }]); + }); + + test("excludes a merge commit even though the branch it merged touched packages/config/**", async () => { + const result = await filterCommitsToPackage([{ hash: hashMerge }], repoDir); + + expect(result).toEqual([]); + }); + + test("does not treat packages/config-other/ as a match for the packages/config/ prefix", async () => { + const result = await filterCommitsToPackage([{ hash: hashPrefixTrap }], repoDir); + + expect(result).toEqual([]); + }); + + test("returns an empty array for empty input", async () => { + const result = await filterCommitsToPackage([], repoDir); + + expect(result).toEqual([]); + }); + + test("includes a commit whose only config path is non-ASCII (core.quotePath would C-quote it without -z)", async () => { + const result = await filterCommitsToPackage([{ hash: hashNonAsciiPath }], repoDir); + + expect(result).toEqual([{ hash: hashNonAsciiPath }]); + }); + + test("includes a root commit that touches packages/config/** (--root diffs it against the empty tree)", async () => { + const rootRepoDir = await mkdtemp(join(tmpdir(), "semantic-release-path-filter-root-")); + try { + await git(rootRepoDir, ["init", "-b", "main", "-q"]); + const rootHash = await commitFiles( + rootRepoDir, + { "packages/config/src/first.ts": "export const first = 1;\n" }, + "chore: repo root commit touching packages/config", + ); + + const result = await filterCommitsToPackage([{ hash: rootHash }], rootRepoDir); + + expect(result).toEqual([{ hash: rootHash }]); + } finally { + await rm(rootRepoDir, { recursive: true, force: true }); + } + }); + + test("rejects with a descriptive error when git diff-tree exits non-zero", async () => { + const notARepo = await mkdtemp(join(tmpdir(), "semantic-release-path-filter-not-a-repo-")); + try { + await expect(filterCommitsToPackage([{ hash: "a".repeat(40) }], notARepo)).rejects.toThrow( + /^git diff-tree --stdin failed with exit code 128: /, + ); + } finally { + await rm(notARepo, { recursive: true, force: true }); + } + }); + }); + + describe("analyzeCommits", () => { + test('resolves "minor" for a feat(config) commit whose diff touches packages/config', async () => { + const context = fakeAnalyzeCommitsContext( + [fakeCommit(hashConfigOnly, "feat(config): add a new config option")], + repoDir, + ); + + const result = await analyzeCommits({}, context); + + expect(result).toBe("minor"); + }); + + test("resolves null when the only commits are fix commits touching apps/cli, not packages/config", async () => { + const context = fakeAnalyzeCommitsContext( + [fakeCommit(hashCliOnly, "fix: correct an unrelated cli bug")], + repoDir, + ); + + const result = await analyzeCommits({}, context); + + expect(result).toBeNull(); + }); + + test('resolves "patch", not "major", because the breaking-change commit outside packages/config is filtered out', async () => { + const context = fakeAnalyzeCommitsContext( + [ + fakeCommit(hashConfigOnly, "fix: correct a bug in the config parser"), + fakeCommit( + hashCliOnly, + "feat!: drop legacy cli flag\n\nBREAKING CHANGE: removes the legacy flag entirely", + ), + ], + repoDir, + ); + + const result = await analyzeCommits({}, context); + + expect(result).toBe("patch"); + }); + }); + + describe("generateNotes", () => { + function fakeGenerateNotesContext(commits: Commit[], cwd: string): GenerateNotesContext { + const base = fakeAnalyzeCommitsContext(commits, cwd); + return { + ...base, + options: { ...base.options, repositoryUrl: "https://github.com/supabase/cli.git" }, + lastRelease: { + version: "0.1.0", + gitTag: "config-v0.1.0", + channels: [], + gitHead: commits[0]?.hash ?? "0".repeat(40), + name: "config-v0.1.0", + }, + nextRelease: { + version: "0.2.0", + gitTag: "config-v0.2.0", + gitHead: commits[commits.length - 1]?.hash ?? "0".repeat(40), + name: "config-v0.2.0", + type: "minor", + channel: "latest", + }, + }; + } + + test("the notes mention the config commit and omit the commit that only touched apps/cli", async () => { + const context = fakeGenerateNotesContext( + [ + fakeCommit(hashConfigOnly, "feat(config): add a new config option"), + fakeCommit(hashCliOnly, "fix(cli): unrelated cli bug that must not appear"), + ], + repoDir, + ); + + const notes = await generateNotes({}, context); + + expect(notes).toContain("add a new config option"); + expect(notes).not.toContain("unrelated cli bug that must not appear"); + }); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5d76c74cd0..d964a86cfc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -45,6 +45,9 @@ catalogs: oxlint-tsgolint: specifier: ^7.0.2001 version: 7.0.2001 + semantic-release: + specifier: ^25.0.9 + version: 25.0.9 tldts: specifier: ^7.4.10 version: 7.4.10 @@ -229,7 +232,7 @@ importers: specifier: ^7.0.1 version: 7.0.1 semantic-release: - specifier: ^25.0.9 + specifier: 'catalog:' version: 25.0.9(typescript@7.0.2) smol-toml: specifier: ^1.8.0 @@ -419,6 +422,12 @@ importers: '@effect/platform-node': specifier: 'catalog:' version: 4.0.0-rc.111(effect@4.0.0-rc.111)(redis@6.2.1) + '@semantic-release/commit-analyzer': + specifier: ^13.0.1 + version: 13.0.1(semantic-release@25.0.9(typescript@7.0.2)) + '@semantic-release/release-notes-generator': + specifier: ^14.1.1 + version: 14.1.1(semantic-release@25.0.9(typescript@7.0.2)) '@tsconfig/bun': specifier: 'catalog:' version: 1.0.11 @@ -431,6 +440,9 @@ importers: effect: specifier: 'catalog:' version: 4.0.0-rc.111 + semantic-release: + specifier: 'catalog:' + version: 25.0.9(typescript@7.0.2) typescript: specifier: 'catalog:' version: 7.0.2 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index b7a6e7eee7..be9204ad17 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -26,6 +26,7 @@ catalog: "oxfmt": "^0.63.0" "oxlint": "^1.78.0" "oxlint-tsgolint": "^7.0.2001" + "semantic-release": "^25.0.9" "tldts": "^7.4.10" "turbo": "2.10.11" "vitest": "^4.1.10" diff --git a/tools/config-api-compare.ts b/tools/config-api-compare.ts index caa320481a..5460b16afd 100644 --- a/tools/config-api-compare.ts +++ b/tools/config-api-compare.ts @@ -28,11 +28,19 @@ * failure. */ -import { appendFile, cp, mkdir, mkdtemp, readFile, rm } from "node:fs/promises"; +import { cp, mkdir, mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import process from "node:process"; import { parseArgs } from "node:util"; +import { + type CompareResult, + countByStatus, + countDeclarationFiles, + diffDeclarationTrees, + renderDiffDetailsBlocks, + writeStepSummary, +} from "./lib/dts-diff.ts"; const repoRoot = path.resolve(import.meta.dir, ".."); const packageRoot = path.join(repoRoot, "packages", "config"); @@ -283,24 +291,6 @@ interface EmitResult { readonly fileCount: number; } -async function countDeclarationFiles(dir: string): Promise { - const glob = new Bun.Glob("**/*.d.ts"); - let count = 0; - for await (const _relativePath of glob.scan({ cwd: dir })) { - count++; - } - return count; -} - -async function listDeclarationFiles(dir: string): Promise { - const glob = new Bun.Glob("**/*.d.ts"); - const relativePaths: string[] = []; - for await (const relativePath of glob.scan({ cwd: dir })) { - relativePaths.push(relativePath); - } - return relativePaths.sort(); -} - /** * Spawns this package's own `node_modules/.bin/tsc` directly rather than * `pnpm exec tsc` (the same corepack-avoidance lesson as the old @@ -330,98 +320,6 @@ async function emitDeclarations( return { exitCode, stdout, stderr, fileCount }; } -async function unifiedDiff( - oldPath: string, - newPath: string, - oldLabel: string, - newLabel: string, -): Promise { - const proc = Bun.spawn(["diff", "-u", "-L", oldLabel, "-L", newLabel, oldPath, newPath], { - stdout: "pipe", - stderr: "pipe", - }); - const [, stdout] = await Promise.all([proc.exited, new Response(proc.stdout).text()]); - return stdout; -} - -interface FileEntry { - readonly status: "added" | "removed" | "changed"; - readonly path: string; - readonly diff: string; -} - -interface CompareResult { - readonly identical: boolean; - readonly entries: readonly FileEntry[]; -} - -/** Diffs the two emitted `.d.ts` trees (`**\/*.d.ts` only — the glob itself never matches `.d.ts.map`). */ -async function diffDeclarationTrees(headDir: string, baseDir: string): Promise { - const [headFiles, baseFiles] = await Promise.all([ - listDeclarationFiles(headDir), - listDeclarationFiles(baseDir), - ]); - const headSet = new Set(headFiles); - const baseSet = new Set(baseFiles); - - const entries: FileEntry[] = []; - - for (const relativePath of headFiles) { - if (!baseSet.has(relativePath)) { - entries.push({ - status: "added", - path: relativePath, - diff: await unifiedDiff( - "/dev/null", - path.join(headDir, relativePath), - "/dev/null", - `head/${relativePath}`, - ), - }); - continue; - } - - const [headContent, baseContent] = await Promise.all([ - readFile(path.join(headDir, relativePath), "utf8"), - readFile(path.join(baseDir, relativePath), "utf8"), - ]); - if (headContent !== baseContent) { - entries.push({ - status: "changed", - path: relativePath, - diff: await unifiedDiff( - path.join(baseDir, relativePath), - path.join(headDir, relativePath), - `base/${relativePath}`, - `head/${relativePath}`, - ), - }); - } - } - - for (const relativePath of baseFiles) { - if (!headSet.has(relativePath)) { - entries.push({ - status: "removed", - path: relativePath, - diff: await unifiedDiff( - path.join(baseDir, relativePath), - "/dev/null", - `base/${relativePath}`, - "/dev/null", - ), - }); - } - } - - entries.sort((a, b) => a.path.localeCompare(b.path)); - return { identical: entries.length === 0, entries }; -} - -function countByStatus(entries: readonly FileEntry[], status: FileEntry["status"]): number { - return entries.filter((entry) => entry.status === status).length; -} - function renderTextReport(baseLabel: string, headLabel: string, result: CompareResult): string { const lines: string[] = [`Config type-surface diff: ${baseLabel} -> ${headLabel}`, ""]; if (result.identical) { @@ -464,18 +362,7 @@ function renderMarkdownSummary( `${countByStatus(result.entries, "changed")} changed**`, "", ); - for (const entry of result.entries) { - lines.push( - `
${entry.status}: ${entry.path}`, - "", - "```diff", - entry.diff.trimEnd(), - "```", - "", - "
", - "", - ); - } + lines.push(...renderDiffDetailsBlocks(result.entries)); return lines.join("\n"); } @@ -503,14 +390,6 @@ function renderSkippedSummary(baseLabel: string, headLabel: string, baseEmit: Em ].join("\n"); } -async function writeStepSummary(markdown: string): Promise { - const summaryPath = process.env.GITHUB_STEP_SUMMARY; - if (!summaryPath) { - return; - } - await appendFile(summaryPath, `${markdown}\n`); -} - async function main(): Promise { requireBinaries(["git", "tar", "diff"]); diff --git a/tools/config-release-gate.ts b/tools/config-release-gate.ts new file mode 100644 index 0000000000..4b799db581 --- /dev/null +++ b/tools/config-release-gate.ts @@ -0,0 +1,487 @@ +/** + * Gates an `@supabase/config` npm release on its compiled `.d.ts` surface — + * diffs the freshly built `packages/config/dist/**\/*.d.ts` against the + * previously published npm tarball's declarations, so a human approving + * `npm publish` sees the surface diff before signing off (CLI-2233; the + * release-time counterpart to the PR-time advisory compare in + * `tools/config-api-compare.ts`). + * + * Usage: + * bun tools/config-release-gate.ts --version [--registry ] [--tarball ] + * + * `--version` is the version semantic-release computed for this release. + * `--registry` defaults to `npm config get registry` (the same + * probe-matches-publish-target alignment `apps/cli/scripts/publish.ts` uses, + * so the local Verdaccio harness works here too), falling back to the public + * npm registry. `--tarball` points at a local `.tgz` to use as the + * "published" side instead of querying the registry — for local testing and + * pipeline rehearsal. A registry-downloaded tarball is verified against the + * registry's `dist.integrity` and refused if its URL points off-registry. + * + * This tool never builds `packages/config/dist` itself — run the package + * build first. When the package has never been published (npm view returns + * E404), the full surface ships as-is and there is nothing to diff against. + * + * Deliberately never exits 1 on a surface diff: the gate IS the human + * approval step reading this summary, not an automatic pass/fail check. + * + * Exit codes: 0 the gate ran (including a first release with nothing + * published yet), 2 tool failure. + */ + +import { mkdir, mkdtemp, readFile, rm, stat } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import process from "node:process"; +import { parseArgs } from "node:util"; +import { + type CompareResult, + countByStatus, + countDeclarationFiles, + diffDeclarationTrees, + type FileEntry, + renderDiffDetailsBlocks, + writeStepSummary, +} from "./lib/dts-diff.ts"; + +const PACKAGE_NAME = "@supabase/config"; +const DEFAULT_REGISTRY = "https://registry.npmjs.org"; + +const repoRoot = path.resolve(import.meta.dir, ".."); +const packageRoot = path.join(repoRoot, "packages", "config"); +const localDistDir = path.join(packageRoot, "dist"); + +interface CommandResult { + readonly exitCode: number; + readonly stdout: string; + readonly stderr: string; +} + +async function runCommand(cmd: readonly string[]): Promise { + const proc = Bun.spawn([...cmd], { cwd: repoRoot, stdout: "pipe", stderr: "pipe" }); + const [exitCode, stdout, stderr] = await Promise.all([ + proc.exited, + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + ]); + return { exitCode, stdout, stderr }; +} + +function requireBinaries(names: readonly string[]): void { + const missing = names.filter((name) => Bun.which(name) === null); + if (missing.length > 0) { + throw new Error(`this tool requires ${missing.join(", ")} on PATH.`); + } +} + +/** + * Same probe-matches-publish-target alignment as `apps/cli/scripts/publish.ts`: + * the local Verdaccio harness (`pnpm local-registry`) rewrites npm's registry + * config, and the gate must read the registry the publish would target. + */ +async function ambientNpmRegistry(): Promise { + const result = await runCommand(["npm", "config", "get", "registry"]); + const registry = result.stdout.trim().replace(/\/+$/, ""); + return result.exitCode === 0 && registry !== "" ? registry : DEFAULT_REGISTRY; +} + +async function pathExists(target: string): Promise { + try { + await stat(target); + return true; + } catch { + return false; + } +} + +async function extractTarball(tarballPath: string, destDir: string): Promise { + await mkdir(destDir, { recursive: true }); + // The tarball is registry-supplied, i.e. untrusted: never restore its + // recorded owners or permission bits. (GNU tar already refuses `..` + // members, so path escape is covered by the extractor itself.) + const result = await runCommand([ + "tar", + "-xzf", + tarballPath, + "-C", + destDir, + "--no-same-owner", + "--no-same-permissions", + ]); + if (result.exitCode !== 0) { + throw new Error( + `tar extraction of ${tarballPath} into ${destDir} failed: ${result.stderr.trim()}`, + ); + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +/** Reads the `version` field out of an extracted tarball's `package/package.json`. */ +async function readExtractedPackageVersion(extractDir: string): Promise { + const packageJsonPath = path.join(extractDir, "package", "package.json"); + const raw = await readFile(packageJsonPath, "utf8"); + const parsed: unknown = JSON.parse(raw); + if (!isRecord(parsed) || typeof parsed.version !== "string") { + throw new Error(`${packageJsonPath} has no string "version" field.`); + } + return parsed.version; +} + +async function downloadFile(url: string, destPath: string): Promise { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`failed to download ${url}: HTTP ${response.status}`); + } + await Bun.write(destPath, response); +} + +/** + * `npm view --json` reports errors as `{"error":{"code":…}}` on stdout, so a + * genuine E404 (never published) is distinguishable from a transport failure + * or an error message that merely mentions "E404" somewhere. + */ +async function npmViewJson( + spec: string, + field: string, + registry: string, +): Promise< + { readonly ok: true; readonly value: unknown } | { readonly ok: false; readonly code: string } +> { + const result = await runCommand(["npm", "view", spec, field, "--registry", registry, "--json"]); + let parsed: unknown; + try { + // `npm view` prints nothing at all for an absent field. + parsed = result.stdout.trim() === "" ? undefined : JSON.parse(result.stdout); + } catch { + throw new Error( + `npm view ${spec} ${field} --registry ${registry} returned unparseable output: ${result.stdout.trim().slice(0, 200)}`, + ); + } + if (result.exitCode !== 0) { + if (isRecord(parsed) && isRecord(parsed.error) && typeof parsed.error.code === "string") { + return { ok: false, code: parsed.error.code }; + } + throw new Error( + `npm view ${spec} ${field} --registry ${registry} failed: ${result.stderr.trim()}`, + ); + } + return { ok: true, value: parsed }; +} + +async function verifyTarballIntegrity(tarballPath: string, integrity: string): Promise { + if (!integrity.startsWith("sha512-")) { + throw new Error(`expected a sha512 integrity value from the registry, got "${integrity}".`); + } + const hasher = new Bun.CryptoHasher("sha512"); + hasher.update(await Bun.file(tarballPath).arrayBuffer()); + const digest = `sha512-${hasher.digest("base64")}`; + if (digest !== integrity) { + throw new Error( + `downloaded tarball failed integrity verification: registry says ${integrity}, got ${digest}.`, + ); + } +} + +type PublishedSide = + | { readonly kind: "first-publish" } + | { readonly kind: "resolved"; readonly version: string; readonly distDir: string }; + +/** + * Resolves the "published" side of the diff: an explicit `--tarball` wins; + * otherwise queries `` for the current `dist-tags.latest` and + * downloads that tarball, verifying it against the registry's own + * `dist.integrity` and refusing a tarball URL pointing off-registry. An + * `E404` (or an existing package with no `latest` dist-tag) means there is + * nothing published to compare against — reported as `"first-publish"` + * rather than an error. + */ +async function resolvePublishedSide( + registry: string, + tarballArg: string | undefined, + extractDir: string, +): Promise { + if (tarballArg) { + await extractTarball(tarballArg, extractDir); + const version = await readExtractedPackageVersion(extractDir); + return { kind: "resolved", version, distDir: path.join(extractDir, "package", "dist") }; + } + + const latestResult = await npmViewJson(PACKAGE_NAME, "dist-tags.latest", registry); + if (!latestResult.ok) { + if (latestResult.code === "E404") { + return { kind: "first-publish" }; + } + throw new Error(`npm view ${PACKAGE_NAME} dist-tags.latest failed with ${latestResult.code}.`); + } + if (typeof latestResult.value !== "string" || latestResult.value === "") { + console.warn( + `[config-release-gate] ${PACKAGE_NAME} exists on ${registry} but has no "latest" dist-tag — treating as first publish.`, + ); + return { kind: "first-publish" }; + } + const latestVersion = latestResult.value; + + const spec = `${PACKAGE_NAME}@${latestVersion}`; + const [tarballUrlResult, integrityResult] = await Promise.all([ + npmViewJson(spec, "dist.tarball", registry), + npmViewJson(spec, "dist.integrity", registry), + ]); + if (!tarballUrlResult.ok || typeof tarballUrlResult.value !== "string") { + throw new Error(`npm view ${spec} dist.tarball returned no tarball URL.`); + } + if (!integrityResult.ok || typeof integrityResult.value !== "string") { + throw new Error(`npm view ${spec} dist.integrity returned no integrity value.`); + } + const tarballUrl = tarballUrlResult.value; + if (new URL(tarballUrl).origin !== new URL(registry).origin) { + throw new Error( + `refusing tarball from ${new URL(tarballUrl).origin} — it does not match the registry origin ${new URL(registry).origin}.`, + ); + } + + const downloadedTarballPath = path.join(extractDir, "published.tgz"); + await downloadFile(tarballUrl, downloadedTarballPath); + await verifyTarballIntegrity(downloadedTarballPath, integrityResult.value); + await extractTarball(downloadedTarballPath, extractDir); + + return { + kind: "resolved", + version: latestVersion, + distDir: path.join(extractDir, "package", "dist"), + }; +} + +type BumpClass = "major" | "minor" | "patch" | "none" | "unknown"; + +interface VersionParts { + readonly major: number; + readonly minor: number; + readonly patch: number; +} + +function parseVersionParts(version: string): VersionParts | null { + const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(version); + if (!match) { + return null; + } + return { major: Number(match[1]), minor: Number(match[2]), patch: Number(match[3]) }; +} + +/** + * Numeric major/minor/patch comparison only — this train is stable-only, so + * plain "x.y.z" is the expected shape on both sides. The published side comes + * from outside this pipeline though (`dist-tags.latest`, or `--tarball`), so + * an unparseable version degrades to `"unknown"` (warnings skipped, noted in + * the summary) instead of failing the plan job. + */ +function computeBumpClass(publishedVersion: string, nextVersion: string): BumpClass { + const published = parseVersionParts(publishedVersion); + const next = parseVersionParts(nextVersion); + if (published === null || next === null) return "unknown"; + if (next.major !== published.major) return "major"; + if (next.minor !== published.minor) return "minor"; + if (next.patch !== published.patch) return "patch"; + return "none"; +} + +/** A `changed` file whose unified diff removes lines — the shape an export deletion takes. */ +function hasRemovedDeclarationLines(entries: readonly FileEntry[]): boolean { + return entries.some( + (entry) => + entry.status === "changed" && + entry.diff.split("\n").some((line) => line.startsWith("-") && !line.startsWith("---")), + ); +} + +/** + * Semver sanity warnings for the human approver — surfaced prominently but + * never turned into a non-zero exit; the human decides. + */ +function computeWarnings(bumpClass: BumpClass, result: CompareResult): string[] { + const warnings: string[] = []; + if (bumpClass === "unknown") { + warnings.push( + "Could not classify the version bump (a version is not a plain x.y.z) — review the diff without semver hints.", + ); + return warnings; + } + if (!result.identical && bumpClass === "patch") { + warnings.push( + "Type-surface diff is non-empty but the version bump is only a patch — confirm this isn't a missed minor/major bump.", + ); + } + if (result.entries.some((entry) => entry.status === "removed") && bumpClass !== "major") { + warnings.push( + "A declaration file was removed but the version bump is not major — confirm this isn't a breaking change.", + ); + } + if (hasRemovedDeclarationLines(result.entries) && bumpClass !== "major") { + warnings.push( + "Declaration lines were removed from an existing .d.ts but the version bump is not major — confirm no export was dropped.", + ); + } + return warnings; +} + +function renderTextReport( + publishedVersion: string, + nextVersion: string, + result: CompareResult, + warnings: readonly string[], +): string { + const lines: string[] = [ + `@supabase/config release gate: ${publishedVersion} -> ${nextVersion}`, + "", + ]; + for (const warning of warnings) { + lines.push(`WARNING: ${warning}`); + } + if (warnings.length > 0) { + lines.push(""); + } + + if (result.identical) { + lines.push("No type-surface differences."); + return lines.join("\n"); + } + + lines.push( + `Added: ${countByStatus(result.entries, "added")}, ` + + `Removed: ${countByStatus(result.entries, "removed")}, ` + + `Changed: ${countByStatus(result.entries, "changed")}`, + "", + ); + for (const entry of result.entries) { + lines.push(`--- ${entry.status} ${entry.path} ---`, entry.diff.trimEnd(), ""); + } + return lines.join("\n"); +} + +function renderMarkdownSummary( + publishedVersion: string, + nextVersion: string, + result: CompareResult, + warnings: readonly string[], +): string { + const lines: string[] = [ + "## @supabase/config release gate — type-surface diff", + "", + `\`${publishedVersion}\` → \`${nextVersion}\``, + "", + ]; + for (const warning of warnings) { + lines.push(`⚠️ **${warning}**`, ""); + } + + if (result.identical) { + lines.push("No type-surface differences."); + return lines.join("\n"); + } + + lines.push( + `**${countByStatus(result.entries, "added")} added, ` + + `${countByStatus(result.entries, "removed")} removed, ` + + `${countByStatus(result.entries, "changed")} changed**`, + "", + ); + lines.push(...renderDiffDetailsBlocks(result.entries)); + return lines.join("\n"); +} + +const FIRST_PUBLISH_MESSAGE = + "First publish — no published version to compare against; the full surface ships as-is."; + +function renderFirstPublishSummary(nextVersion: string): string { + return [ + "## @supabase/config release gate — type-surface diff", + "", + `Preparing the first release, \`${nextVersion}\`.`, + "", + FIRST_PUBLISH_MESSAGE, + ].join("\n"); +} + +async function main(): Promise { + const { values } = parseArgs({ + options: { + version: { type: "string" }, + registry: { type: "string" }, + tarball: { type: "string" }, + }, + }); + + if (!values.version) { + throw new Error("--version is required."); + } + const nextVersion = values.version; + + requireBinaries(values.tarball ? ["tar", "diff"] : ["tar", "diff", "npm"]); + + const registry = + values.registry ?? (values.tarball ? DEFAULT_REGISTRY : await ambientNpmRegistry()); + + if (!(await pathExists(localDistDir)) || (await countDeclarationFiles(localDistDir)) === 0) { + throw new Error( + `${localDistDir} has no .d.ts files — run the package build first (e.g. ` + + "`pnpm exec turbo run @supabase/config#build`).", + ); + } + + const extractDir = await mkdtemp(path.join(tmpdir(), "supabase-config-release-gate-")); + + try { + const published = await resolvePublishedSide(registry, values.tarball, extractDir); + + if (published.kind === "first-publish") { + console.log(`[config-release-gate] ${PACKAGE_NAME} has never been published to ${registry}.`); + console.log(FIRST_PUBLISH_MESSAGE); + await writeStepSummary(renderFirstPublishSummary(nextVersion)); + return 0; + } + + // A published tarball without declarations means "nothing to diff", not a + // tool failure — the .gitignore/packlist trap that motivated + // packages/config/.npmignore (CLI-2234) is exactly how such a tarball + // could exist, and it must not block every subsequent release. + if ((await countDeclarationFiles(published.distDir)) === 0) { + const message = + `published ${published.version} tarball contains no .d.ts files — nothing to diff against; ` + + "the next release's full surface ships as reviewed."; + console.warn(`[config-release-gate] ${message}`); + await writeStepSummary( + [ + "## @supabase/config release gate — type-surface diff", + "", + `\`${published.version}\` → \`${nextVersion}\``, + "", + `⚠️ **${message}**`, + ].join("\n"), + ); + return 0; + } + + console.log( + `[config-release-gate] comparing published ${published.version} against next ${nextVersion}...`, + ); + const result = await diffDeclarationTrees(localDistDir, published.distDir); + const bumpClass = computeBumpClass(published.version, nextVersion); + const warnings = computeWarnings(bumpClass, result); + + console.log(renderTextReport(published.version, nextVersion, result, warnings)); + await writeStepSummary(renderMarkdownSummary(published.version, nextVersion, result, warnings)); + + return 0; + } finally { + await rm(extractDir, { recursive: true, force: true }); + } +} + +try { + process.exit(await main()); +} catch (error) { + console.error(`[config-release-gate] ${error instanceof Error ? error.message : String(error)}`); + process.exit(2); +} diff --git a/tools/lib/dts-diff.ts b/tools/lib/dts-diff.ts new file mode 100644 index 0000000000..fbf2d09e89 --- /dev/null +++ b/tools/lib/dts-diff.ts @@ -0,0 +1,190 @@ +/** + * Shared `.d.ts` tree-diffing machinery for `@supabase/config`'s compiled + * declaration surface. Used by both the PR-time advisory compare + * (`tools/config-api-compare.ts`, base vs head commit) and the release-time + * hard gate (`tools/config-release-gate.ts`, published npm tarball vs freshly + * built `dist/`). + */ + +import { appendFile, readFile, stat } from "node:fs/promises"; +import path from "node:path"; +import process from "node:process"; + +/** `Bun.Glob.scan` throws ENOENT on a missing directory; a missing tree means "no declarations", not a tool failure. */ +async function directoryExists(dir: string): Promise { + try { + return (await stat(dir)).isDirectory(); + } catch { + return false; + } +} + +export async function countDeclarationFiles(dir: string): Promise { + if (!(await directoryExists(dir))) { + return 0; + } + const glob = new Bun.Glob("**/*.d.ts"); + let count = 0; + for await (const _relativePath of glob.scan({ cwd: dir })) { + count++; + } + return count; +} + +async function listDeclarationFiles(dir: string): Promise { + if (!(await directoryExists(dir))) { + return []; + } + const glob = new Bun.Glob("**/*.d.ts"); + const relativePaths: string[] = []; + for await (const relativePath of glob.scan({ cwd: dir })) { + relativePaths.push(relativePath); + } + return relativePaths.sort(); +} + +async function unifiedDiff( + oldPath: string, + newPath: string, + oldLabel: string, + newLabel: string, +): Promise { + const proc = Bun.spawn(["diff", "-u", "-L", oldLabel, "-L", newLabel, oldPath, newPath], { + stdout: "pipe", + stderr: "pipe", + }); + const [, stdout] = await Promise.all([proc.exited, new Response(proc.stdout).text()]); + return stdout; +} + +export interface FileEntry { + readonly status: "added" | "removed" | "changed"; + readonly path: string; + readonly diff: string; +} + +export interface CompareResult { + readonly identical: boolean; + readonly entries: readonly FileEntry[]; +} + +/** Diffs two emitted `.d.ts` trees (`**\/*.d.ts` only — the glob itself never matches `.d.ts.map`). */ +export async function diffDeclarationTrees( + headDir: string, + baseDir: string, +): Promise { + const [headFiles, baseFiles] = await Promise.all([ + listDeclarationFiles(headDir), + listDeclarationFiles(baseDir), + ]); + const headSet = new Set(headFiles); + const baseSet = new Set(baseFiles); + + const entries: FileEntry[] = []; + + for (const relativePath of headFiles) { + if (!baseSet.has(relativePath)) { + entries.push({ + status: "added", + path: relativePath, + diff: await unifiedDiff( + "/dev/null", + path.join(headDir, relativePath), + "/dev/null", + `head/${relativePath}`, + ), + }); + continue; + } + + const [headContent, baseContent] = await Promise.all([ + readFile(path.join(headDir, relativePath), "utf8"), + readFile(path.join(baseDir, relativePath), "utf8"), + ]); + if (headContent !== baseContent) { + entries.push({ + status: "changed", + path: relativePath, + diff: await unifiedDiff( + path.join(baseDir, relativePath), + path.join(headDir, relativePath), + `base/${relativePath}`, + `head/${relativePath}`, + ), + }); + } + } + + for (const relativePath of baseFiles) { + if (!headSet.has(relativePath)) { + entries.push({ + status: "removed", + path: relativePath, + diff: await unifiedDiff( + path.join(baseDir, relativePath), + "/dev/null", + `base/${relativePath}`, + "/dev/null", + ), + }); + } + } + + entries.sort((a, b) => a.path.localeCompare(b.path)); + return { identical: entries.length === 0, entries }; +} + +export function countByStatus(entries: readonly FileEntry[], status: FileEntry["status"]): number { + return entries.filter((entry) => entry.status === status).length; +} + +function escapeHtml(text: string): string { + return text.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">"); +} + +/** + * A fence long enough that no backtick run inside `content` can close it — + * the diffed `.d.ts` text is untrusted (published-tarball side) and a JSDoc + * `@example` with its own fenced block would otherwise break out and render + * as live markdown in the approver's step summary. + */ +function fenceFor(content: string): string { + const longestRun = Math.max(0, ...[...content.matchAll(/`+/g)].map((match) => match[0].length)); + return "`".repeat(Math.max(3, longestRun + 1)); +} + +/** GITHUB_STEP_SUMMARY is capped at ~1 MB; past this, a diff stops being reviewable inline anyway. */ +const MAX_RENDERED_DIFF_LENGTH = 20_000; + +/** The `
` markdown block for each changed file, shared verbatim by both tools' summaries. */ +export function renderDiffDetailsBlocks(entries: readonly FileEntry[]): string[] { + const lines: string[] = []; + for (const entry of entries) { + let diff = entry.diff.trimEnd(); + if (diff.length > MAX_RENDERED_DIFF_LENGTH) { + diff = + `${diff.slice(0, MAX_RENDERED_DIFF_LENGTH)}\n` + + `… diff truncated (${diff.length} chars total) — see the job log for the full diff`; + } + const fence = fenceFor(diff); + lines.push( + `
${entry.status}: ${escapeHtml(entry.path)}`, + "", + `${fence}diff`, + diff, + fence, + "", + "
", + "", + ); + } + return lines; +} + +export async function writeStepSummary(markdown: string): Promise { + const summaryPath = process.env.GITHUB_STEP_SUMMARY; + if (!summaryPath) { + return; + } + await appendFile(summaryPath, `${markdown}\n`); +}