From 573b3648627274d76f296d0e75a96329e242c83a Mon Sep 17 00:00:00 2001 From: 7ttp <117663341+7ttp@users.noreply.github.com> Date: Wed, 19 Aug 2026 01:44:47 +0530 Subject: [PATCH 1/4] ci(cli): restore cli reference publishing --- .github/workflows/release.yml | 55 +++++++ apps/cli/docs/README.md | 20 ++- apps/cli/package.json | 2 + .../publish-docs-spec.integration.test.ts | 80 ++++++++++ apps/cli/scripts/publish-docs-spec.ts | 149 ++++++++++++++++++ pnpm-lock.yaml | 10 ++ 6 files changed, 310 insertions(+), 6 deletions(-) create mode 100644 apps/cli/scripts/publish-docs-spec.integration.test.ts create mode 100644 apps/cli/scripts/publish-docs-spec.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4140f2880c..9b90167e91 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -228,6 +228,61 @@ jobs: LINEAR_CLI_STABLE_RELEASE_ACCESS_KEY: ${{ secrets.LINEAR_CLI_STABLE_RELEASE_ACCESS_KEY }} LINEAR_CLI_BETA_RELEASE_ACCESS_KEY: ${{ secrets.LINEAR_CLI_BETA_RELEASE_ACCESS_KEY }} + # Republishes the supabase.com CLI reference, restoring the job the Go + # `tools/bumpdoc` ran before the monorepo merge (71b543255) deleted it — the + # published spec has been frozen at 2.98.2 since. Stable only: the reference + # documents the CLI users actually install, so pre-releases are skipped. + # Nothing depends on this job, so a docs-site failure cannot affect the + # already-completed release. + docs: + name: Publish reference docs + needs: [plan, release] + if: needs.plan.outputs.channel == 'stable' + runs-on: ubuntu-latest + timeout-minutes: 15 + continue-on-error: true + steps: + # Scoped to supabase/supabase: this job pushes a branch and opens a PR + # there, and needs nothing beyond a plain read-only checkout here. + - 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 }} + owner: ${{ github.repository_owner }} + repositories: | + supabase + permission-contents: write + permission-pull-requests: write + - uses: useblacksmith/checkout@6fd481652155169ed4d2f25ebaf97464f685175f # v1 + with: + persist-credentials: false + - name: Setup + uses: ./.github/actions/setup + with: + dependency-firewall-token: ${{ secrets.DF_FIREWALL_TOKEN }} + - name: Authenticate git for the docs push + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + run: gh auth setup-git + - name: Publish the CLI reference + working-directory: apps/cli + # `shell: bash` for `-o pipefail`: the default `run:` shell would take + # the exit status of the publisher alone, so a generator that died + # part-way would look like a successful run of a truncated spec. + shell: bash + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + VERSION: ${{ needs.plan.outputs.version }} + DRY_RUN: ${{ needs.plan.outputs.dry_run }} + run: | + args=() + if [[ "$DRY_RUN" == "true" ]]; then + args+=(--dry-run) + fi + bun scripts/generate-docs-spec.ts "$VERSION" \ + | bun scripts/publish-docs-spec.ts --version "$VERSION" "${args[@]}" + # Posts to the release Slack channel once the pipeline succeeds. Listing # `release` in `needs` without a status function in `if:` keeps the implicit # success() gate, so this only runs when both plan and release succeeded. diff --git a/apps/cli/docs/README.md b/apps/cli/docs/README.md index fdc021a9f2..705865445f 100644 --- a/apps/cli/docs/README.md +++ b/apps/cli/docs/README.md @@ -13,16 +13,24 @@ bun scripts/generate-docs-spec.ts > cli_v1_commands.yaml ## Release -1. Clone the [supabase/supabase](https://github.com/supabase/supabase) repo -2. Copy over the CLI reference and reformat +The `docs` job in `.github/workflows/release.yml` publishes the reference on every stable +release: it pipes the generator into `scripts/publish-docs-spec.ts`, which formats the spec, +pushes the `cli/ref-doc` branch in [supabase/supabase](https://github.com/supabase/supabase), +and opens a PR when none is open. When the spec already matches, the run is a no-op. +Later releases add commits on top of an open `cli/ref-doc` PR instead of rewriting it, so +fixes committed onto the branch survive. + +New commands also need an entry in +[common-cli-sections.json](https://github.com/supabase/supabase/blob/master/apps/docs/spec/common-cli-sections.json) +— the sidebar decides which pages exist, and a command without an entry is silently +dropped from the docs site. + +To publish by hand, run the same pipe the job runs: ```bash -mv ../cli/apps/cli/cli_v1_commands.yaml apps/docs/spec/ -npx prettier -w apps/docs/spec/cli_v1_commands.yaml +bun scripts/generate-docs-spec.ts | bun scripts/publish-docs-spec.ts --version [--dry-run] ``` -3. If there are new commands added, update [common-cli-sections.json](https://github.com/supabase/supabase/blob/master/apps/docs/spec/common-cli-sections.json) manually - ## Maintenance When adding or changing a command or flag, update the matching entries in diff --git a/apps/cli/package.json b/apps/cli/package.json index af7a2f96f6..eb2cf38544 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -79,6 +79,7 @@ "pg": "^8.23.0", "pg-copy-streams": "^7.0.0", "posthog-node": "^5.48.1", + "prettier": "3.8.1", "react": "^19.2.8", "react-devtools-core": "^7.0.1", "semantic-release": "^25.0.9", @@ -141,6 +142,7 @@ "oxfmt", "oxlint", "oxlint-tsgolint", + "prettier", "semantic-release", "@anthropic-ai/claude-agent-sdk", "@anthropic-ai/sdk", diff --git a/apps/cli/scripts/publish-docs-spec.integration.test.ts b/apps/cli/scripts/publish-docs-spec.integration.test.ts new file mode 100644 index 0000000000..819c6b09d4 --- /dev/null +++ b/apps/cli/scripts/publish-docs-spec.integration.test.ts @@ -0,0 +1,80 @@ +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { stringify } from "yaml"; + +const cliRoot = path.resolve(import.meta.dirname, ".."); + +function runScript( + args: ReadonlyArray, + stdin: string, +): { exitCode: number; stdout: string; stderr: string } { + const { FORCE_COLOR: _forceColor, NO_COLOR: _noColor, ...environment } = process.env; + const result = Bun.spawnSync(["bun", "scripts/publish-docs-spec.ts", ...args], { + cwd: cliRoot, + env: environment, + stdin: Buffer.from(stdin), + }); + return { + exitCode: result.exitCode, + stdout: result.stdout.toString(), + stderr: result.stderr.toString(), + }; +} + +function validSpec(version: string): string { + return stringify({ + clispec: "001", + info: { id: "cli", version, title: "Supabase CLI" }, + commands: Array.from({ length: 144 }, (_, index) => ({ id: `supabase-command-${index}` })), + }); +} + +describe("publish-docs-spec.ts entrypoint", () => { + it("prints usage and fails without --version", () => { + const { exitCode, stderr } = runScript(["--dry-run"], validSpec("1.0.0")); + expect(exitCode).toBe(1); + expect(stderr).toContain("Usage:"); + }, 30_000); + + it("refuses an empty spec", () => { + const { exitCode, stderr } = runScript(["--version", "1.0.0", "--dry-run"], ""); + expect(exitCode).toBe(1); + expect(stderr).toContain("Refusing to publish an empty spec"); + }, 30_000); + + it("refuses a spec that is not valid clispec YAML", () => { + const { exitCode, stderr } = runScript( + ["--version", "1.0.0", "--dry-run"], + "clispec: [unclosed", + ); + expect(exitCode).toBe(1); + expect(stderr).toContain("Refusing to publish"); + }, 30_000); + + it("refuses a truncated spec with too few commands", () => { + const truncated = stringify({ + clispec: "001", + info: { id: "cli", version: "1.0.0", title: "Supabase CLI" }, + commands: [{ id: "supabase-init" }], + }); + const { exitCode, stderr } = runScript(["--version", "1.0.0", "--dry-run"], truncated); + expect(exitCode).toBe(1); + expect(stderr).toContain("expected at least 144"); + }, 30_000); + + it("refuses a spec whose version does not match --version", () => { + const { exitCode, stderr } = runScript(["--version", "1.0.0", "--dry-run"], validSpec("2.0.0")); + expect(exitCode).toBe(1); + expect(stderr).toContain("does not match --version 1.0.0"); + }, 30_000); + + it("summarizes a valid spec in dry-run mode without publishing", () => { + const spec = validSpec("1.0.0"); + const { exitCode, stdout, stderr } = runScript(["--version", "1.0.0", "--dry-run"], spec); + expect(exitCode).toBe(0); + expect(stderr).toBe(""); + expect(stdout.trim()).toBe( + `Would publish ${spec.length} bytes to supabase/supabase:apps/docs/spec/cli_v1_commands.yaml on branch cli/ref-doc`, + ); + }, 30_000); +}); diff --git a/apps/cli/scripts/publish-docs-spec.ts b/apps/cli/scripts/publish-docs-spec.ts new file mode 100644 index 0000000000..aff011cfa5 --- /dev/null +++ b/apps/cli/scripts/publish-docs-spec.ts @@ -0,0 +1,149 @@ +/** + * Publishes the generated CLI reference to the docs site by opening a PR + * against supabase/supabase, replacing the Go `tools/bumpdoc` that was deleted + * in the monorepo merge (71b543255). The reference has not been republished + * since, so the published spec is frozen at 2.98.2. + * + * Reads the spec on stdin so it composes with the generator exactly the way the + * Go release job did: + * + * bun scripts/generate-docs-spec.ts | bun scripts/publish-docs-spec.ts --version + * + * Like `bumpdoc`, this is a no-op when the published file already matches: it + * prints "already up to date" and exits 0 without touching the branch or + * opening a PR. Any real failure exits non-zero. + */ +import { $ } from "bun"; +import { mkdtemp, rm, writeFile } 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 { parse } from "yaml"; + +const { values } = parseArgs({ + options: { + version: { type: "string" }, + repo: { type: "string", default: "supabase/supabase" }, + // Path of the spec inside the docs repo, as the Go job passed it. + "spec-path": { type: "string", default: "apps/docs/spec/cli_v1_commands.yaml" }, + branch: { type: "string", default: "cli/ref-doc" }, + base: { type: "string", default: "master" }, + "dry-run": { type: "boolean", default: false }, + }, +}); + +const versionArgument = values.version; +if (!versionArgument) { + console.error( + "Usage: bun scripts/generate-docs-spec.ts | bun scripts/publish-docs-spec.ts --version [--repo ] [--spec-path ] [--branch ] [--base ] [--dry-run]", + ); + process.exit(1); +} +const version = versionArgument.startsWith("v") ? versionArgument.slice(1) : versionArgument; + +const repo = values.repo!; +const specPath = values["spec-path"]!; +const branch = values.branch!; +const base = values.base!; +const dryRun = values["dry-run"]!; + +const spec = await Bun.stdin.text(); +if (spec.trim().length === 0) { + console.error("Refusing to publish an empty spec: nothing was piped in on stdin."); + process.exit(1); +} + +let parsed: { clispec?: unknown; info?: { version?: unknown }; commands?: unknown }; +try { + parsed = parse(spec); +} catch (error) { + console.error(`Refusing to publish: stdin is not valid YAML (${error}).`); + process.exit(1); +} +if (parsed?.clispec !== "001") { + console.error( + `Refusing to publish: expected clispec "001", got ${JSON.stringify(parsed?.clispec)}.`, + ); + process.exit(1); +} +if (parsed.info?.version !== version) { + console.error( + `Refusing to publish: the spec's version ${JSON.stringify(parsed.info?.version)} does not match --version ${version}.`, + ); + process.exit(1); +} +const commands = parsed.commands; +if (!Array.isArray(commands) || commands.length < 144) { + console.error( + `Refusing to publish: the spec has ${Array.isArray(commands) ? commands.length : 0} commands, expected at least 144.`, + ); + process.exit(1); +} + +if (dryRun) { + console.log(`Would publish ${spec.length} bytes to ${repo}:${specPath} on branch ${branch}`); + process.exit(0); +} + +// `--depth 1` is enough: when `cli/ref-doc` already exists its tip is fetched +// and the new spec lands as one commit on top, so commits pushed onto an open +// PR (sidebar fixes) survive later releases; otherwise the branch starts from +// base. +const tmpDir = await mkdtemp(path.join(tmpdir(), "supabase-docs-")); +try { + await $`git clone --quiet --depth 1 --branch ${base} https://github.com/${repo}.git ${tmpDir}`; + + const remoteBranch = await $`git -C ${tmpDir} fetch --quiet --depth 1 origin ${branch}` + .nothrow() + .quiet(); + if (remoteBranch.exitCode === 0) { + await $`git -C ${tmpDir} checkout --quiet -B ${branch} FETCH_HEAD`; + } else { + await $`git -C ${tmpDir} checkout --quiet -B ${branch}`; + } + + const target = path.join(tmpDir, specPath); + await writeFile(target, spec); + const prettier = path.resolve(import.meta.dir, "../node_modules/.bin/prettier"); + await $`${prettier} --no-config --single-quote --print-width 100 --log-level warn --write ${target}`; + + await $`git -C ${tmpDir} add ${specPath}`; + const unchanged = await $`git -C ${tmpDir} diff --quiet --cached -- ${specPath}` + .nothrow() + .quiet(); + if (unchanged.exitCode === 0) { + console.log(`${specPath} is already up to date in ${repo}`); + } else { + const message = "chore: update cli reference doc"; + await $`git -C ${tmpDir} -c ${"user.name=github-actions[bot]"} -c ${"user.email=41898282+github-actions[bot]@users.noreply.github.com"} commit --quiet -m ${message}`; + await $`git -C ${tmpDir} push --quiet origin ${branch}`; + console.log(`Pushed ${branch} to ${repo}`); + + const existing = await $`gh pr list --repo ${repo} --head ${branch} --state open --json number` + .cwd(tmpDir) + .text(); + if (JSON.parse(existing).length > 0) { + console.log(`Reusing the open PR for ${branch}`); + } else { + const body = [ + "Updates the CLI reference from the supabase/cli release workflow.", + "", + "Generated by `scripts/generate-docs-spec.ts` in supabase/cli — edit the", + "command tree or the content under `apps/cli/docs/` there rather than this", + "file.", + "", + "New commands also need an entry in `apps/docs/spec/common-cli-sections.json` —", + "without one a command's page is silently dropped from the docs site.", + "Sections fixes can be committed directly onto this branch — later releases", + "add commits on top instead of rewriting it.", + ].join("\n"); + await $`gh pr create --repo ${repo} --title ${message} --body ${body} --base ${base} --head ${branch}`.cwd( + tmpDir, + ); + console.log(`Opened a pull request against ${repo}`); + } + } +} finally { + await rm(tmpDir, { recursive: true, force: true }); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0e5aeee6c2..7b6fe6c077 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -226,6 +226,9 @@ importers: posthog-node: specifier: ^5.48.1 version: 5.48.1 + prettier: + specifier: 3.8.1 + version: 3.8.1 react: specifier: ^19.2.8 version: 19.2.8 @@ -6120,6 +6123,11 @@ packages: rxjs: optional: true + prettier@3.8.1: + resolution: {integrity: sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==} + engines: {node: '>=14'} + hasBin: true + pretty-ms@9.3.0: resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==} engines: {node: '>=18'} @@ -12846,6 +12854,8 @@ snapshots: dependencies: '@posthog/core': 1.47.0 + prettier@3.8.1: {} + pretty-ms@9.3.0: dependencies: parse-ms: 4.0.0 From e52647db7832056861840727050336c85e270857 Mon Sep 17 00:00:00 2001 From: 7ttp <117663341+7ttp@users.noreply.github.com> Date: Wed, 19 Aug 2026 02:56:20 +0530 Subject: [PATCH 2/4] test: classify publisher suite as e2e --- ...ocs-spec.integration.test.ts => publish-docs-spec.e2e.test.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename apps/cli/scripts/{publish-docs-spec.integration.test.ts => publish-docs-spec.e2e.test.ts} (100%) diff --git a/apps/cli/scripts/publish-docs-spec.integration.test.ts b/apps/cli/scripts/publish-docs-spec.e2e.test.ts similarity index 100% rename from apps/cli/scripts/publish-docs-spec.integration.test.ts rename to apps/cli/scripts/publish-docs-spec.e2e.test.ts From f047e2106e7a4dfe384de75c062fb5855117c160 Mon Sep 17 00:00:00 2001 From: 7ttp <117663341+7ttp@users.noreply.github.com> Date: Wed, 19 Aug 2026 03:10:55 +0530 Subject: [PATCH 3/4] fix: open the docs pr when the branch is already ahead --- apps/cli/docs/README.md | 3 ++- apps/cli/scripts/publish-docs-spec.ts | 21 ++++++++++++++++----- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/apps/cli/docs/README.md b/apps/cli/docs/README.md index 705865445f..c8b7f33ae7 100644 --- a/apps/cli/docs/README.md +++ b/apps/cli/docs/README.md @@ -16,7 +16,8 @@ bun scripts/generate-docs-spec.ts > cli_v1_commands.yaml The `docs` job in `.github/workflows/release.yml` publishes the reference on every stable release: it pipes the generator into `scripts/publish-docs-spec.ts`, which formats the spec, pushes the `cli/ref-doc` branch in [supabase/supabase](https://github.com/supabase/supabase), -and opens a PR when none is open. When the spec already matches, the run is a no-op. +and opens a PR when none is open. When the spec is already published and a PR is open +or not needed, the run is a no-op. Later releases add commits on top of an open `cli/ref-doc` PR instead of rewriting it, so fixes committed onto the branch survive. diff --git a/apps/cli/scripts/publish-docs-spec.ts b/apps/cli/scripts/publish-docs-spec.ts index aff011cfa5..bf1fd07723 100644 --- a/apps/cli/scripts/publish-docs-spec.ts +++ b/apps/cli/scripts/publish-docs-spec.ts @@ -9,9 +9,11 @@ * * bun scripts/generate-docs-spec.ts | bun scripts/publish-docs-spec.ts --version * - * Like `bumpdoc`, this is a no-op when the published file already matches: it - * prints "already up to date" and exits 0 without touching the branch or - * opening a PR. Any real failure exits non-zero. + * Like `bumpdoc`, this is a no-op when the spec is already published and no PR + * is missing: it prints "already up to date" and exits 0. A branch whose spec + * is ahead of base still gets its PR ensured, so a run that pushed but failed + * to open the PR is repaired by the next release. Any real failure exits + * non-zero. */ import { $ } from "bun"; import { mkdtemp, rm, writeFile } from "node:fs/promises"; @@ -108,18 +110,27 @@ try { const prettier = path.resolve(import.meta.dir, "../node_modules/.bin/prettier"); await $`${prettier} --no-config --single-quote --print-width 100 --log-level warn --write ${target}`; + const message = "chore: update cli reference doc"; await $`git -C ${tmpDir} add ${specPath}`; const unchanged = await $`git -C ${tmpDir} diff --quiet --cached -- ${specPath}` .nothrow() .quiet(); if (unchanged.exitCode === 0) { - console.log(`${specPath} is already up to date in ${repo}`); + console.log( + remoteBranch.exitCode === 0 + ? `${specPath} is already up to date on ${branch}` + : `${specPath} is already up to date in ${repo}`, + ); } else { - const message = "chore: update cli reference doc"; await $`git -C ${tmpDir} -c ${"user.name=github-actions[bot]"} -c ${"user.email=41898282+github-actions[bot]@users.noreply.github.com"} commit --quiet -m ${message}`; await $`git -C ${tmpDir} push --quiet origin ${branch}`; console.log(`Pushed ${branch} to ${repo}`); + } + const unpublished = await $`git -C ${tmpDir} diff --quiet origin/${base} HEAD -- ${specPath}` + .nothrow() + .quiet(); + if (unpublished.exitCode === 1) { const existing = await $`gh pr list --repo ${repo} --head ${branch} --state open --json number` .cwd(tmpDir) .text(); From da7b4c453fa384fc410925a31d5c82e1f34d4d14 Mon Sep 17 00:00:00 2001 From: 7ttp <117663341+7ttp@users.noreply.github.com> Date: Wed, 19 Aug 2026 03:11:35 +0530 Subject: [PATCH 4/4] fix: scope docs pr reuse by owner and base --- apps/cli/scripts/publish-docs-spec.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/apps/cli/scripts/publish-docs-spec.ts b/apps/cli/scripts/publish-docs-spec.ts index bf1fd07723..b4b969500f 100644 --- a/apps/cli/scripts/publish-docs-spec.ts +++ b/apps/cli/scripts/publish-docs-spec.ts @@ -131,10 +131,16 @@ try { .nothrow() .quiet(); if (unpublished.exitCode === 1) { - const existing = await $`gh pr list --repo ${repo} --head ${branch} --state open --json number` - .cwd(tmpDir) - .text(); - if (JSON.parse(existing).length > 0) { + const existing = + await $`gh pr list --repo ${repo} --head ${branch} --base ${base} --state open --json number,headRepositoryOwner` + .cwd(tmpDir) + .text(); + const openPulls: Array<{ headRepositoryOwner?: { login?: string } }> = JSON.parse(existing); + const ownPulls = openPulls.filter( + (pull) => + pull.headRepositoryOwner?.login?.toLowerCase() === repo.split("/")[0]?.toLowerCase(), + ); + if (ownPulls.length > 0) { console.log(`Reusing the open PR for ${branch}`); } else { const body = [