Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 61 additions & 5 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand All @@ -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.
Expand All @@ -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
Expand Down
7 changes: 6 additions & 1 deletion .github/workflows/slack-notify.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions apps/cli/docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,20 @@ To publish by hand, run the same pipe the job runs:
bun scripts/generate-docs-spec.ts <version> | bun scripts/publish-docs-spec.ts --version <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<N>.json` into `apps/www/public/schemas` on the `cli/schemas` branch in supabase/supabase,
serving it at `https://supabase.com/schemas/<name>/v<N>.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 <version> [--dry-run]
```

## Maintenance

When adding or changing a command or flag, update the matching entries in
Expand Down
137 changes: 30 additions & 107 deletions apps/cli/scripts/publish-docs-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = <A>(command: () => Promise<A>) =>
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 = <A>(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" },
Expand Down Expand Up @@ -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(
Expand Down
Loading
Loading