diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c49328b629..1a1ce8040a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -267,16 +267,19 @@ jobs: LINEAR_CLI_BETA_RELEASE_ACCESS_KEY: ${{ secrets.LINEAR_CLI_BETA_RELEASE_ACCESS_KEY }} SUPABASE_E2E_CLI_LIVE_STAGING_ACCESS_TOKEN: ${{ secrets.SUPABASE_E2E_CLI_LIVE_STAGING_ACCESS_TOKEN }} - # Republishes the supabase.com CLI reference. Stable only: the reference documents the CLI - # users actually install, so pre-releases and maintenance lines are skipped. Nothing depends on this job, so a - # docs-site failure cannot affect the already-completed release. + # Republishes the CLI reference and the config schemas to supabase.com. Stable only: both + # document the CLI users actually install, so pre-releases and maintenance lines are skipped. + # Nothing depends on this job, so a docs-site failure cannot affect the already-completed + # release; a failed publish reaches Slack through notify-slack-publish-failure. docs: - name: Publish reference docs + name: Publish to supabase.com needs: [plan, release] if: ${{ !cancelled() && needs.release.result == 'success' && needs.plan.outputs.channel == 'stable' }} runs-on: ubuntu-latest - timeout-minutes: 15 + timeout-minutes: 25 continue-on-error: true + outputs: + failed: ${{ steps.report.outputs.failed }} 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. @@ -302,6 +305,8 @@ jobs: GH_TOKEN: ${{ steps.app-token.outputs.token }} run: gh auth setup-git - name: Publish the CLI reference + id: reference + continue-on-error: true working-directory: apps/cli # `shell: bash` for `-o pipefail`: the default `run:` shell takes only the publisher's # exit status, so a generator that died part-way would look like a successful run. @@ -317,6 +322,41 @@ jobs: fi bun scripts/generate-docs-spec.ts "$VERSION" \ | bun scripts/publish-docs-spec.ts --version "$VERSION" "${args[@]}" + - name: Publish the config schemas + id: schemas + continue-on-error: true + working-directory: apps/cli + 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/publish-schemas.ts --version "$VERSION" "${args[@]}" + - name: Report failed publishes + id: report + if: ${{ !cancelled() }} + env: + REFERENCE_OUTCOME: ${{ steps.reference.outcome }} + SCHEMAS_OUTCOME: ${{ steps.schemas.outcome }} + run: | + failed=false + report() { + if [[ "$2" != "success" ]]; then + failed=true + echo "::error title=Publishing the $1 failed::The $1 was not published to supabase.com (step outcome: $2)." + echo "- The $1 was not published to supabase.com (step outcome: $2)." >> "$GITHUB_STEP_SUMMARY" + fi + } + report "CLI reference" "$REFERENCE_OUTCOME" + report "config schemas" "$SCHEMAS_OUTCOME" + echo "failed=$failed" >> "$GITHUB_OUTPUT" + if [[ "$failed" == "true" ]]; then + exit 1 + fi # Notifies real (non-dry-run) stable and maintenance cuts. Nothing depends on this job, so a # Slack failure can't affect the already-completed release. @@ -337,6 +377,22 @@ jobs: secrets: SLACK_RELEASE_WEBHOOK: ${{ secrets.SLACK_RELEASE_WEBHOOK }} + # Pages when the release shipped but the supabase.com publish did not. The docs job's + # continue-on-error hides its own failures, so its report step exports `failed`; a job that ran + # without exporting `failed=false` (timeout, crash before the report step) counts as failed. + notify-slack-publish-failure: + name: Notify Slack (publish failure) + needs: [plan, docs] + if: ${{ !cancelled() && needs.plan.outputs.dry_run != 'true' && needs.docs.result != 'skipped' && needs.docs.outputs.failed != 'false' }} + uses: ./.github/workflows/slack-notify.yml + with: + package: Supabase CLI + status: publish-failure + version: ${{ needs.plan.outputs.version }} + channel: ${{ needs.plan.outputs.channel }} + secrets: + SLACK_RELEASE_WEBHOOK: ${{ secrets.SLACK_RELEASE_WEBHOOK }} + # Reports a broken release on every channel. `failure()` evaluates against the full `needs` # chain, so this fires whenever `test-next`, `plan` or `release` (including the reusable # release-shared workflow) fails. Skipped jobs don't count as failures. Dry runs are excluded diff --git a/.github/workflows/slack-notify.yml b/.github/workflows/slack-notify.yml index 81eb16cc2f..6bfbf2c800 100644 --- a/.github/workflows/slack-notify.yml +++ b/.github/workflows/slack-notify.yml @@ -18,7 +18,7 @@ on: type: string default: "" status: - description: Notification kind (success | failure | awaiting-approval | declined | sync-conflict) + description: Notification kind (success | failure | publish-failure | awaiting-approval | declined | sync-conflict) required: false type: string default: success @@ -105,6 +105,11 @@ jobs: DETAILS="*Version:* v${VERSION}\n${DETAILS}" fi ;; + publish-failure) + HEADER="⚠️ ${PACKAGE} v${VERSION} released, but publishing to supabase.com failed" + FALLBACK_TEXT="⚠️ ${PACKAGE} v${VERSION} was released, but publishing to supabase.com failed" + DETAILS="The CLI reference or config schemas pull request in supabase/supabase was not opened or updated. Rerun the *Publish to supabase.com* job.\n*Version:* v${VERSION}\n*Commit:* <${COMMIT_URL}|${SHORT_SHA}>\n*Workflow run:* <${RUN_URL}|view failed run>" + ;; awaiting-approval) # The approval itself happens on the workflow run page (Approve button + # the plan job's evidence summary) — an incoming webhook is one-way and diff --git a/apps/cli/docs/README.md b/apps/cli/docs/README.md index 88cfd33d78..efc58d31b2 100644 --- a/apps/cli/docs/README.md +++ b/apps/cli/docs/README.md @@ -32,6 +32,20 @@ To publish by hand, run the same pipe the job runs: bun scripts/generate-docs-spec.ts | bun scripts/publish-docs-spec.ts --version [--dry-run] ``` +The same job then publishes the config, project-config and `supabase.lock` JSON Schemas from +`apps/docs/public/schemas` with `scripts/publish-schemas.ts`. It copies each schema's current +(highest) `v.json` into `apps/www/public/schemas` on the `cli/schemas` branch in supabase/supabase, +serving it at `https://supabase.com/schemas//v.json`, opens a PR when the files differ, and +arms auto-merge, which squash-merges once a reviewer approves. Lower versions are frozen: the script +never writes them and refuses to run when supabase.com already serves a higher version than the +current one. Both publishers share the clone, branch, commit and PR flow in +`scripts/publish-pull-request.ts`. A failed publish of either pages Slack; rerun the "Publish to +supabase.com" job. + +```bash +bun scripts/publish-schemas.ts --version [--dry-run] +``` + ## Maintenance When adding or changing a command or flag, update the matching entries in diff --git a/apps/cli/scripts/publish-docs-spec.ts b/apps/cli/scripts/publish-docs-spec.ts index a02343d4fd..b937b64c9e 100644 --- a/apps/cli/scripts/publish-docs-spec.ts +++ b/apps/cli/scripts/publish-docs-spec.ts @@ -13,36 +13,16 @@ import { Console, Data, Effect, FileSystem, Layer, Path } from "effect"; import process from "node:process"; import { parseArgs } from "node:util"; import { parse } from "yaml"; - -class ShellCommandFailed extends Data.TaggedError("ShellCommandFailed")<{ - readonly cause: unknown; -}> {} +import { json, publishPullRequest, shell } from "./publish-pull-request.ts"; class StdinReadFailed extends Data.TaggedError("StdinReadFailed")<{ readonly cause: unknown }> {} class SpecYamlInvalid extends Data.TaggedError("SpecYamlInvalid")<{ readonly cause: unknown }> {} -class JsonFailed extends Data.TaggedError("JsonFailed")<{ readonly cause: unknown }> {} - -const shell = (command: () => Promise) => - Effect.tryPromise({ try: command, catch: (cause) => new ShellCommandFailed({ cause }) }); - -/* Plain `JSON` keeps the engine's own error for a cyclic spec value or malformed `gh` output, - which a `Schema` codec would replace with its issue text. */ -const json = (run: () => A) => - Effect.try({ try: run, catch: (cause) => new JsonFailed({ cause }) }); - function jsonText(value: unknown): string { return `${JSON.stringify(value)}`; } -function ownPullsOf(existing: string, repo: string) { - const openPulls: Array<{ headRepositoryOwner?: { login?: string } }> = JSON.parse(existing); - return openPulls.filter( - (pull) => pull.headRepositoryOwner?.login?.toLowerCase() === repo.split("/")[0]?.toLowerCase(), - ); -} - const { values } = parseArgs({ options: { version: { type: "string" }, @@ -120,95 +100,38 @@ const main = Effect.gen(function* () { return yield* Effect.sync(() => process.exit(0)); } - // `--depth 1` suffices: an existing `cli/ref-doc` branch is fetched at its tip and the new spec - // lands as one commit on top, so commits pushed onto an open PR survive later releases. - const publishSpec = (tmpDir: string) => - Effect.gen(function* () { - yield* shell( - () => - $`git clone --quiet --depth 1 --branch ${base} https://github.com/${repo}.git ${tmpDir}`, - ); - - const remoteBranch = yield* shell(() => - $`git -C ${tmpDir} fetch --quiet --depth 1 origin ${branch}`.nothrow().quiet(), - ); - if (remoteBranch.exitCode === 0) { - yield* shell(() => $`git -C ${tmpDir} checkout --quiet -B ${branch} FETCH_HEAD`); - } else { - yield* shell(() => $`git -C ${tmpDir} checkout --quiet -B ${branch}`); - } - - const target = path.join(tmpDir, specPath); - yield* fs.writeFileString(target, spec); - const prettier = path.resolve(import.meta.dir, "../node_modules/.bin/prettier"); - yield* shell( - () => - $`${prettier} --no-config --single-quote --print-width 100 --log-level warn --write ${target}`, - ); - - const message = "chore: update cli reference doc"; - yield* shell(() => $`git -C ${tmpDir} add ${specPath}`); - const unchanged = yield* shell(() => - $`git -C ${tmpDir} diff --quiet --cached -- ${specPath}`.nothrow().quiet(), - ); - if (unchanged.exitCode === 0) { - yield* Console.log( - remoteBranch.exitCode === 0 - ? `${specPath} is already up to date on ${branch}` - : `${specPath} is already up to date in ${repo}`, - ); - } else { + const prettier = path.resolve(import.meta.dir, "../node_modules/.bin/prettier"); + const message = "chore: update cli reference doc"; + yield* publishPullRequest({ + repo, + base, + branch, + stagedPath: specPath, + write: (checkoutDir) => + Effect.gen(function* () { + const target = path.join(checkoutDir, specPath); + yield* fs.writeFileString(target, spec); yield* shell( () => - $`git -C ${tmpDir} -c ${"user.name=github-actions[bot]"} -c ${"user.email=41898282+github-actions[bot]@users.noreply.github.com"} commit --quiet -m ${message}`, + $`${prettier} --no-config --single-quote --print-width 100 --log-level warn --write ${target}`, ); - yield* shell(() => $`git -C ${tmpDir} push --quiet origin ${branch}`); - yield* Console.log(`Pushed ${branch} to ${repo}`); - } - - const unpublished = yield* shell(() => - $`git -C ${tmpDir} diff --quiet origin/${base} HEAD -- ${specPath}`.nothrow().quiet(), - ); - if (unpublished.exitCode === 1) { - const existing = yield* shell(() => - $`gh pr list --repo ${repo} --head ${branch} --base ${base} --state open --json number,headRepositoryOwner` - .cwd(tmpDir) - .text(), - ); - const ownPulls = yield* json(() => ownPullsOf(existing, repo)); - if (ownPulls.length > 0) { - yield* 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"); - yield* shell(() => - $`gh pr create --repo ${repo} --title ${message} --body ${body} --base ${base} --head ${branch}`.cwd( - tmpDir, - ), - ); - yield* Console.log(`Opened a pull request against ${repo}`); - } - } - }); - /* A failed removal replaces the run's outcome. */ - yield* Effect.uninterruptibleMask((restore) => - Effect.gen(function* () { - const tmpDir = yield* fs.makeTempDirectory({ prefix: "supabase-docs-" }); - const outcome = yield* Effect.exit(restore(publishSpec(tmpDir))); - yield* fs.remove(tmpDir, { recursive: true, force: true }); - return yield* outcome; - }), - ); + }), + commitMessage: message, + pullRequestTitle: message, + pullRequestBody: [ + "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"), + autoMerge: false, + }); }); await Effect.runPromise( diff --git a/apps/cli/scripts/publish-pull-request.ts b/apps/cli/scripts/publish-pull-request.ts new file mode 100644 index 0000000000..12ebd9aa20 --- /dev/null +++ b/apps/cli/scripts/publish-pull-request.ts @@ -0,0 +1,133 @@ +/** + * Publishes files to another repository through a pull request: clones it, writes on a reused or + * new branch, pushes when the staged diff is non-empty, and ensures a PR exists while the branch + * differs from base. + */ +import { $ } from "bun"; +import { Console, Data, Effect, FileSystem } from "effect"; + +class ShellCommandFailed extends Data.TaggedError("ShellCommandFailed")<{ + readonly cause: unknown; +}> {} + +class JsonFailed extends Data.TaggedError("JsonFailed")<{ readonly cause: unknown }> {} + +export const shell = (command: () => Promise) => + Effect.tryPromise({ try: command, catch: (cause) => new ShellCommandFailed({ cause }) }); + +/* Plain `JSON` keeps the engine's own error for a cyclic value or malformed `gh` output, + which a `Schema` codec would replace with its issue text. */ +export const json = (run: () => A) => + Effect.try({ try: run, catch: (cause) => new JsonFailed({ cause }) }); + +function ownPullsOf(existing: string, repo: string) { + const openPulls: Array<{ headRepositoryOwner?: { login?: string } }> = JSON.parse(existing); + return openPulls.filter( + (pull) => pull.headRepositoryOwner?.login?.toLowerCase() === repo.split("/")[0]?.toLowerCase(), + ); +} + +export interface PublishPullRequestOptions { + readonly repo: string; + readonly base: string; + readonly branch: string; + /** Repo-relative file or directory that `write` changes; it is staged and diffed against base. */ + readonly stagedPath: string; + readonly write: (checkoutDir: string) => Effect.Effect; + readonly commitMessage: string; + readonly pullRequestTitle: string; + readonly pullRequestBody: string; + /** Arms `gh pr merge --auto --squash` on the open pull request, new or reused. */ + readonly autoMerge: boolean; +} + +export const publishPullRequest = ({ + repo, + base, + branch, + stagedPath, + write, + commitMessage, + pullRequestTitle, + pullRequestBody, + autoMerge, +}: PublishPullRequestOptions) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + + // `--depth 1` suffices: an existing branch is fetched at its tip and the new content lands as + // one commit on top, so commits pushed onto an open PR survive later releases. + const publish = (tmpDir: string) => + Effect.gen(function* () { + yield* shell( + () => + $`git clone --quiet --depth 1 --branch ${base} https://github.com/${repo}.git ${tmpDir}`, + ); + + const remoteBranch = yield* shell(() => + $`git -C ${tmpDir} fetch --quiet --depth 1 origin ${branch}`.nothrow().quiet(), + ); + if (remoteBranch.exitCode === 0) { + yield* shell(() => $`git -C ${tmpDir} checkout --quiet -B ${branch} FETCH_HEAD`); + } else { + yield* shell(() => $`git -C ${tmpDir} checkout --quiet -B ${branch}`); + } + + yield* write(tmpDir); + + yield* shell(() => $`git -C ${tmpDir} add ${stagedPath}`); + const unchanged = yield* shell(() => + $`git -C ${tmpDir} diff --quiet --cached -- ${stagedPath}`.nothrow().quiet(), + ); + if (unchanged.exitCode === 0) { + yield* Console.log( + remoteBranch.exitCode === 0 + ? `${stagedPath} is already up to date on ${branch}` + : `${stagedPath} is already up to date in ${repo}`, + ); + } else { + yield* shell( + () => + $`git -C ${tmpDir} -c ${"user.name=github-actions[bot]"} -c ${"user.email=41898282+github-actions[bot]@users.noreply.github.com"} commit --quiet -m ${commitMessage}`, + ); + yield* shell(() => $`git -C ${tmpDir} push --quiet origin ${branch}`); + yield* Console.log(`Pushed ${branch} to ${repo}`); + } + + const unpublished = yield* shell(() => + $`git -C ${tmpDir} diff --quiet origin/${base} HEAD -- ${stagedPath}`.nothrow().quiet(), + ); + if (unpublished.exitCode === 1) { + const existing = yield* shell(() => + $`gh pr list --repo ${repo} --head ${branch} --base ${base} --state open --json number,headRepositoryOwner` + .cwd(tmpDir) + .text(), + ); + const ownPulls = yield* json(() => ownPullsOf(existing, repo)); + if (ownPulls.length > 0) { + yield* Console.log(`Reusing the open PR for ${branch}`); + } else { + yield* shell(() => + $`gh pr create --repo ${repo} --title ${pullRequestTitle} --body ${pullRequestBody} --base ${base} --head ${branch}`.cwd( + tmpDir, + ), + ); + yield* Console.log(`Opened a pull request against ${repo}`); + } + if (autoMerge) { + yield* shell(() => $`gh pr merge ${branch} --repo ${repo} --auto --squash`.cwd(tmpDir)); + yield* Console.log(`Armed auto-merge for ${branch}`); + } + } + }); + + /* A failed removal replaces the run's outcome. */ + yield* Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + const tmpDir = yield* fs.makeTempDirectory({ prefix: "supabase-publish-" }); + const outcome = yield* Effect.exit(restore(publish(tmpDir))); + yield* fs.remove(tmpDir, { recursive: true, force: true }); + return yield* outcome; + }), + ); + }); diff --git a/apps/cli/scripts/publish-schemas.e2e.test.ts b/apps/cli/scripts/publish-schemas.e2e.test.ts new file mode 100644 index 0000000000..bdcc1287b1 --- /dev/null +++ b/apps/cli/scripts/publish-schemas.e2e.test.ts @@ -0,0 +1,144 @@ +import { BunFileSystem, BunPath } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, FileSystem, JsonSchema, Layer, Path } from "effect"; +import { fileURLToPath } from "node:url"; + +const cliRoot = fileURLToPath(new URL("..", import.meta.url)); +const platformLayer = Layer.mergeAll(BunFileSystem.layer, BunPath.layer); + +const runScript = (args: ReadonlyArray) => + Effect.gen(function* () { + const { FORCE_COLOR: _forceColor, NO_COLOR: _noColor, ...env } = process.env; + const child = Bun.spawn(["bun", "scripts/publish-schemas.ts", ...args], { + cwd: cliRoot, + env, + stdout: "pipe", + stderr: "pipe", + }); + const [exitCode, stdout, stderr] = yield* Effect.all( + [ + Effect.promise(() => child.exited), + Effect.promise(() => new Response(child.stdout).text()), + Effect.promise(() => new Response(child.stderr).text()), + ], + { concurrency: "unbounded" }, + ); + return { exitCode, stdout, stderr }; + }); + +const schemaText = (name: string, version: number, overrides: Record = {}) => + JSON.stringify({ + $schema: JsonSchema.META_SCHEMA_URI_DRAFT_07, + $id: `https://supabase.com/schemas/${name}/v${version}.json`, + type: "object", + ...overrides, + }); + +const schemasDirOf = (files: Record) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "publish-schemas-" }); + for (const [file, content] of Object.entries(files)) { + yield* fs.makeDirectory(path.dirname(path.join(directory, file)), { recursive: true }); + yield* fs.writeFileString(path.join(directory, file), content); + } + return directory; + }); + +const refusals: ReadonlyArray<{ + description: string; + files: Record; + message: string; +}> = [ + { + description: "an $id that does not match the schema's path", + files: { "config/v1.json": schemaText("config", 1, { $id: "https://example.com/c.json" }) }, + message: `Refusing to publish: config/v1.json must declare "$id": "https://supabase.com/schemas/config/v1.json".`, + }, + { + description: "a $schema that is not draft-07", + files: { + "config/v1.json": schemaText("config", 1, { + $schema: "https://json-schema.org/draft/2020-12/schema", + }), + }, + message: `Refusing to publish: config/v1.json must declare "$schema": "${JsonSchema.META_SCHEMA_URI_DRAFT_07}".`, + }, + { + description: "a file that is not valid JSON", + files: { "config/v1.json": "{ not json" }, + message: "Refusing to publish: config/v1.json is not a valid JSON object.", + }, + { + description: "a file in a schema directory that is not named v.json", + files: { "config/v1.json": schemaText("config", 1), "config/notes.md": "# notes" }, + message: "Refusing to publish: config/notes.md does not match v.json.", + }, + { + description: "a schemas directory without schemas", + files: {}, + message: "Refusing to publish: no schemas found in ", + }, +]; + +describe("publish-schemas.ts entrypoint", () => { + it.effect("prints usage and fails without --version", () => + Effect.gen(function* () { + const { exitCode, stderr } = yield* runScript(["--dry-run"]); + + expect(exitCode).toBe(1); + expect(stderr).toContain("Usage:"); + }).pipe(Effect.provide(platformLayer)), + ); + + it.effect("summarizes the committed schemas in dry-run mode without publishing", () => + Effect.gen(function* () { + const { exitCode, stdout, stderr } = yield* runScript(["--version", "v2.0.0", "--dry-run"]); + + expect(exitCode, stderr).toBe(0); + expect(stdout.trim()).toMatch( + /^Would publish .+ to supabase\/supabase:apps\/www\/public\/schemas on branch cli\/schemas$/, + ); + }).pipe(Effect.provide(platformLayer)), + ); + + it.effect("summarizes only the highest version of each schema", () => + Effect.gen(function* () { + const schemasDir = yield* schemasDirOf({ + "config/v1.json": "frozen, never validated", + "config/v2.json": schemaText("config", 2), + "lock/v1.json": schemaText("lock", 1), + }); + + const { exitCode, stdout, stderr } = yield* runScript([ + "--version", + "1.0.0", + "--schemas-dir", + schemasDir, + "--dry-run", + ]); + + expect(exitCode, stderr).toBe(0); + expect(stdout).toContain("Would publish config/v2.json, lock/v1.json to "); + }).pipe(Effect.provide(platformLayer)), + ); + + it.effect.each(refusals)("refuses $description", ({ files, message }) => + Effect.gen(function* () { + const schemasDir = yield* schemasDirOf(files); + + const { exitCode, stdout, stderr } = yield* runScript([ + "--version", + "1.0.0", + "--schemas-dir", + schemasDir, + "--dry-run", + ]); + + expect(exitCode).toBe(1); + expect(stdout).toBe(""); + expect(stderr).toContain(message); + }).pipe(Effect.provide(platformLayer)), + ); +}); diff --git a/apps/cli/scripts/publish-schemas.git.e2e.test.ts b/apps/cli/scripts/publish-schemas.git.e2e.test.ts new file mode 100644 index 0000000000..617e08c3c7 --- /dev/null +++ b/apps/cli/scripts/publish-schemas.git.e2e.test.ts @@ -0,0 +1,359 @@ +import { BunFileSystem, BunPath } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Config, ConfigProvider, Effect, FileSystem, JsonSchema, Layer, Path } from "effect"; +import { fileURLToPath } from "node:url"; + +const scriptPath = fileURLToPath(new URL("./publish-schemas.ts", import.meta.url)); +const platformLayer = Layer.mergeAll( + BunFileSystem.layer, + BunPath.layer, + ConfigProvider.layer(ConfigProvider.fromEnvRecord(process.env, { preserveEmptyStrings: true })), +); + +const REPO = "supabase/supabase"; +const PUBLISHED = "apps/www/public/schemas"; +const SEED_IDENTITY = ["-c", "user.name=seed", "-c", "user.email=seed@example.com"]; + +const fakeGh = `#!/usr/bin/env bash +printf '%s' "$*" | tr '\\n' ' ' >> "$FAKE_GH_DIR/calls.log" +echo >> "$FAKE_GH_DIR/calls.log" +if [ "$1 $2" = "pr list" ]; then + cat "$FAKE_GH_DIR/open-pulls.json" +fi +exit 0 +`; + +// Not prettier-formatted, so a formatting step would show up as a byte difference. +const schemaText = (name: string, version: number, marker: string) => + `{"$schema":"${JsonSchema.META_SCHEMA_URI_DRAFT_07}",\n\t"$id":"https://supabase.com/schemas/${name}/v${version}.json", "marker":"${marker}"}`; + +const spawn = (argv: ReadonlyArray, cwd: string, env: Record) => + Effect.gen(function* () { + const child = Bun.spawn([...argv], { cwd, env, stdout: "pipe", stderr: "pipe" }); + const [exitCode, stdout, stderr] = yield* Effect.all( + [ + Effect.promise(() => child.exited), + Effect.promise(() => new Response(child.stdout).text()), + Effect.promise(() => new Response(child.stderr).text()), + ], + { concurrency: "unbounded" }, + ); + return { exitCode, stdout, stderr }; + }); + +const writeFiles = (directory: string, files: Record) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + for (const [file, content] of Object.entries(files)) { + yield* fs.makeDirectory(path.dirname(path.join(directory, file)), { recursive: true }); + yield* fs.writeFileString(path.join(directory, file), content); + } + }); + +/** + * A local bare repository standing in for supabase/supabase, seeded on `master`, and a schemas + * directory to publish from. `branch` adds a `cli/schemas` branch cut from the seed and + * `targetAfterBranch` a later `master` commit. git reaches the bare repository through env-only URL + * rewriting and `gh` is a recording fake. + */ +const harness = (options: { + readonly target: Record; + readonly local: Record; + readonly branch?: Record; + readonly targetAfterBranch?: Record; + readonly openPulls?: string; +}) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "publish-schemas-" }); + const bare = path.join(root, "supabase.git"); + const seed = path.join(root, "seed"); + const schemasDir = path.join(root, "schemas"); + const bin = path.join(root, "bin"); + const tmp = path.join(root, "tmp"); + + const isolatedGit = { GIT_CONFIG_GLOBAL: "/dev/null", GIT_CONFIG_NOSYSTEM: "1" }; + const git = (cwd: string, ...args: ReadonlyArray) => + Effect.gen(function* () { + const result = yield* spawn(["git", ...args], cwd, { ...process.env, ...isolatedGit }); + expect(result.exitCode, result.stderr).toBe(0); + return result.stdout; + }); + + const commitAndPush = (branch: string, message: string, files: Record) => + Effect.gen(function* () { + yield* writeFiles(seed, files); + yield* git(seed, "add", "-A"); + yield* git(seed, ...SEED_IDENTITY, "commit", "--quiet", "-m", message); + yield* git(seed, "push", "--quiet", bare, branch); + }); + + yield* fs.makeDirectory(seed); + yield* fs.makeDirectory(tmp); + yield* git(root, "init", "--quiet", "--bare", "--initial-branch=master", bare); + yield* git(seed, "init", "--quiet", "--initial-branch=master"); + yield* commitAndPush("master", "seed", { "README.md": "# supabase", ...options.target }); + if (options.branch) { + yield* git(seed, "switch", "--quiet", "--create", "cli/schemas"); + yield* commitAndPush("cli/schemas", "branch", options.branch); + yield* git(seed, "switch", "--quiet", "master"); + } + if (options.targetAfterBranch) { + yield* commitAndPush("master", "master moves on", options.targetAfterBranch); + } + + yield* writeFiles(schemasDir, options.local); + yield* writeFiles(bin, { gh: fakeGh }); + yield* fs.chmod(path.join(bin, "gh"), 0o755); + yield* writeFiles(root, { "calls.log": "", "open-pulls.json": options.openPulls ?? "[]" }); + + const bunExecutable = Bun.which("bun"); + if (!bunExecutable) throw new Error("Bun executable not found"); + const inheritedPath = yield* Config.String("PATH"); + const { FORCE_COLOR: _forceColor, NO_COLOR: _noColor, ...inherited } = process.env; + const env = { + ...inherited, + ...isolatedGit, + PATH: `${bin}:${inheritedPath}`, + FAKE_GH_DIR: root, + TMPDIR: tmp, + GIT_CONFIG_COUNT: "1", + GIT_CONFIG_KEY_0: `url.file://${bare}.insteadOf`, + GIT_CONFIG_VALUE_0: `https://github.com/${REPO}.git`, + }; + + return { + publish: spawn( + [bunExecutable, scriptPath, "--version", "1.2.3", "--schemas-dir", schemasDir], + root, + env, + ), + branches: git(bare, "for-each-ref", "--format=%(refname:short)", "refs/heads").pipe( + Effect.map((text) => text.trim().split("\n")), + ), + show: (ref: string, file: string) => git(bare, "show", `${ref}:${PUBLISHED}/${file}`), + revision: (ref: string) => + git(bare, "rev-parse", "--verify", ref).pipe(Effect.map((text) => text.trim())), + commitSubject: (ref: string) => git(bare, "log", "-1", "--format=%an: %s", ref), + leftoverTempEntries: fs.readDirectory(tmp), + ghCalls: fs + .readFileString(path.join(root, "calls.log")) + .pipe(Effect.map((text) => text.split("\n").filter((line) => line !== ""))), + }; + }); + +describe("publish-schemas.ts against a local stand-in for supabase/supabase", () => { + it.live( + "pushes the changed schemas byte for byte, opens a PR and arms auto-merge", + () => + Effect.gen(function* () { + const config = schemaText("config", 1, "new"); + const projectConfig = schemaText("project-config", 1, "added"); + const target = yield* harness({ + target: { [`${PUBLISHED}/config/v1.json`]: schemaText("config", 1, "old") }, + local: { "config/v1.json": config, "project-config/v1.json": projectConfig }, + }); + + const { exitCode, stdout, stderr } = yield* target.publish; + + expect(exitCode, stderr).toBe(0); + expect(stdout).toContain("Pushed cli/schemas to supabase/supabase"); + expect(yield* target.branches).toEqual(["cli/schemas", "master"]); + expect(yield* target.show("cli/schemas", "config/v1.json")).toBe(config); + expect(yield* target.show("cli/schemas", "project-config/v1.json")).toBe(projectConfig); + expect(yield* target.show("master", "config/v1.json")).toBe(schemaText("config", 1, "old")); + expect(yield* target.commitSubject("cli/schemas")).toBe( + "github-actions[bot]: chore: update cli config schemas to v1.2.3\n", + ); + const calls = yield* target.ghCalls; + expect(calls.map((call) => call.split(" ", 2).join(" "))).toEqual([ + "pr list", + "pr create", + "pr merge", + ]); + expect(calls[1]).toContain("--title chore: update cli config schemas --body"); + expect(calls[1]).toContain("--base master --head cli/schemas"); + expect(calls[2]).toBe("pr merge cli/schemas --repo supabase/supabase --auto --squash"); + }).pipe(Effect.provide(platformLayer)), + 60_000, + ); + + it.live( + "arms auto-merge on the PR that is already open instead of creating another", + () => + Effect.gen(function* () { + const target = yield* harness({ + target: {}, + local: { "config/v1.json": schemaText("config", 1, "new") }, + openPulls: '[{"number":7,"headRepositoryOwner":{"login":"supabase"}}]', + }); + + const { exitCode, stdout, stderr } = yield* target.publish; + + expect(exitCode, stderr).toBe(0); + expect(stdout).toContain("Reusing the open PR for cli/schemas"); + expect((yield* target.ghCalls).map((call) => call.split(" ", 2).join(" "))).toEqual([ + "pr list", + "pr merge", + ]); + }).pipe(Effect.provide(platformLayer)), + 60_000, + ); + + it.live( + "publishes only the current version and leaves lower versions as published", + () => + Effect.gen(function* () { + const published = schemaText("supabase-lock", 1, "published"); + const current = schemaText("supabase-lock", 2, "current"); + const target = yield* harness({ + target: { [`${PUBLISHED}/supabase-lock/v1.json`]: published }, + local: { + "supabase-lock/v1.json": schemaText("supabase-lock", 1, "edited after the freeze"), + "supabase-lock/v2.json": current, + }, + }); + + const { exitCode, stderr } = yield* target.publish; + + expect(exitCode, stderr).toBe(0); + expect(yield* target.show("cli/schemas", "supabase-lock/v2.json")).toBe(current); + expect(yield* target.show("cli/schemas", "supabase-lock/v1.json")).toBe(published); + }).pipe(Effect.provide(platformLayer)), + 60_000, + ); + + it.live( + "pushes nothing and opens no PR when the target already has identical files", + () => + Effect.gen(function* () { + const config = schemaText("config", 1, "same"); + const target = yield* harness({ + target: { [`${PUBLISHED}/config/v1.json`]: config }, + local: { "config/v1.json": config }, + }); + + const { exitCode, stdout, stderr } = yield* target.publish; + + expect(exitCode, stderr).toBe(0); + expect(stdout).toContain(`${PUBLISHED} is already up to date in supabase/supabase`); + expect(yield* target.branches).toEqual(["master"]); + expect(yield* target.ghCalls).toEqual([]); + }).pipe(Effect.provide(platformLayer)), + 60_000, + ); + + it.live( + "refuses to write a version below one supabase.com already serves, pushes nothing and cleans up", + () => + Effect.gen(function* () { + const target = yield* harness({ + target: { [`${PUBLISHED}/config/v2.json`]: schemaText("config", 2, "served") }, + local: { "config/v1.json": schemaText("config", 1, "local") }, + }); + + const { exitCode, stdout, stderr } = yield* target.publish; + + expect(exitCode).toBe(1); + expect(stdout).toBe(""); + expect(stderr).toBe( + "Refusing to publish: supabase/supabase already serves config/v2.json, so writing config/v1.json would modify a frozen version.\n", + ); + expect(yield* target.branches).toEqual(["master"]); + expect(yield* target.ghCalls).toEqual([]); + expect(yield* target.leftoverTempEntries).toEqual([]); + }).pipe(Effect.provide(platformLayer)), + 60_000, + ); + + it.live( + "refuses a version below one master gained after cli/schemas was cut, leaving the branch untouched", + () => + Effect.gen(function* () { + const target = yield* harness({ + target: {}, + branch: { [`${PUBLISHED}/config/v1.json`]: schemaText("config", 1, "stale branch") }, + targetAfterBranch: { [`${PUBLISHED}/config/v2.json`]: schemaText("config", 2, "served") }, + local: { "config/v1.json": schemaText("config", 1, "local") }, + }); + const branchTip = yield* target.revision("cli/schemas"); + + const { exitCode, stdout, stderr } = yield* target.publish; + + expect(exitCode).toBe(1); + expect(stdout).toBe(""); + expect(stderr).toBe( + "Refusing to publish: supabase/supabase already serves config/v2.json, so writing config/v1.json would modify a frozen version.\n", + ); + expect(yield* target.revision("cli/schemas")).toBe(branchTip); + expect(yield* target.ghCalls).toEqual([]); + expect(yield* target.leftoverTempEntries).toEqual([]); + }).pipe(Effect.provide(platformLayer)), + 60_000, + ); + + it.live( + "refuses to publish when cli/schemas edits a frozen version, leaving the branch untouched", + () => + Effect.gen(function* () { + const target = yield* harness({ + target: { + [`${PUBLISHED}/supabase-lock/v1.json`]: schemaText("supabase-lock", 1, "frozen"), + }, + branch: { + [`${PUBLISHED}/supabase-lock/v1.json`]: schemaText( + "supabase-lock", + 1, + "edited on branch", + ), + }, + local: { + "supabase-lock/v1.json": schemaText("supabase-lock", 1, "frozen"), + "supabase-lock/v2.json": schemaText("supabase-lock", 2, "current"), + }, + }); + const branchTip = yield* target.revision("cli/schemas"); + + const { exitCode, stdout, stderr } = yield* target.publish; + + expect(exitCode).toBe(1); + expect(stdout).toBe(""); + expect(stderr).toBe( + "Refusing to publish: cli/schemas also changes supabase-lock/v1.json against master, and only the current schema versions may change. Delete cli/schemas in supabase/supabase and rerun the publish.\n", + ); + expect(yield* target.revision("cli/schemas")).toBe(branchTip); + expect(yield* target.ghCalls).toEqual([]); + expect(yield* target.leftoverTempEntries).toEqual([]); + }).pipe(Effect.provide(platformLayer)), + 60_000, + ); + + it.live( + "publishes over an older edit of a current version as one new commit on the existing branch", + () => + Effect.gen(function* () { + const config = schemaText("config", 1, "new"); + const target = yield* harness({ + target: { [`${PUBLISHED}/config/v1.json`]: schemaText("config", 1, "published") }, + branch: { [`${PUBLISHED}/config/v1.json`]: schemaText("config", 1, "older edit") }, + local: { "config/v1.json": config }, + }); + const branchTip = yield* target.revision("cli/schemas"); + + const { exitCode, stdout, stderr } = yield* target.publish; + + expect(exitCode, stderr).toBe(0); + expect(stdout).toContain("Pushed cli/schemas to supabase/supabase"); + expect(yield* target.show("cli/schemas", "config/v1.json")).toBe(config); + expect(yield* target.revision("cli/schemas^")).toBe(branchTip); + expect((yield* target.ghCalls).map((call) => call.split(" ", 2).join(" "))).toEqual([ + "pr list", + "pr create", + "pr merge", + ]); + }).pipe(Effect.provide(platformLayer)), + 60_000, + ); +}); diff --git a/apps/cli/scripts/publish-schemas.ts b/apps/cli/scripts/publish-schemas.ts new file mode 100644 index 0000000000..203fc5273b --- /dev/null +++ b/apps/cli/scripts/publish-schemas.ts @@ -0,0 +1,215 @@ +/** + * Publishes the current version of each JSON Schema under `apps/docs/public/schemas` to + * supabase.com by opening a PR against supabase/supabase, which serves + * `apps/www/public/schemas` at https://supabase.com/schemas: + * + * bun scripts/publish-schemas.ts --version + * + * Lower versions of a schema are frozen: only the highest `v.json` per schema is written. The + * run refuses when the base branch already serves a higher version, or when the publishing branch + * changes anything other than the current versions. A relative `--schemas-dir` resolves against the + * repository root. A release that changes no schema opens or updates nothing. + */ +import { BunFileSystem, BunPath } from "@effect/platform-bun"; +import { $ } from "bun"; +import { Console, Data, Effect, FileSystem, JsonSchema, Layer, Path, Schema } from "effect"; +import process from "node:process"; +import { parseArgs } from "node:util"; +import { publishPullRequest, shell } from "./publish-pull-request.ts"; + +const PUBLISHED_PATH = "apps/www/public/schemas"; +const VERSION_FILE = /^v([1-9]\d*)\.json$/; + +class PublishRefused extends Data.TaggedError("PublishRefused")<{ readonly reason: string }> {} + +interface CurrentSchema { + readonly name: string; + readonly version: number; + readonly file: string; +} + +const decodeDocument = Schema.decodeUnknownEffect( + Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)), +); + +const { values } = parseArgs({ + options: { + version: { type: "string" }, + repo: { type: "string", default: "supabase/supabase" }, + "schemas-dir": { type: "string", default: "apps/docs/public/schemas" }, + branch: { type: "string", default: "cli/schemas" }, + base: { type: "string", default: "master" }, + "dry-run": { type: "boolean", default: false }, + }, +}); + +const versionsIn = (entries: ReadonlyArray) => + entries.flatMap((entry) => { + const match = VERSION_FILE.exec(entry); + return match?.[1] === undefined ? [] : [Number(match[1])]; + }); + +const listDirectory = (directory: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return (yield* fs.exists(directory)) ? (yield* fs.readDirectory(directory)).sort() : []; + }); + +const discoverCurrentSchemas = (schemasDir: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const current: Array = []; + for (const name of yield* listDirectory(schemasDir)) { + const directory = path.join(schemasDir, name); + if ((yield* fs.stat(directory)).type !== "Directory") continue; + const entries = yield* listDirectory(directory); + if (entries.length === 0) continue; + const misnamed = entries.find((entry) => !VERSION_FILE.test(entry)); + if (misnamed !== undefined) { + return yield* new PublishRefused({ + reason: `${name}/${misnamed} does not match v.json.`, + }); + } + const version = Math.max(...versionsIn(entries)); + current.push({ name, version, file: path.join(directory, `v${version}.json`) }); + } + if (current.length === 0) { + return yield* new PublishRefused({ reason: `no schemas found in ${schemasDir}.` }); + } + return current; + }); + +const validateCurrentSchema = ({ name, version, file }: CurrentSchema) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const label = `${name}/v${version}.json`; + const document = yield* decodeDocument(yield* fs.readFileString(file)).pipe( + Effect.mapError(() => new PublishRefused({ reason: `${label} is not a valid JSON object.` })), + ); + if (document["$schema"] !== JsonSchema.META_SCHEMA_URI_DRAFT_07) { + return yield* new PublishRefused({ + reason: `${label} must declare "$schema": "${JsonSchema.META_SCHEMA_URI_DRAFT_07}".`, + }); + } + const id = `https://supabase.com/schemas/${name}/v${version}.json`; + if (document["$id"] !== id) { + return yield* new PublishRefused({ reason: `${label} must declare "$id": "${id}".` }); + } + }); + +const writeCurrentSchemas = ( + repo: string, + base: string, + branch: string, + schemas: ReadonlyArray, + checkoutDir: string, +) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const published = (name: string) => path.join(checkoutDir, PUBLISHED_PATH, name); + const prefix = `${PUBLISHED_PATH}/`; + + const listing = yield* shell(() => + $`git -C ${checkoutDir} -c core.quotePath=false ls-tree -r --name-only origin/${base} -- ${PUBLISHED_PATH}`.text(), + ); + const servedPaths = listing + .split("\n") + .filter((line) => line.startsWith(prefix)) + .map((line) => line.slice(prefix.length)); + for (const { name, version } of schemas) { + const served = versionsIn( + servedPaths + .filter((file) => file.startsWith(`${name}/`)) + .map((file) => file.slice(name.length + 1)), + ); + const newest = Math.max(version, ...served); + if (newest > version) { + return yield* new PublishRefused({ + reason: `${repo} already serves ${name}/v${newest}.json, so writing ${name}/v${version}.json would modify a frozen version.`, + }); + } + } + for (const { name, version, file } of schemas) { + yield* fs.makeDirectory(published(name), { recursive: true }); + yield* fs.copyFile(file, path.join(published(name), `v${version}.json`)); + } + + yield* shell(() => $`git -C ${checkoutDir} add -- ${PUBLISHED_PATH}`); + const changed = yield* shell(() => + $`git -C ${checkoutDir} -c core.quotePath=false diff --cached --name-only --no-renames origin/${base} -- ${PUBLISHED_PATH}`.text(), + ); + const current = new Set( + schemas.map(({ name, version }) => `${prefix}${name}/v${version}.json`), + ); + const unexpected = changed + .split("\n") + .filter((line) => line !== "" && !current.has(line)) + .map((line) => line.slice(prefix.length)) + .sort(); + if (unexpected.length > 0) { + return yield* new PublishRefused({ + reason: `${branch} also changes ${unexpected.join(", ")} against ${base}, and only the current schema versions may change. Delete ${branch} in ${repo} and rerun the publish.`, + }); + } + }); + +const main = Effect.gen(function* () { + const path = yield* Path.Path; + + const versionArgument = values.version; + if (!versionArgument) { + yield* Console.error( + "Usage: bun scripts/publish-schemas.ts --version [--repo ] [--schemas-dir ] [--branch ] [--base ] [--dry-run]", + ); + return yield* Effect.sync(() => process.exit(1)); + } + const version = versionArgument.startsWith("v") ? versionArgument.slice(1) : versionArgument; + + const repo = values.repo!; + const branch = values.branch!; + const base = values.base!; + const schemasDir = path.resolve(import.meta.dir, "../../..", values["schemas-dir"]!); + + const schemas = yield* discoverCurrentSchemas(schemasDir); + yield* Effect.forEach(schemas, validateCurrentSchema); + + if (values["dry-run"]) { + const files = schemas.map(({ name, version }) => `${name}/v${version}.json`).join(", "); + yield* Console.log(`Would publish ${files} to ${repo}:${PUBLISHED_PATH} on branch ${branch}`); + return; + } + + yield* publishPullRequest({ + repo, + base, + branch, + stagedPath: PUBLISHED_PATH, + write: (checkoutDir) => writeCurrentSchemas(repo, base, branch, schemas, checkoutDir), + commitMessage: `chore: update cli config schemas to v${version}`, + pullRequestTitle: "chore: update cli config schemas", + pullRequestBody: [ + "Updates the published JSON Schemas from the supabase/cli release workflow.", + "", + "Generated by `scripts/publish-schemas.ts` in supabase/cli from", + "`apps/docs/public/schemas` — edit the schemas there rather than this folder.", + "Published versions below the current one are frozen and never rewritten.", + "", + "Auto-merge is enabled: the PR squash-merges once a reviewer approves it.", + ].join("\n"), + autoMerge: true, + }); +}); + +await Effect.runPromise( + main.pipe( + Effect.catchTag("PublishRefused", ({ reason }) => + Console.error(`Refusing to publish: ${reason}`).pipe( + Effect.andThen(Effect.sync(() => process.exit(1))), + ), + ), + Effect.provide(Layer.mergeAll(BunFileSystem.layer, BunPath.layer)), + Effect.catch((error) => Effect.die(error.cause ?? error)), + ), +); diff --git a/docs/self-documenting-cli.md b/docs/self-documenting-cli.md index adaeda653f..6185062a2a 100644 --- a/docs/self-documenting-cli.md +++ b/docs/self-documenting-cli.md @@ -28,6 +28,8 @@ development. The generator: `public/schemas//v1.json`, mirroring their `https://supabase.com/schemas/` URLs. The two config schemas are copied from the `@supabase/config` build, which requires `@supabase/config#build` to have run first; `turbo.json` wires that up. + Stable releases publish these files to supabase.com; see the + [CLI reference README](../apps/cli/docs/README.md#release). The generated `content/docs/commands/` directory is git-ignored; only the hand-authored pages are committed.