diff --git a/.gitattributes b/.gitattributes index c48799578d..645a2f8895 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,5 +1,36 @@ -# pnpm 12 parses patch files with a Rust patch parser that rejects a carriage -# return in the `---`/`+++` header lines. Git for Windows checks text files out -# with CRLF by default, which broke `pnpm install` on the Windows release -# smoke-test. Keep patch files LF everywhere. +# pnpm 12 briefly had a bug (pnpm/pnpm#14557) where it parsed patch files with a +# Rust patch parser that rejects a carriage return in the `---`/`+++` header lines. +# Git for Windows checks text files out with CRLF by default, which broke +# `pnpm install` on the Windows release smoke-test. +# This guarantees that we keep patch files LF everywhere. patches/*.patch text eol=lf + +# error-tag-stability.unit.test.ts splits this fixture on newlines to diff it +# against a live-scanned tag set. Pin it to LF so a CRLF checkout on Windows +# can't leave a trailing `\r` on every entry and make every tag mismatch. +apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt text eol=lf + +# Generated artifacts. `linguist-generated` keeps these out of GitHub's language +# statistics and collapses them in pull request diffs -- they are build output to +# be regenerated, not code to review, and together they outweigh the hand-written +# source. The attribute is GitHub-only: git, CI, and local diffs are unaffected. +# Collapsed is not hidden. v1-openapi.yaml and the lockfiles do carry changes +# worth reading -- an upstream API change, a dependency substitution -- and both +# stay one click away in review. +# Regeneration is owned by `go generate` in apps/cli-go and by the `generate` +# turbo task, whose `outputs` in turbo.json are the source of truth for this list. +# Only the generated files are listed, not their hand-written inputs +# (pkg/api/*.cfg.yaml, api/overlay.yaml). apps/docs/content/docs/commands/ is +# generated too but gitignored, so it needs no entry. +apps/cli-go/api/v1-openapi.yaml linguist-generated=true +apps/cli-go/pkg/api/*.gen.go linguist-generated=true +packages/api/src/generated/* linguist-generated=true +packages/api/scripts/openapi-source.json linguist-generated=true +apps/cli/src/shared/feedback/database.types.ts linguist-generated=true +apps/docs/public/cli/config.schema.json linguist-generated=true +apps/docs/public/cli/project-config.schema.json linguist-generated=true + +# Lockfiles. Collapsed rather than hidden -- a dependency change is still one +# click away in review, which is the same treatment GitHub gives package-lock.json. +pnpm-lock.yaml linguist-generated=true +apps/cli-go/go.sum linguist-generated=true diff --git a/.github/MAINTAINERS.md b/.github/MAINTAINERS.md index eee43c5322..7353c710d6 100644 --- a/.github/MAINTAINERS.md +++ b/.github/MAINTAINERS.md @@ -51,26 +51,78 @@ issue directly (from the GitHub UI, or from the Linear-linked issue). ## `run-ci`: full develop CI on stacked or draft PRs Ready (non-draft) PRs targeting `develop` already get the default suite: Test -(check / unit+integration / e2e), preview CLI packages, and PR-title lint. +(check / unit+integration / e2e) and PR-title lint. Stacked PRs (base is another PR branch) and drafts do **not** get that suite unless they carry the **`run-ci`** label. [`run-ci.yml`](./workflows/run-ci.yml) -then calls Test and preview-package publish as reusable workflows, including -while the PR is still a draft. +then calls Test as a reusable workflow, including while the PR is still a draft. - Add `run-ci` to start (or resume) the suite; remove it to cancel in-progress `run-ci` runs via that workflow's concurrency group. -- Other labels do not start or cancel Test / preview. PR-title lint may - retrigger because that check is cheap. +- Other labels do not start or cancel Test. PR-title lint may retrigger because + that check is cheap. - After a stacked PR is retargeted onto `develop`, push or reopen so the native required checks (`Check code quality`, etc.) populate. The opt-in suite uses different check names (`Test / Check code quality`). -- This is independent of `run-live-e2e-ci`, which opts into the separate - supabox live e2e dispatch. +- This is independent of `run-preview-packages` and `run-live-e2e-ci`. The `run-ci` label must exist as a repository label; create it from **Issues → Labels** if it is missing. +## `run-preview-packages`: on-demand pkg.pr.new preview + +CLI preview packages are large, so they are **not** published on every PR. +Add the **`run-preview-packages`** label to publish via +[`publish-preview-cli-packages.yml`](./workflows/publish-preview-cli-packages.yml) +(any base branch, including drafts). While the label stays on, each subsequent +push re-publishes; remove it to cancel in-progress runs. + +The workflow posts (or updates) a PR comment with an `npx` install command for +the preview. This is independent of `run-ci` and `run-live-e2e-ci`. + +The `run-preview-packages` label must exist as a repository label; create it +from **Issues → Labels** if it is missing. + +## Live e2e coverage and stable releases + +[`Live E2E`](./workflows/live-e2e.yml) exercises managed staging after every push +to `develop`, daily at 06:23 UTC, and on manual dispatch. New `develop` pushes +cancel superseded push runs; nightly and manual runs execute independently. +Nightly runs do not depend on a new beta version: they also detect staging +changes between CLI releases. + +Stable publishing requires a passing live suite for the exact release commit. +The release workflow reuses a verified successful staging run on `develop` for +that commit when available; otherwise it runs the suite before publishing. +Normal promotion fast-forwards that commit from `develop` to `main`. The gate +deliberately queries `develop` runs of `live-e2e.yml`; renaming the workflow +requires updating that selector. Actions API lookup errors and live-test +failures block publication. This also applies to +manual stable releases. Beta publication keeps its existing build and smoke-test +gates. + +Live-test failures and recoveries are sent to the channel configured by +`SLACK_RELEASE_WEBHOOK`, with commit and workflow links. Routine successful runs +stay quiet. GitHub Actions logs contain the test failures; notification delivery +does not determine whether the suite passed. + +PR live coverage remains opt-in through `run-live-e2e-ci`. That label dispatches +the PR commit to the separate Supabox harness, which also has its own nightly +schedule against pinned submodules. A Supabox result does not replace the +managed-staging gate for stable publication. + +The gate and notifier identify the reusable suite by the `Live e2e` job name (or +the exact ` / Live e2e` suffix). The gate also checks the `Run live e2e` step +name. Keep these names aligned with their consumers. Push, scheduled, manual, and stable-gate runs +use separate concurrency groups because they own independent temporary project +sets; this is intentional and does not imply a global concurrency quota. +Notification history inspects at most 25 recent runs of the same workflow and +branch. It suppresses repeated outcomes and results superseded by a newer run +or attempt. Recovery requires a known prior failure. History lookup errors +produce warnings; a confirmed current failure can still be reported if its +prior outcome is unknown. Release failures use the existing release notification +to avoid a second failure alert from the live notifier. + ## Deferred: automatic Linear → GitHub label sync We considered auto-applying `open-for-contribution` when a Linear issue moves out of diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml index d5c4e069b7..1fdd077f3b 100644 --- a/.github/actions/setup/action.yml +++ b/.github/actions/setup/action.yml @@ -75,6 +75,11 @@ runs: shell: bash env: DEPENDENCY_FIREWALL_TOKEN: ${{ inputs.dependency-firewall-token }} + # Skip husky's hook install: the commit-msg hook is a local dev + # convenience, not a gate for bot-authored commits (e.g. + # propose-release-notes.ts), and CI already enforces scopes on the + # PR title separately. + HUSKY: "0" run: | if [ -z "$DEPENDENCY_FIREWALL_TOKEN" ]; then echo "Dependency Firewall token unavailable; using default npm registry." diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 547e80b469..b0922574ff 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -11,10 +11,30 @@ updates: actions-major: patterns: - "*" + exclude-patterns: + - "openai/codex-action" ignore: - dependency-name: "supabase/setup-cli" update-types: - "version-update:semver-major" + - dependency-name: "openai/codex-action" + # NOTE: this ecosystem's `versions` strings are parsed as Ruby + # Gem::Requirement (RubyGems comparator syntax), NOT npm-style semver + # ranges — "1.12.x" is not a wildcard here, it parses as a literal + # (and never-matching) version string, which is why the prior attempt + # at this silently failed to block v1.12 (PR #6541, 2026-09-09). Use + # real comparators instead. + versions: + - ">= 1.12, < 1.13" + # Deliberately pinned to v1.11 in ai-review.yml — v1.12 has two + # confirmed, still-open upstream regressions: a wrapper-level hang + # (openai/codex-action#151) and a runner-killing failure + # (openai/codex-action#160). A grouped Dependabot bump already + # silently reintroduced v1.12 once (PR #6484, 2026-09-07). Scoped to + # the 1.12 line (not a blanket ignore) so Dependabot still proposes + # v1.13+ once a fix ships; evaluate any such proposal in its own + # deliberate PR, checking the upstream changelog/issue tracker first + # — never bundle it into the actions-major group. cooldown: default-days: 7 - package-ecosystem: "gomod" @@ -24,6 +44,8 @@ updates: schedule: interval: "cron" cronjob: "0 0 * * *" + commit-message: + prefix: "chore(cli-go): " groups: go-minor: update-types: @@ -43,9 +65,8 @@ updates: interval: "cron" cronjob: "0 0 * * *" commit-message: - prefix: "fix" - prefix-development: "chore" - include: "scope" + prefix: "fix(misc): " + prefix-development: "chore(misc): " groups: npm-major: patterns: @@ -58,7 +79,7 @@ updates: interval: "cron" cronjob: "0 0 * * *" commit-message: - prefix: "fix(docker): " + prefix: "fix(misc): " groups: docker-minor: update-types: diff --git a/.github/scripts/ai-review/post-review.test.ts b/.github/scripts/ai-review/post-review.test.ts index cd1db8890a..0f884b196c 100644 --- a/.github/scripts/ai-review/post-review.test.ts +++ b/.github/scripts/ai-review/post-review.test.ts @@ -25,9 +25,7 @@ import { truncateReviewBody, } from "./post-review.ts"; -// A single hunk touching file.ts lines 10-14 on the new side: line 10 is -// context, line 11 replaces a removed line, 12 is a pure addition, 13-14 are -// trailing context. Hand-computed RIGHT-side anchors: {10, 11, 12, 13, 14}. +// One hunk on file.ts; RIGHT-side anchors: {10, 11, 12, 13, 14}. const SINGLE_HUNK_DIFF = `diff --git a/file.ts b/file.ts index 111..222 100644 --- a/file.ts @@ -41,8 +39,7 @@ index 111..222 100644 context line 14 `; -// Two hunks in the same file: {1,2,3} from the first hunk, {20,21,22} from -// the second (the RIGHT counter resets to each hunk's own header). +// Two hunks in one file; each resets the RIGHT counter to its own header. const MULTI_HUNK_DIFF = `diff --git a/multi.ts b/multi.ts index 1..2 100644 --- a/multi.ts @@ -101,8 +98,7 @@ index 0..6 +line three `; -// A trailing "\ No newline at end of file" marker on both sides must not -// perturb the RIGHT counter: anchors are still {1,2}. +// A trailing "\ No newline at end of file" marker must not perturb the RIGHT counter. const NO_NEWLINE_DIFF = `diff --git a/nonewline.ts b/nonewline.ts index 7..8 100644 --- a/nonewline.ts @@ -115,9 +111,7 @@ index 7..8 100644 \\ No newline at end of file `; -// git appends a literal TAB after a `+++` path that needs quoting (here, -// because it contains a space); the tab must be stripped so anchors key on -// "has space.ts", not "has space.ts\t". +// git appends a literal TAB after a quoted `+++` path; it must be stripped. const TAB_PATH_DIFF = `diff --git a/has space.ts b/has space.ts index 9..a 100644 --- a/has space.ts @@ -127,10 +121,7 @@ index 9..a 100644 +added line `; -// A pure rename (100% similarity) carries no `---`/`+++`/`@@` lines at all, -// followed by a normal file's diff — the parser must not leak state (e.g. a -// leftover `currentFile`) from the header-less rename section into the next -// file. +// A pure rename carries no `---`/`+++`/`@@` lines; state must not leak into the next file's diff. const RENAME_ONLY_THEN_NORMAL_DIFF = `diff --git a/old-name.ts b/new-name.ts similarity index 100% rename from old-name.ts @@ -144,10 +135,7 @@ index 1..2 100644 +added `; -// An added line whose literal content is "++ b/not-a-real-header.ts" appears -// in the diff, prefixed by the diff's own "+", as "+++ b/not-a-real-header.ts" -// — a `+++`-lookalike that must not hijack `currentFile` because it occurs -// inside a hunk, not between a `diff --git` boundary and the first `@@`. +// An added line whose content looks like a `+++` header must not hijack `currentFile`. const PLUS_LOOKALIKE_DIFF = `diff --git a/lookalike.ts b/lookalike.ts index 1..2 100644 --- a/lookalike.ts @@ -828,10 +816,6 @@ describe("buildReviewPayload", () => { }); test("truncates the very first payload's body when it already exceeds the cap with zero comments to fold", () => { - // Not anchorable (line 999 is outside the diff hunk), so this produces a - // body-only payload with no inline comments — the 422-retry fold path - // never runs, so only truncating `buildReviewPayload`'s own body catches - // an oversized initial POST. const finding = makeFinding({ file: "file.ts", line: 999, claim: "x".repeat(70_000) }); const review = makeMergedReview({ findings: [finding] }); const payload = buildReviewPayload(review, anchors, footer); @@ -1045,10 +1029,7 @@ describe("post flow via injected ReviewIo", () => { if (opts.failSupersede) { return Promise.reject(new Error("listReviews failed")); } - // Mirror real GitHub: a review posted earlier in the same run shows - // up in later listings as a marker-bearing bot review. The supersede - // pass must snapshot BEFORE posting or it would wrap the fresh - // review as "superseded" too. + // Mirrors real GitHub: a review posted earlier in the same run shows up in later listings. const alreadyPosted = postedReviews.map((payload, i) => ({ id: 900 + i, body: payload.body, @@ -1141,9 +1122,6 @@ describe("post flow via injected ReviewIo", () => { await postConsolidatedReview(io, 42, review, footer); - // With no prior AI review on the PR, nothing may be wrapped as superseded - // — especially not the review this run just posted (which the fake's - // listReviews, like real GitHub, includes in post-POST listings). expect(postedReviews).toHaveLength(1); expect(updatedReviews).toEqual([]); expect(updatedComments).toEqual([]); @@ -1230,8 +1208,6 @@ describe("post flow via injected ReviewIo", () => { }); test("posts a truncated body on the very first attempt for an oversized body-only review (no comments to fold)", async () => { - // Not anchorable, so there's no inline comment for GitHub to 422 on — the - // old behavior threw here instead of posting a truncated body. const finding = makeFinding({ file: "file.ts", line: 999, claim: "x".repeat(70_000) }); const review = makeMergedReview({ findings: [finding] }); const { io, postedReviews } = makeReviewIo({ diff: SINGLE_HUNK_DIFF }); diff --git a/.github/scripts/ai-review/post-review.ts b/.github/scripts/ai-review/post-review.ts index c56949d1d3..93a042ed44 100644 --- a/.github/scripts/ai-review/post-review.ts +++ b/.github/scripts/ai-review/post-review.ts @@ -1,56 +1,38 @@ /** - * AI review poster: validates the structured findings both model passes - * produce, and posts the ONE consolidated PR review the pipeline is allowed - * to post per run. + * AI review poster: validates the structured findings the two model passes + * produce, and posts the one consolidated PR review this pipeline posts per run. * - * Four subcommands, dispatched from `argv`: - * - `validate-findings ` — checks a Claude findings JSON file against - * the shape `.github/ai-review/findings.schema.json` describes. The - * `--json-schema` flag passed to `claude` is a hint to the model, not a - * runtime guarantee, so the CI step re-checks the extracted output here - * before it is trusted. - * - `validate-merged ` — same idea for the Codex-adjudicated merged - * review, against `.github/ai-review/merged-review.schema.json`. - * - `redact ` — reads a JSON file, deep-walks every string value - * through `redactSecrets`, and writes it back in place. Run on every - * model-output JSON file before it's uploaded as a (public-repo) - * artifact, so a prompt-injected `Read` of a secret-bearing path can't - * smuggle a credential out through the artifact even though the posted - * review is already scrubbed at render time. - * - `post` — snapshots the PR's prior AI reviews, posts the consolidated - * review, THEN best-effort supersedes the snapshotted ones (the - * marker/dedup guard in `resolve.ts` should normally prevent a second - * run, but `/ai-review` lets a maintainer force one). The snapshot must - * happen BEFORE the POST — the fresh review is itself a marker-bearing - * bot review, so a post-hoc listing would sweep it into its own - * supersede pass and every new review would collapse itself. Posting - * before superseding, and treating both the snapshot and the supersede - * as best-effort, means a cosmetic failure can never cost the real - * review. + * Subcommands, dispatched from `argv`: + * - `validate-findings ` / `validate-merged ` — check a model's + * findings/merged-review JSON against its schema under `.github/ai-review/`. + * The `--json-schema` flag passed to `claude` is only a hint to the model, + * so this re-validates its output before it's trusted. + * - `redact ` — scrubs secret-shaped strings from a JSON file in + * place; run on every model-output file before it's uploaded as a + * (public-repo) artifact. + * - `post` — builds and posts the consolidated review, then supersedes any + * prior AI reviews on the PR (see `listPriorRunsBestEffort` for why the + * snapshot must happen before the POST). * * `parseDiffAnchors`, `partitionFindings`, `renderReviewBody`, * `renderInlineComment`, `buildReviewPayload`, `foldInlineCommentsIntoBody`, * `supersededBody`, `isSuperseded`, `sanitizeFilePath`, and `redactSecrets` - * are pure and exported for tests. `postConsolidatedReview` is the I/O - * orchestration function for the `post` subcommand; it's exported so a test can drive it against - * an injected `ReviewIo` fake without the network, the same way - * `resolveDecision` is tested in `resolve.ts`. `main()` wires up the real - * GitHub I/O and argv dispatch. + * are pure and exported for tests. `postConsolidatedReview` drives the `post` + * subcommand against an injected `ReviewIo` fake in tests. * * Run in CI as: `bun .github/scripts/ai-review/post-review.ts `. */ export const AI_REVIEW_MARKER = ""; const SUPERSEDED_SUMMARY = "Superseded by a newer AI review"; -/** Hidden marker `isSuperseded` looks for. Kept out of the human-readable - * `SUPERSEDED_SUMMARY` text and broken by `sanitizeModelText` so a model - * can't forge or evade a supersede by echoing the visible text into a - * `claim`/`summary` field. */ +/** Hidden marker `isSuperseded` checks for. Kept out of the human-readable + * `SUPERSEDED_SUMMARY` text, and broken by `sanitizeModelText`, so a model + * can't forge or evade a supersede by echoing it into a `claim`/`summary` field. */ const SUPERSEDED_MARKER = ""; const WORKFLOW_BOT_LOGIN = "github-actions[bot]"; const GITHUB_REVIEW_BODY_MAX = 65536; -// --- Shared types (mirror the two schema files by hand; keep in sync) --- +// Mirrors the two schema files under `.github/ai-review/`; keep them in sync by hand. export type Severity = "critical" | "major" | "minor" | "nit"; export type Verdict = "confirmed" | "refuted" | "uncertain"; @@ -107,12 +89,8 @@ export interface MergedReview { stats: MergedReviewStats; } -// --- Hand-rolled schema validators --- -// -// `.github/ai-review/findings.schema.json` and `merged-review.schema.json` -// are the model-facing contract (passed as `--json-schema`/`output-schema-file`); -// these validators are the runtime enforcement and must be kept in sync with -// them by hand whenever either shape changes. +// Runtime enforcement for the schemas in `.github/ai-review/`; keep these in +// sync by hand whenever either shape changes. function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); @@ -219,12 +197,11 @@ function expectCategory(value: unknown, path: string, context: string): string { return str; } -/** `file` is model-controlled and rendered inside `` `code` `` spans at - * several sites; a backtick, newline, other ASCII control char, or `<` in it - * could break out of the span (markdown/HTML injection, mention/#ref pings) - * or forge one of the hidden HTML-comment markers. Reject those at parse - * time as the primary defense; `sanitizeFilePath` neutralizes the same - * characters again at render time in case a caller ever skips validation. */ +/** `file` is model-controlled and rendered inside `` `code` `` spans; a + * backtick, newline, other ASCII control char, or `<` in it could break out + * of the span or forge a hidden marker, so reject those at parse time. + * `sanitizeFilePath` neutralizes the same characters again at render time in + * case a caller skips validation. */ // eslint-disable-next-line no-control-regex -- matching control characters is the point of this pattern const FILE_PATH_FORBIDDEN_PATTERN = /[`<\x00-\x1f\x7f]/; @@ -385,8 +362,6 @@ export function assertMergedReview(value: unknown): asserts value is MergedRevie parseMergedReview(value); } -// --- Diff anchoring --- - const DIFF_GIT_HEADER = /^diff --git /; const HUNK_HEADER = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/; const NEW_FILE_HEADER = /^\+\+\+ (?:b\/(.+)|\/dev\/null)$/; @@ -408,15 +383,13 @@ function stripTrailingTab(path: string): string { } /** - * Parses a unified diff into, for each file, the set of new-side (RIGHT) line - * numbers present in the diff — i.e. the lines a PR review comment can - * anchor to. Context and `+` lines advance the RIGHT counter and are - * anchorable; `-` lines don't exist on the new side and are skipped. + * Parses a unified diff into, for each file, the new-side (RIGHT) line + * numbers a PR review comment can anchor to. Context and `+` lines advance + * the RIGHT counter; `-` lines don't exist on the new side and are skipped. * - * Tracks whether we're inside a hunk so a `+++ ` file header is only ever - * recognized between a `diff --git` boundary and that file's first `@@` - * hunk — otherwise an added/context line whose literal content happens to - * start with `+++ ` (a `+++`-lookalike) could hijack `currentFile`. + * A `+++ ` file header is only recognized between a `diff --git` boundary and + * that file's first `@@` hunk, so an added/context line that happens to start + * with `+++ ` can't hijack `currentFile`. */ export function parseDiffAnchors(diff: string): Map> { const anchors = new Map>(); @@ -461,8 +434,6 @@ function isAnchorable(anchors: Map>, file: string, line: num return anchors.get(file)?.has(line) ?? false; } -// --- Findings partitioning and rendering --- - export interface PartitionedFindings { /** Confirmed/uncertain findings whose start line lands on a diff hunk; posted as inline comments. */ anchorable: MergedFinding[]; @@ -510,17 +481,14 @@ const HTML_COMMENT_OPENER_PATTERN = /"); } -/** Neutralizes the same characters `expectFile` rejects at parse time - * (backtick, `<`, ASCII control chars) inside a model-provided `file` path - * before it's rendered into a `` `code` `` span. Every finding reaching a - * render site will already have passed `expectFile`; this is defense-in-depth - * for any caller that renders a `MergedFinding` without going through - * `assertMergedReview` first. */ +/** Strips the same characters `expectFile` rejects at parse time (backtick, + * `<`, ASCII control chars) so a caller that renders a `MergedFinding` + * without going through `assertMergedReview` first still can't break a + * rendered `` `code` `` span. */ // eslint-disable-next-line no-control-regex -- matching control characters is the point of this pattern const FILE_PATH_UNSAFE_CHARS = /[`<\x00-\x1f\x7f]/g; @@ -629,9 +588,8 @@ export function renderInlineComment(finding: MergedFinding): string { export interface ReviewFooterInfo { trigger: Trigger; runUrl: string; - /** e.g. `` `claude-fable-5` + `gpt-5.6-sol` ``. Passed in from the workflow's - * `CLAUDE_MODEL`/`CODEX_MODEL` env vars instead of being hardcoded here, so - * the model names have one source of truth. */ + /** e.g. `` `claude-fable-5` + `gpt-5.6-sol` ``. Sourced from the workflow's + * model env vars so there's one source of truth for the model names. */ modelsFooter: string; } @@ -768,10 +726,8 @@ export function buildReviewPayload( const partitioned = partitionFindings(review.findings, anchors); const comments = partitioned.anchorable.map((finding) => buildInlineComment(finding, anchors)); const body = renderReviewBody(review, partitioned, footer); - // A body-only review (many non-anchorable findings, few or no inline - // comments) has no fold-retry path to truncate it on a 422 — truncate the - // very first payload too, so an oversized body posts truncated instead of - // throwing when GitHub rejects it for exceeding the review body cap. + // A body-only review has no 422 fold-retry to truncate it later, so + // truncate the first payload too rather than posting an oversized body. return { event: "COMMENT", body: truncateReviewBody(body, footer.runUrl), comments }; } @@ -791,10 +747,7 @@ export function foldInlineCommentsIntoBody(payload: ReviewPayload): ReviewPayloa } /** Truncates a review body to GitHub's 65536-char review body cap, appending - * an explicit truncation marker + the workflow run URL. Applied to both the - * very first payload (`buildReviewPayload`) and the folded 422-retry body - * (every inline comment stuffed into one body), a no-op when the body is - * already under the cap. */ + * a truncation marker with the workflow run URL. No-op under the cap. */ export function truncateReviewBody(body: string, runUrl: string): string { if (body.length <= GITHUB_REVIEW_BODY_MAX) { return body; @@ -822,8 +775,6 @@ export function supersededBody(oldBody: string): string { ].join("\n"); } -// --- Injected GitHub I/O --- - export interface MarkedEntry { id: number; body: string; @@ -863,12 +814,10 @@ function isSupersedableAiEntry(entry: MarkedEntry): boolean { ); } -/** Snapshots the prior AI reviews/comments to supersede. MUST run before the - * new review is posted: the fresh review is itself a marker-bearing bot - * review, so a post-hoc listing would sweep it into its own supersede pass - * and every new review would immediately collapse as "superseded". - * Best-effort — a listing failure degrades to an empty snapshot (prior runs - * stay unwrapped) rather than costing the real review. */ +/** Snapshots the prior AI reviews/comments to supersede. Must run before the + * new review is posted, or a post-hoc listing would sweep the fresh review + * into its own supersede pass. Best-effort — a listing failure degrades to + * an empty snapshot rather than costing the real review. */ async function listPriorRunsBestEffort(io: ReviewIo, prNumber: number): Promise { try { const [reviews, comments] = await Promise.all([ @@ -886,9 +835,8 @@ async function listPriorRunsBestEffort(io: ReviewIo, prNumber: number): Promise< } /** Wraps the snapshotted prior AI reviews/comments in a superseded `
` - * block. Best-effort: a cosmetic failure here (e.g. a transient 404 on a - * review that was deleted mid-run) must never fail the pipeline after the - * real review has already been posted. */ + * block. Best-effort: a cosmetic failure here must never fail the pipeline + * after the real review has already been posted. */ async function supersedePriorRunsBestEffort( io: ReviewIo, prNumber: number, @@ -945,8 +893,6 @@ export async function postConsolidatedReview( await supersedePriorRunsBestEffort(io, prNumber, prior); } -// --- Real GitHub I/O (only runs when executed directly) --- - function requireEnv(name: string): string { const value = process.env[name]; if (!value) { @@ -995,11 +941,7 @@ function isRecordEntry(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } -/** - * The validated boundary between `Response.json()` (typed `Promise` - * under `@tsconfig/bun`) and this file's typed shapes: `assert` narrows the - * parsed value to `T` before any caller reads a field off it. - */ +/** Narrows `Response.json()`'s `unknown` result to `T` via `assert` before any caller reads a field off it. */ async function githubJson( response: Response, assert: (value: unknown) => asserts value is T, @@ -1132,9 +1074,7 @@ async function postReview( "application/vnd.github+json", [422], ); - // `githubFetch` only returns without throwing for a 2xx or the allowed - // 422; read the body for the 422 case too so a second failed retry can - // surface it instead of discarding it. + // Read the body for the 422 case too so a second failed retry can surface it. if (response.status === 422) { return { status: response.status, body: await response.text() }; } @@ -1172,9 +1112,6 @@ async function runPost(): Promise { const trigger = parseTrigger(requireEnv("TRIGGER")); const runUrl = requireEnv("RUN_URL"); const mergedReviewPath = requireEnv("MERGED_REVIEW_PATH"); - // Sourced from the workflow's top-level `env:` block (the same values fed - // to the `claude`/`codex-action` invocations), not hardcoded here, so the - // model names have one source of truth. const claudeModel = requireEnv("CLAUDE_MODEL"); const codexModel = requireEnv("CODEX_MODEL"); @@ -1189,8 +1126,7 @@ async function runPost(): Promise { console.log(`Posted AI review on PR #${prNumber} (${raw.findings.length} finding(s)).`); } -/** Reads a JSON file, redacts every string value in place through - * `redactSecretsDeep`, and writes it back — the `redact` subcommand's I/O. */ +/** Reads a JSON file, redacts every string value in place, and writes it back. */ async function runRedact(path: string): Promise { const raw: unknown = JSON.parse(await Bun.file(path).text()); const redacted = redactSecretsDeep(raw); diff --git a/.github/scripts/ai-review/resolve.test.ts b/.github/scripts/ai-review/resolve.test.ts index 0beb52747d..07453c1c32 100644 --- a/.github/scripts/ai-review/resolve.test.ts +++ b/.github/scripts/ai-review/resolve.test.ts @@ -361,8 +361,6 @@ describe("resolveDecision: issue_comment authorization", () => { io, ); expect(result.shouldRun).toBe(true); - // The effective permission is always resolved (only the write-permission - // requirement short-circuits for OWNER), so the lookup still happens. expect(permissionLookups).toEqual(["maintainer"]); expect(reactions).toEqual([555]); }); diff --git a/.github/scripts/ai-review/resolve.ts b/.github/scripts/ai-review/resolve.ts index 400d0e3973..58df368df1 100644 --- a/.github/scripts/ai-review/resolve.ts +++ b/.github/scripts/ai-review/resolve.ts @@ -1,25 +1,19 @@ /** * AI review resolver: decides whether the one-shot AI review pipeline should - * run for a PR. + * run for a PR. The pipeline runs once per PR, so this is the only gate + * between a new commit landing and Claude/Codex burning API budget again. * - * The pipeline runs EXACTLY ONCE per PR, so this is the only gate standing - * between "new commit lands" and "Claude + Codex burn API budget again". Two - * triggers feed it: - * - manual (`workflow_dispatch` or an internal maintainer's `/ai-review` - * issue comment): a human explicitly asked for a review, so the - * marker/dedup guard and the draft/fork/bot skips are bypassed. - * - auto (`pull_request` `opened`/`ready_for_review`): only PRs whose - * author has repository write access get the automatic review. Skips - * drafts, bots, fork PRs, authors without write access (external - * contributors go through the manual maintainer path), and PRs that - * already carry a marker comment/review from a prior run. + * Two triggers: + * - manual (`workflow_dispatch` or a maintainer's `/ai-review` issue + * comment): bypasses the marker/dedup guard and the draft/fork/bot skips. + * - auto (`pull_request` `opened`/`ready_for_review`): only for PRs whose + * author has repository write access; skips drafts, bots, fork PRs, and + * PRs that already carry a marker comment/review from a prior run. * - * `resolveDecision` is the pure orchestration function (I/O injected, like - * `evaluateAllOpenPrs` in `contribution-gate.ts`) that a test can drive - * without the network; `main()` wires up the real GitHub I/O, writes the - * step outputs `should_run`, `pr_number`, `head_ref`, and `trigger` to - * `$GITHUB_OUTPUT`, and surfaces the skip reason (if any) in - * `$GITHUB_STEP_SUMMARY`. + * `resolveDecision` is the pure orchestration function (I/O injected) a test + * can drive without the network; `main()` wires up the real GitHub I/O, + * writes `should_run`/`pr_number`/`head_ref`/`trigger` to `$GITHUB_OUTPUT`, + * and surfaces the skip reason (if any) in `$GITHUB_STEP_SUMMARY`. * * Run in CI as: `bun .github/scripts/ai-review/resolve.ts`. */ @@ -29,14 +23,11 @@ import { appendFileSync } from "node:fs"; import { fetchAuthorPermission, WRITE_PERMISSIONS } from "../contribution-gate.ts"; import { AI_REVIEW_MARKER } from "./post-review.ts"; -// Re-export so existing consumers (tests, this file's own dedup check) can -// keep importing the marker from `resolve.ts`; `post-review.ts` — which owns -// posting — is the single source of truth for the literal. +// `post-review.ts` owns the literal; re-exported here for convenience. export { AI_REVIEW_MARKER }; /** Login every review/comment posted by this workflow carries. Duplicated - * (not imported) from `post-review.ts`'s `WORKFLOW_BOT_LOGIN`; keep the two - * literals in sync. */ + * from `post-review.ts`'s `WORKFLOW_BOT_LOGIN`; keep the two in sync. */ const WORKFLOW_BOT_LOGIN = "github-actions[bot]"; export type EventName = "workflow_dispatch" | "issue_comment" | "pull_request"; @@ -96,11 +87,9 @@ export interface ResolveResult { trigger: Trigger; } -/** No size gate: Claude and Codex review agentically — reading the diff and the - * changed files via their own tools over many turns, like the local CLI — so a - * PR that clears the draft/bot/fork/dedup checks is reviewed regardless of its - * size. Very large diffs are handled best-effort within the model's - * context/turn budget. */ +/** No size gate: Claude and Codex review agentically over many turns, so any + * PR that clears the draft/bot/fork/dedup checks is reviewed regardless of + * size, best-effort within the model's context/turn budget. */ function decideForPr(trigger: Trigger): ResolveResult { return { shouldRun: true, trigger }; } @@ -128,9 +117,8 @@ export async function resolveDecision(input: ResolveInput, io: ResolveIo): Promi throw new Error("issue_comment trigger requires comment details"); } - // Authoritative command match: the workflow's job `if:` only - // pre-filters on `startsWith('/ai-review')`, so `/ai-reviewers` or - // `/ai-review-please` would otherwise also reach here. + // The workflow's job `if:` only pre-filters on `startsWith('/ai-review')`, + // so `/ai-reviewers` or `/ai-review-please` would otherwise also reach here. const firstLine = comment.body.split("\n")[0]?.trim() ?? ""; if (firstLine !== "/ai-review") { return { @@ -140,13 +128,9 @@ export async function resolveDecision(input: ResolveInput, io: ResolveIo): Promi }; } - // Authoritative authorization: always resolve the commenter's - // effective repository permission and require write/admin. Only the - // repository OWNER may short-circuit that requirement — any other - // association (including MEMBER/COLLABORATOR, which merely mean "in - // the org"/"added as a collaborator", not necessarily push-capable) - // must pass the permission check. Mirrors `contribution-gate.ts`'s - // `WRITE_PERMISSIONS`. + // Only the repository OWNER short-circuits the permission check; any + // other association (MEMBER/COLLABORATOR only mean "in the org"/"added + // as a collaborator", not necessarily push-capable) must pass it. const permission = await io.fetchPermission(comment.authorLogin); const authorized = comment.authorAssociation === "OWNER" || @@ -175,8 +159,6 @@ export async function resolveDecision(input: ResolveInput, io: ResolveIo): Promi return decideForPr(trigger); } - // Auto trigger (`pull_request` events): internal PRs only, fires at most - // once per PR. if (pr.draft) { return { shouldRun: false, skipReason: "PR is a draft.", trigger }; } @@ -191,13 +173,10 @@ export async function resolveDecision(input: ResolveInput, io: ResolveIo): Promi }; } - // Authoritative auto-trigger authorization: only PRs authored by someone - // with effective repository write access are reviewed automatically. This - // is the actual author check, not defense-in-depth — a same-repo head - // branch only proves the branch exists in this repo, not that the AUTHOR - // pushed it (a PR can be opened from a branch someone else pushed). An - // unresolvable permission counts as unauthorized. Mirrors the manual - // path's gate above and `contribution-gate.ts`'s `WRITE_PERMISSIONS`. + // A same-repo head branch only proves the branch exists in this repo, not + // that the author pushed it, so this checks the author's own permission + // rather than trusting the branch's origin. An unresolvable permission + // counts as unauthorized. const authorPermission = await io.fetchPermission(pr.authorLogin); if (authorPermission === undefined || !WRITE_PERMISSIONS.has(authorPermission)) { return { @@ -214,9 +193,8 @@ export async function resolveDecision(input: ResolveInput, io: ResolveIo): Promi io.listReviews(pr.number), io.listIssueComments(pr.number), ]); - // Only a marker posted BY the workflow bot counts — otherwise anyone could - // paste the (invisible) marker into a comment to permanently suppress the - // auto review of their own PR. + // Only a marker posted by the workflow bot counts, or anyone could paste + // the invisible marker into a comment to suppress their own PR's review. const alreadyReviewed = [...reviews, ...comments].some( (entry) => entry.authorLogin === WORKFLOW_BOT_LOGIN && entry.body.includes(AI_REVIEW_MARKER), ); @@ -231,8 +209,6 @@ export async function resolveDecision(input: ResolveInput, io: ResolveIo): Promi return decideForPr(trigger); } -// --- GitHub I/O (only runs when executed directly) --- - function requireEnv(name: string): string { const value = process.env[name]; if (!value) { @@ -275,11 +251,7 @@ function isRecordEntry(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } -/** - * The validated boundary between `Response.json()` (typed `Promise` - * under `@tsconfig/bun`) and this file's typed shapes: `assert` narrows the - * parsed value to `T` before any caller reads a field off it. - */ +/** Narrows `Response.json()`'s `unknown` result to `T` via `assert` before any caller reads a field off it. */ async function githubJson( response: Response, assert: (value: unknown) => asserts value is T, @@ -389,10 +361,8 @@ async function reactToComment(token: string, base: string, commentId: number): P }); } -/** Writes each `$GITHUB_OUTPUT` value using the heredoc/delimiter form (with - * a random delimiter per line) rather than `name=value`, defensively — none - * of today's values can contain a newline, but a future value shouldn't be - * able to inject extra output lines either. */ +/** Writes each `$GITHUB_OUTPUT` value with a random per-line delimiter rather + * than `name=value`, so a future multi-line value can't inject extra output lines. */ function writeOutputs(result: ResolveResult, prNumber: number): void { const outputFile = requireEnv("GITHUB_OUTPUT"); const entries: Record = { @@ -410,9 +380,7 @@ function writeOutputs(result: ResolveResult, prNumber: number): void { appendFileSync(outputFile, `${lines.join("\n")}\n`); } -/** Surfaces the skip reason (if any) in the job's step summary — the only - * place it's actually read; it's not exposed as a job `outputs:` because - * nothing downstream consumes it there. */ +/** Surfaces the skip reason (if any) in the job's step summary. */ function writeStepSummary(result: ResolveResult): void { if (!result.skipReason) { return; diff --git a/.github/scripts/contribution-gate.test.ts b/.github/scripts/contribution-gate.test.ts index 7951de817a..6c8ed6a544 100644 --- a/.github/scripts/contribution-gate.test.ts +++ b/.github/scripts/contribution-gate.test.ts @@ -21,9 +21,6 @@ describe("isInternalAuthor", () => { ); test.each(["admin", "write"])("treats push-capable permission %s as internal", (permission) => { - // A private org member surfaces only as CONTRIBUTOR/NONE via - // author_association, but their effective repo permission gives them - // away as a maintainer. expect(isInternalAuthor("CONTRIBUTOR", permission)).toBe(true); expect(isInternalAuthor("NONE", permission)).toBe(true); }); @@ -130,8 +127,6 @@ describe("evaluateGate", () => { }); test("ignores an open, labeled issue from a different repository", () => { - // Cross-repo closing keyword (e.g. `Closes attacker/repo#1`): the issue is - // controlled by the contributor, so it must not satisfy the gate. const result = evaluateGate({ repository: REPO, isInternal: false, @@ -231,7 +226,6 @@ describe("evaluateAllOpenPrs", () => { expect(byNumber[6]?.pass).toBe(true); expect(byNumber[6]?.reason).toBe("internal"); expect(closed.find((c) => c.number === 1)?.message).toContain(GATE_LABEL); - // Bots and public members are settled without a permission lookup. expect(permissionLookups).not.toContain("maint"); expect(permissionLookups).not.toContain("dependabot"); }); @@ -275,9 +269,6 @@ describe("fetchAuthorPermission", () => { }); test("maps a 404 (non-collaborator fork author) to undefined", async () => { - // The endpoint 404s for users who are not collaborators — the common case - // for the external contributors the gate targets. It must map to external, - // not abort the run. stubFetch(404, { message: "Not Found" }); const permission = await fetchAuthorPermission("t", "supabase", "cli", "ext"); expect(permission).toBeUndefined(); diff --git a/.github/scripts/contribution-gate.ts b/.github/scripts/contribution-gate.ts index 061fc7107b..5d2666446d 100644 --- a/.github/scripts/contribution-gate.ts +++ b/.github/scripts/contribution-gate.ts @@ -1,13 +1,11 @@ /** - * Contribution gate: enforces the Supabase CLI contribution workflow across all - * OPEN pull requests opened by external contributors. + * Contribution gate: enforces the Supabase CLI contribution workflow across + * every open pull request opened by external contributors. * - * A PR passes only when it links to an OPEN GitHub issue that carries the - * `open-for-contribution` label. Maintainers (recognised by their effective - * repository permission, so private org members count too — not just the - * `author_association` GitHub exposes for public members) and bots are exempt - * (they work from Linear tickets or automation). PRs that do not follow the - * process are commented on and closed. + * A PR passes only when it links to an open GitHub issue that carries the + * `open-for-contribution` label. Maintainers (recognized by their effective + * repository permission, so private org members count too) and bots are + * exempt. PRs that don't follow the process are commented on and closed. * * Two modes, both driven from `main()`: * - single-PR (default): reacts to one PR on `pull_request_target` @@ -16,13 +14,10 @@ * open PR, for on-demand `workflow_dispatch` runs. Set `DRY_RUN=true` to * log decisions without commenting/closing. * - * In both modes the workflow checks out the base branch, so this only ever - * executes trusted repository code — it never runs a fork's code. + * The workflow checks out the base branch in both modes, so this only ever + * executes trusted repository code. * * Run in CI as: `bun .github/scripts/contribution-gate.ts`. - * The pure `evaluateGate` decision and the `evaluateAllOpenPrs` orchestrator - * (I/O injected) are unit-tested in `contribution-gate.test.ts`; `main()` wires - * up the real GitHub I/O. */ export const GATE_LABEL = "open-for-contribution"; @@ -30,25 +25,19 @@ export const GATE_LABEL = "open-for-contribution"; /** * Author associations treated as internal (exempt from the gate). * - * NOTE: `author_association` only reports `MEMBER` when a user's organization - * membership is *public*. A private org member (or a team member who keeps - * their membership private) is reported as `CONTRIBUTOR`/`NONE`, so this set - * alone is not enough to identify internal maintainers — see - * `isInternalAuthor`, which also consults the author's effective repository - * permission. + * `author_association` only reports `MEMBER` for a *public* org membership; a + * private member is reported as `CONTRIBUTOR`/`NONE`, so this set alone can't + * identify every internal maintainer — see `isInternalAuthor`. */ export const INTERNAL_ASSOCIATIONS = new Set(["OWNER", "MEMBER", "COLLABORATOR"]); /** - * Effective repository permissions that mark an author as internal. A user who - * can push to the repository (directly, or via a team/org grant that - * `author_association` does not surface) is a trusted maintainer, not an - * external contributor. The legacy REST `permission` field collapses the - * `maintain` role to `write`, so `admin`/`write` covers every push-capable - * role. + * Effective repository permissions that mark an author as internal: anyone + * who can push, directly or via a team/org grant `author_association` won't + * surface. The legacy REST `permission` field collapses `maintain` into + * `write`, so `admin`/`write` covers every push-capable role. * - * Exported for `resolve.ts`, which requires the same write-permission bar to - * authorize a `/ai-review` command. + * Exported for `resolve.ts`, which uses the same bar to authorize `/ai-review`. */ export const WRITE_PERMISSIONS = new Set(["admin", "write"]); @@ -147,9 +136,8 @@ export function evaluateGate(input: GateInput): GateResult { return { pass: true, reason: "internal" }; } - // Only issues in THIS repository count. A cross-repository closing keyword - // (e.g. `Closes attacker/repo#1`) links an issue the contributor controls, - // so it must never satisfy the gate. + // Only issues in this repository count; a cross-repository closing keyword + // (e.g. `Closes attacker/repo#1`) links an issue the contributor controls. const repo = input.repository.toLowerCase(); const repoIssues = input.linkedIssues.filter((issue) => issue.repository.toLowerCase() === repo); @@ -234,8 +222,6 @@ export async function evaluateAllOpenPrs(io: GateIo, repository: string): Promis return entries; } -// --- GitHub I/O (only runs when executed directly) --- - interface GraphQLIssueNode { number: number; state: "OPEN" | "CLOSED"; @@ -358,16 +344,11 @@ async function fetchOpenPullRequests( } /** - * Resolve an author's effective permission on the repository. Reflects access - * granted directly or through a team/org membership, so it recognises private - * org members that `author_association` reports only as `CONTRIBUTOR`. This - * endpoint needs just `Metadata: read` (covered by the workflow's - * `contents: read`). - * - * A 404 means the author is not a collaborator at all — the common case for - * external fork contributors, who are exactly who the gate targets — so it maps - * to `undefined` (external) rather than throwing. Other failures still throw so - * a transient API error aborts the run without wrongly closing PRs. + * Resolves an author's effective permission on the repository, recognizing + * private org members that `author_association` reports only as + * `CONTRIBUTOR`. A 404 means the author isn't a collaborator at all — the + * common case for external fork contributors — so it maps to `undefined` + * rather than throwing; other failures still throw. */ export async function fetchAuthorPermission( token: string, @@ -426,9 +407,8 @@ async function runSinglePr( const authorLogin = process.env.PR_AUTHOR_LOGIN ?? ""; const isBot = (process.env.PR_AUTHOR_TYPE ?? "User") === "Bot"; - // Resolve maintainer status from the effective repository permission unless a - // cheaper signal already settles it. `author_association` only exposes public - // org membership, so a private member must be confirmed via permission. + // `author_association` only exposes public org membership, so a private + // member must be confirmed via the effective repository permission. let permission: string | undefined; let isInternal = isBot || INTERNAL_ASSOCIATIONS.has(authorAssociation); if (!isInternal) { diff --git a/.github/scripts/sync-workload-catalog.test.ts b/.github/scripts/sync-workload-catalog.test.ts new file mode 100644 index 0000000000..4e362c4f35 --- /dev/null +++ b/.github/scripts/sync-workload-catalog.test.ts @@ -0,0 +1,293 @@ +import { describe, expect, test } from "bun:test"; + +import { + CATALOG_PATH, + InvalidPayloadError, + planCatalogUpdate, + releaseLine, + validatePayload, +} from "./sync-workload-catalog.ts"; + +const DIGEST_A = `sha256:${"a".repeat(64)}`; +const DIGEST_B = `sha256:${"b".repeat(64)}`; + +/** The real catalog, so a shape drift fails here instead of passing on a stale fixture. */ +const realCatalog = await Bun.file(CATALOG_PATH).text(); + +/** A trimmed catalog carrying both entry shapes the script has to rewrite. */ +const fixture = `const workloadCatalog = { + "database:database": native( + "postgres", + "17.6.1.168", + "ghcr.io/supabase/cli/postgres:17.6.1.168@sha256:${"9".repeat(64)}", + "bin/supabase-postgres-start", + ["bin/supabase-postgres-start"], + { + additionalReleases: { + "15.14.1.168": + "ghcr.io/supabase/cli/postgres:15.14.1.168@sha256:${"f".repeat(64)}", + }, + containerAlias: "supabase-database", + }, + ), + "rest:rest": native( + "postgrest", + "v16.2", + "ghcr.io/supabase/cli/postgrest:v16.2", + "bin/postgrest", + ["bin/postgrest"], + { containerAlias: "supabase-rest" }, + ), + "auth:auth": native("auth", "v2.196.0", "ghcr.io/supabase/cli/auth:v2.196.0", "bin/auth", [ + "bin/auth", + ]), + "studio:studio": native( + "studio", + "2026.09.04-sha-5a67366", + "ghcr.io/supabase/cli/studio:2026.09.04-sha-5a67366@sha256:${"c".repeat(64)}", + "bin/studio", + ["bin/studio"], + ), +} satisfies Readonly>; +`; + +describe("releaseLine", () => { + test.each([ + ["17.6.1.168", "17"], + ["15.14.1.168", "15"], + ["v2.196.0", "2"], + ["v16.2", "16"], + ["2026.09.04-sha-5a67366", "2026"], + ["0.53.0", "0"], + ])("reads %s as line %s", (version, expected) => { + expect(releaseLine(version)).toBe(expected); + }); +}); + +describe("validatePayload", () => { + test("rejects a version that would escape the string literal", () => { + expect(() => + validatePayload({ service: "auth", version: 'v1",\n "pwned', digest: DIGEST_A }), + ).toThrow(InvalidPayloadError); + }); + + test("rejects a non-sha256 digest", () => { + expect(() => validatePayload({ service: "auth", version: "v1.0.0", digest: "latest" })).toThrow( + InvalidPayloadError, + ); + }); + + test("rejects an uppercase or path-traversing service name", () => { + expect(() => + validatePayload({ service: "../../etc", version: "v1.0.0", digest: DIGEST_A }), + ).toThrow(InvalidPayloadError); + expect(() => validatePayload({ service: "Auth", version: "v1.0.0", digest: DIGEST_A })).toThrow( + InvalidPayloadError, + ); + }); +}); + +describe("planCatalogUpdate", () => { + test("bumps a single-line service and pins the digest", () => { + const plan = planCatalogUpdate({ + source: fixture, + service: "auth", + version: "v2.197.0", + digest: DIGEST_A, + }); + + expect(plan.kind).toBe("updated"); + if (plan.kind !== "updated") return; + expect(plan.previousVersion).toBe("v2.196.0"); + expect(plan.target).toBe("default"); + expect(plan.source).toContain( + `native("auth", "v2.197.0", "ghcr.io/supabase/cli/auth:v2.197.0@${DIGEST_A}", "bin/auth"`, + ); + expect(plan.source).not.toContain("v2.196.0"); + }); + + test("adds a digest to a previously tag-only pin", () => { + const plan = planCatalogUpdate({ + source: fixture, + service: "postgrest", + version: "v16.2", + digest: DIGEST_A, + }); + + expect(plan.kind).toBe("updated"); + if (plan.kind !== "updated") return; + expect(plan.source).toContain(`"ghcr.io/supabase/cli/postgrest:v16.2@${DIGEST_A}"`); + }); + + test("bumps a date-versioned service across a year boundary", () => { + const plan = planCatalogUpdate({ + source: fixture, + service: "studio", + version: "2027.01.02-sha-abc1234", + digest: DIGEST_A, + }); + + expect(plan.kind).toBe("updated"); + if (plan.kind !== "updated") return; + expect(plan.source).toContain( + `"ghcr.io/supabase/cli/studio:2027.01.02-sha-abc1234@${DIGEST_A}"`, + ); + }); + + test("a 17.x postgres release moves the default and leaves the 15.x line alone", () => { + const plan = planCatalogUpdate({ + source: fixture, + service: "postgres", + version: "17.6.1.169", + digest: DIGEST_A, + }); + + expect(plan.kind).toBe("updated"); + if (plan.kind !== "updated") return; + expect(plan.previousVersion).toBe("17.6.1.168"); + expect(plan.target).toBe("default"); + expect(plan.source).toContain(`"postgres",\n "17.6.1.169",`); + expect(plan.source).toContain(`"ghcr.io/supabase/cli/postgres:17.6.1.169@${DIGEST_A}"`); + expect(plan.source).toContain(`"15.14.1.168":`); + expect(plan.source).toContain(`sha256:${"f".repeat(64)}`); + }); + + test("a 15.x postgres release moves the additional line, never the 17.x default", () => { + const plan = planCatalogUpdate({ + source: fixture, + service: "postgres", + version: "15.14.1.169", + digest: DIGEST_B, + }); + + expect(plan.kind).toBe("updated"); + if (plan.kind !== "updated") return; + expect(plan.previousVersion).toBe("15.14.1.168"); + expect(plan.target).toBe("additional"); + expect(plan.source).toContain(`"15.14.1.169":`); + expect(plan.source).toContain(`"ghcr.io/supabase/cli/postgres:15.14.1.169@${DIGEST_B}"`); + expect(plan.source).toContain(`"postgres",\n "17.6.1.168",`); + expect(plan.source).toContain(`sha256:${"9".repeat(64)}`); + }); + + test("skips a postgres release line the catalog does not carry", () => { + const plan = planCatalogUpdate({ + source: fixture, + service: "postgres", + version: "16.4.1.001", + digest: DIGEST_A, + }); + + expect(plan.kind).toBe("unmodelled-release-line"); + if (plan.kind !== "unmodelled-release-line") return; + expect(plan.known).toEqual(["17.6.1.168", "15.14.1.168"]); + }); + + test("re-dispatching the same release is a no-op", () => { + const first = planCatalogUpdate({ + source: fixture, + service: "auth", + version: "v2.197.0", + digest: DIGEST_A, + }); + expect(first.kind).toBe("updated"); + if (first.kind !== "updated") return; + + expect( + planCatalogUpdate({ + source: first.source, + service: "auth", + version: "v2.197.0", + digest: DIGEST_A, + }).kind, + ).toBe("unchanged"); + }); + + test("a digest change on the same version still syncs", () => { + const plan = planCatalogUpdate({ + source: fixture, + service: "auth", + version: "v2.196.0", + digest: DIGEST_A, + }); + + expect(plan.kind).toBe("updated"); + if (plan.kind !== "updated") return; + expect(plan.source).toContain(`auth:v2.196.0@${DIGEST_A}`); + }); + + test("reports a service the catalog does not model", () => { + expect( + planCatalogUpdate({ + source: fixture, + service: "kong", + version: "v3.0.0", + digest: DIGEST_A, + }).kind, + ).toBe("unmodelled-service"); + }); + + test("does not confuse postgres with postgrest", () => { + const plan = planCatalogUpdate({ + source: fixture, + service: "postgrest", + version: "v17.0", + digest: DIGEST_A, + }); + + expect(plan.kind).toBe("updated"); + if (plan.kind !== "updated") return; + expect(plan.previousVersion).toBe("v16.2"); + expect(plan.source).toContain(`"17.6.1.168",`); + expect(plan.source).toContain(`"15.14.1.168":`); + }); + + test("rejects an invalid payload instead of rewriting the catalog", () => { + expect(() => + planCatalogUpdate({ + source: fixture, + service: "auth", + version: "v1.0.0", + digest: "sha256:not-a-digest", + }), + ).toThrow(InvalidPayloadError); + }); +}); + +describe("against the real catalog", () => { + test("every modelled service is addressable and idempotent", () => { + // Derived from the catalog, so a new workload is covered without editing this. + const services = [ + ...new Set( + [...realCatalog.matchAll(/native\(\s*"([a-z][a-z0-9-]*)",/g)].map( + (match) => match[1] ?? "", + ), + ), + ]; + expect(services.length).toBeGreaterThan(10); + + for (const service of services) { + const bumped = planCatalogUpdate({ + source: realCatalog, + service, + version: "99.99.99", + digest: DIGEST_A, + }); + // 99.x is a line no service carries: multi-line postgres skips, single-line services bump. + expect( + bumped.kind === "updated" || bumped.kind === "unmodelled-release-line", + `${service} was not addressable in the real catalog`, + ).toBe(true); + + if (bumped.kind !== "updated") continue; + expect( + planCatalogUpdate({ + source: bumped.source, + service, + version: "99.99.99", + digest: DIGEST_A, + }).kind, + ).toBe("unchanged"); + } + }); +}); diff --git a/.github/scripts/sync-workload-catalog.ts b/.github/scripts/sync-workload-catalog.ts new file mode 100644 index 0000000000..cb00c23bb7 --- /dev/null +++ b/.github/scripts/sync-workload-catalog.ts @@ -0,0 +1,201 @@ +/** + * Pins one workload in `packages/stack/src/model/WorkloadCatalog.ts` to a + * slim-services release, driven by the same `mirror-slim-image` dispatch as the + * ECR mirror (`mirror-slim-image.yml`). + * + * Dependabot owns the Dockerfile and cannot own this table: these pins carry + * image digests, which tag resolution never produces (ADR 0017). The dispatch + * payload is untrusted and revalidated here — those patterns are what keep + * `version`/`digest` inside the string literals they are written into. + * + * Run: `bun .github/scripts/sync-workload-catalog.ts` with SLIM_SERVICE, + * SLIM_VERSION, SLIM_DIGEST. Exit 1 on an invalid payload; an unmodelled + * service or release line is a successful no-op. + */ + +export const CATALOG_PATH = "packages/stack/src/model/WorkloadCatalog.ts"; + +/** Mirrors the payload validation in `mirror-slim-image.yml`. */ +const SERVICE_PATTERN = /^[a-z][a-z0-9-]*$/; +const VERSION_PATTERN = /^[A-Za-z0-9._-]+$/; +const DIGEST_PATTERN = /^sha256:[0-9a-f]{64}$/; + +const SLIM_IMAGE_PREFIX = "ghcr.io/supabase/cli/"; + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +/** Leading numeric component, `v` stripped. Only postgres carries >1 line. */ +export function releaseLine(version: string): string { + const withoutPrefix = version.replace(/^[vV]/, ""); + const separator = withoutPrefix.indexOf("."); + return separator === -1 ? withoutPrefix : withoutPrefix.slice(0, separator); +} + +export interface CatalogUpdateInput { + readonly source: string; + readonly service: string; + readonly version: string; + readonly digest: string; +} + +export type CatalogUpdatePlan = + | { + readonly kind: "updated"; + readonly source: string; + readonly previousVersion: string; + /** `default` bumped the entry's defaultVersion; `additional` bumped one of its extra lines. */ + readonly target: "default" | "additional"; + } + | { readonly kind: "unchanged" } + | { readonly kind: "unmodelled-service" } + | { readonly kind: "unmodelled-release-line"; readonly known: ReadonlyArray }; + +export class InvalidPayloadError extends Error {} + +export function validatePayload(input: { + readonly service: string; + readonly version: string; + readonly digest: string; +}): void { + if (!SERVICE_PATTERN.test(input.service)) { + throw new InvalidPayloadError(`invalid service name: '${input.service}'`); + } + if (!VERSION_PATTERN.test(input.version)) { + throw new InvalidPayloadError(`invalid version: '${input.version}'`); + } + if (!DIGEST_PATTERN.test(input.digest)) { + throw new InvalidPayloadError(`invalid digest: '${input.digest}'`); + } +} + +/** `native("", "", ""` — image anchored so postgres != postgrest. */ +function defaultEntryPattern(service: string): RegExp { + const s = escapeRegExp(service); + return new RegExp( + `(native\\(\\s*"${s}",\\s*")([^"]+)("\\s*,\\s*")(${escapeRegExp(SLIM_IMAGE_PREFIX)}${s}:[^"]+)(")`, + ); +} + +/** `additionalReleases` entries: `"": ""`. The `:` is what distinguishes them. */ +function additionalEntryPattern(service: string, version?: string): RegExp { + const s = escapeRegExp(service); + const key = version === undefined ? `[^"]+` : escapeRegExp(version); + // Groups: 1 key, 2 separator (kept, to preserve wrapping), 3 image. + return new RegExp( + `"(${key})"(\\s*:\\s*)"(${escapeRegExp(SLIM_IMAGE_PREFIX)}${s}:[^"]+)"`, + version === undefined ? "g" : "", + ); +} + +function slimImageRef(service: string, version: string, digest: string): string { + return `${SLIM_IMAGE_PREFIX}${service}:${version}@${digest}`; +} + +/** Rewrites `service`'s entry onto `version`/`digest`, or says why there was nothing to do. */ +export function planCatalogUpdate(input: CatalogUpdateInput): CatalogUpdatePlan { + validatePayload(input); + const { source, service, version, digest } = input; + + const defaultMatch = defaultEntryPattern(service).exec(source); + if (defaultMatch === null) { + return { kind: "unmodelled-service" }; + } + + const currentDefaultVersion = defaultMatch[2] ?? ""; + const currentDefaultImage = defaultMatch[4] ?? ""; + const desiredImage = slimImageRef(service, version, digest); + + const additional = [...source.matchAll(additionalEntryPattern(service))].map((match) => ({ + version: match[1] ?? "", + image: match[3] ?? "", + })); + + // One line: always the default. Several (postgres): the release line picks, + // so a 15.x release can never overwrite the 17.x default. + const bumpsDefault = + additional.length === 0 || releaseLine(version) === releaseLine(currentDefaultVersion); + + if (bumpsDefault) { + if (currentDefaultVersion === version && currentDefaultImage === desiredImage) { + return { kind: "unchanged" }; + } + return { + kind: "updated", + source: source.replace( + defaultEntryPattern(service), + (_full, prefix: string, _version: string, mid: string, _image: string, suffix: string) => + `${prefix}${version}${mid}${desiredImage}${suffix}`, + ), + previousVersion: currentDefaultVersion, + target: "default", + }; + } + + const sameLine = additional.find((entry) => releaseLine(entry.version) === releaseLine(version)); + if (sameLine === undefined) { + return { + kind: "unmodelled-release-line", + known: [currentDefaultVersion, ...additional.map((entry) => entry.version)], + }; + } + + if (sameLine.version === version && sameLine.image === desiredImage) { + return { kind: "unchanged" }; + } + + return { + kind: "updated", + source: source.replace( + additionalEntryPattern(service, sameLine.version), + (_full, _key: string, separator: string) => `"${version}"${separator}"${desiredImage}"`, + ), + previousVersion: sameLine.version, + target: "additional", + }; +} + +function requireEnv(name: string): string { + const value = process.env[name]; + if (value === undefined || value.trim() === "") { + throw new InvalidPayloadError(`missing required environment variable: ${name}`); + } + return value.trim(); +} + +async function main(): Promise { + const service = requireEnv("SLIM_SERVICE"); + const version = requireEnv("SLIM_VERSION"); + const digest = requireEnv("SLIM_DIGEST"); + + const source = await Bun.file(CATALOG_PATH).text(); + const plan = planCatalogUpdate({ source, service, version, digest }); + + switch (plan.kind) { + case "unmodelled-service": + console.log(`::notice ::${CATALOG_PATH} models no '${service}' workload; nothing to sync.`); + return; + case "unmodelled-release-line": + console.log( + `::notice ::${service} ${version} is not on a release line ${CATALOG_PATH} carries (${plan.known.join(", ")}); nothing to sync.`, + ); + return; + case "unchanged": + console.log(`::notice ::${service} is already pinned to ${version} at ${digest}.`); + return; + case "updated": + await Bun.write(CATALOG_PATH, plan.source); + console.log( + `Updated ${service} ${plan.target} release ${plan.previousVersion} -> ${version} (${digest}).`, + ); + return; + } +} + +if (import.meta.main) { + main().catch((error: unknown) => { + console.log(`::error ::${error instanceof Error ? error.message : String(error)}`); + process.exit(1); + }); +} diff --git a/.github/workflows/ai-review.yml b/.github/workflows/ai-review.yml index 879b8fcf1f..a386875f9d 100644 --- a/.github/workflows/ai-review.yml +++ b/.github/workflows/ai-review.yml @@ -1,17 +1,12 @@ name: AI Review -# One-shot AI code review: replaces the Codex GitHub App's automatic -# per-push reviews (which churned 30-40 short rounds per PR) with a single -# exhaustive pass that runs at most once per PR. See -# .github/ai-review/README.md for the full design and security model. +# One-shot AI code review: replaces the Codex GitHub App's automatic per-push reviews with a +# single exhaustive pass that runs at most once per PR. See .github/ai-review/README.md for the +# full design and security model. # -# Three ways to trigger a run: -# - workflow_dispatch, for testing / ad-hoc runs against any PR number. -# - an internal maintainer commenting `/ai-review` on a PR. -# - automatically, when a PR opens or leaves draft. resolve.ts gates the -# automatic path to PR authors with repository write access; external -# contributors' PRs are skipped and go through the manual `/ai-review` -# maintainer path instead. +# Triggered by workflow_dispatch (testing/ad-hoc), an internal maintainer commenting +# `/ai-review`, or automatically when a PR opens or leaves draft — resolve.ts gates the automatic +# path to PR authors with write access; external contributors go through the manual path instead. on: workflow_dispatch: inputs: @@ -29,30 +24,21 @@ on: permissions: {} -# One source of truth for the two model names — `resolve`/`claude-review`/ -# `codex-review` all read these instead of hardcoding them a second and -# third time, and `post-review`'s footer reads them too (see the "Post -# review" step below). +# One source of truth for the model names; resolve, claude-review, codex-review, and +# post-review's footer all read these instead of hardcoding them separately. env: CLAUDE_MODEL: claude-opus-5 CODEX_MODEL: gpt-5.6-sol -# Ordinary (non-command) issue_comment events fire this workflow for EVERY -# comment on EVERY PR; with only the PR number in the group, any comment -# (even one that isn't `/ai-review`) would cancel an in-flight review via -# `cancel-in-progress`. Give those runs their own per-run group so they can -# never cancel a real review. The command test is exact equality -# (`!= '/ai-review'`), mirroring resolve.ts's first-line check — `startsWith` -# would let a near-miss like `/ai-reviewers` (which resolve.ts rejects) land -# in a shared group and cancel a running review anyway. +# Non-command issue_comment events fire for every comment on every PR, so grouping by PR number +# alone would let any comment cancel an in-flight review; give those runs their own per-run +# group instead. The command check is exact equality (mirroring resolve.ts), so a near-miss like +# `/ai-reviewers` can't land in the shared group and cancel a real review. # -# `pull_request` events get their own per-PR `auto` group, separate from the -# manual (`/ai-review` / dispatch) `review` group: an auto event may well -# resolve to a SKIP (dedup, no write access), and letting it share the manual -# group would let e.g. a ready_for_review event cancel an in-flight -# maintainer-requested review and then not replace it. The cost is that an -# auto and a manual run can overlap on the same PR — rare, and self-healing, -# since the later post supersedes the earlier review. +# `pull_request` events get a separate per-PR `auto` group from the manual `review` group, since +# an auto event may resolve to a skip and would otherwise cancel an in-flight maintainer-requested +# review without replacing it. An auto and manual run can overlap on the same PR as a result — +# rare, and self-healing since the later post supersedes the earlier one. concurrency: group: >- ai-review-${{ github.event.pull_request.number || github.event.issue.number || inputs.pr }}-${{ @@ -66,21 +52,17 @@ jobs: name: Resolve runs-on: ubuntu-latest timeout-minutes: 5 - # For issue_comment events, only PR comments starting with /ai-review - # AND carrying an association that could plausibly be a maintainer reach - # this job at all. This is a cheap, non-authoritative pre-filter - # (defense-in-depth only): it can't see a private org member's real - # permission, so it can under-admit. The authoritative checks — the - # EXACT command match and the effective-permission lookup — happen in - # resolve.ts, which is the actual gate. + # Only PR comments starting with /ai-review, from an association that could plausibly be a + # maintainer, reach this job — a cheap, non-authoritative pre-filter (it can't see a private + # org member's real permission, so it can under-admit). The authoritative checks — exact + # command match and effective-permission lookup — happen in resolve.ts. if: > github.event_name != 'issue_comment' || (github.event.issue.pull_request != null && startsWith(github.event.comment.body, '/ai-review') && contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association)) - # `resolve` reacts 👀 to the triggering comment (pull-requests: write) but - # runs ONLY trusted, default-branch code (see the pinned checkout ref - # below) — never a PR's own code — so granting it write is safe. + # Reacts 👀 to the triggering comment (pull-requests: write) but runs only trusted, + # default-branch code (see the pinned checkout ref below), never a PR's own — safe to grant write. permissions: pull-requests: write contents: read @@ -90,9 +72,8 @@ jobs: head_ref: ${{ steps.resolve.outputs.head_ref }} trigger: ${{ steps.resolve.outputs.trigger }} steps: - # Base repo, default ref, pinned explicitly — this job runs trusted - # repository code exclusively, and must keep doing so even though the - # `pull_request` trigger above hands it PR-authored event payloads. + # Pinned to the default ref explicitly — this job must keep running only trusted repo code, + # even though the pull_request trigger above hands it PR-authored event payloads. - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ github.event.repository.default_branch }} @@ -119,14 +100,11 @@ jobs: if: needs.resolve.outputs.should_run == 'true' runs-on: ubuntu-latest timeout-minutes: 60 - # SECURITY-CRITICAL: this job checks out the PR's own head commit, which - # is untrusted review subject matter, not something this job trusts with - # more access. Nothing this job EXECUTES may come from that checkout: - # prompts, the findings schema, and the validation script are all read - # from a SEPARATE trusted checkout of the default branch (`path: trusted` - # below). The job holds no write permissions, a read-only Claude tool - # allowlist (no write/edit tools, no Bash), and no secrets beyond - # ANTHROPIC_API_KEY. + # Security-critical: this job checks out the PR's own head commit as untrusted review subject + # matter only — nothing it executes may come from that checkout. Prompts, the findings schema, + # and the validation script are all read from a separate trusted default-branch checkout + # (`path: trusted` below). The job holds no write permissions, a read-only Claude tool + # allowlist (no write/edit tools, no Bash), and no secrets beyond ANTHROPIC_API_KEY. permissions: contents: read pull-requests: read @@ -169,11 +147,10 @@ jobs: base_ref=$(jq -r '.baseRefName' /tmp/ai-review/pr.json) bun .github/scripts/ai-review/generate-pr-diff.ts "$PR" "$base_ref" - # Pin the exact published version so a new Claude Code release can't - # silently change review behavior mid-rollout; bump deliberately. - # Install from the TRUSTED checkout with npm config isolation so a - # PR-supplied `.npmrc`/`.npmrc`-adjacent config in the untrusted `pr` - # checkout can never redirect this install to a hostile registry. + # Pin the exact published version so a Claude Code release can't silently change review + # behavior mid-rollout. Installed from the trusted checkout with npm config isolation so a + # PR-supplied `.npmrc` in the untrusted `pr` checkout can never redirect this install to a + # hostile registry. - name: Install Claude Code CLI working-directory: trusted run: | @@ -187,18 +164,13 @@ jobs: --globalconfig "${RUNNER_TEMP}/ai-review-npmrc-global" \ --registry=https://registry.npmjs.org/ @anthropic-ai/claude-code@2.1.247 - # SECURITY-CRITICAL invariant: PR code is only ever READ by `claude`, - # via the `( cd .../pr && claude ... )` subshell below — nothing else in - # this step, and no `bun` process anywhere in this job, ever runs with - # a cwd inside `pr`. `bun` auto-loads `bunfig.toml` (`preload` runs - # arbitrary code) and `.env` from its cwd; a `pr`-cwd `bun` invocation - # would let a PR-authored `pr/bunfig.toml` execute attacker code in a - # step that holds `ANTHROPIC_API_KEY`. `claude` is a standalone binary - # (not run via `bun`), so `bunfig.toml` never applies to it; `--bare` - # already disables hooks/MCP/CLAUDE.md, and `--strict-mcp-config` is - # belt-and-suspenders against a future CLI regression. The step's own - # `working-directory: trusted` keeps `jq` and `bun` on the trusted - # checkout for everything outside that one subshell. + # Security-critical invariant: PR code is only ever read by `claude`, in the `pr`-cwd + # subshell below — no other command in this job, including any `bun` call, runs with a cwd + # inside `pr`. `bun` auto-loads `bunfig.toml` (arbitrary `preload` code) and `.env` from its + # cwd, so a `pr`-cwd `bun` call would let a PR-authored `bunfig.toml` execute in a step + # holding `ANTHROPIC_API_KEY`. `claude` is a standalone binary unaffected by `bunfig.toml`; + # `--bare` disables hooks/MCP/CLAUDE.md, and `--strict-mcp-config` guards against a future + # CLI regression. - name: Run Claude review working-directory: trusted env: @@ -249,15 +221,10 @@ jobs: exit 1 fi - # Scrubs any secret-shaped substring a prompt-injected model might have - # echoed back (e.g. from `Read`-ing a secret-bearing path) out of the - # raw JSON before it's uploaded as a (public-repo) artifact; the posted - # review is scrubbed separately at render time. `if: always()` so a - # partial `claude-raw.json` from a failed attempt is still scrubbed - # before the always-on upload step below; guarded because - # `claude-findings.json` may not exist if every attempt failed before - # the extraction step. Runs from the trusted cwd, same as every other - # `bun` invocation in this job. + # Scrubs any secret-shaped substring a prompt-injected model might have echoed back before + # the raw JSON is uploaded as a public-repo artifact; the posted review is scrubbed + # separately at render time. Runs with `if: always()` so a partial `claude-raw.json` from a + # failed attempt is still scrubbed before the always-on upload step. - name: Redact secrets from Claude findings if: always() working-directory: trusted @@ -284,11 +251,10 @@ jobs: name: Codex review needs: resolve if: needs.resolve.outputs.should_run == 'true' - # Codex's INDEPENDENT review. It no longer depends on claude-review, so it - # runs IN PARALLEL with it. It works purely from /tmp/ai-review/pr.diff - # (absolute path in its prompt), so it needs no PR-head checkout — its ONLY - # checkout is the trusted default branch. The verify-by-reading step (which - # does need the PR's files) is the separate `adjudicate` job below. + # Codex's independent review doesn't depend on claude-review, so it runs in parallel. It + # works purely from /tmp/ai-review/pr.diff, so it needs only the trusted default-branch + # checkout — no PR-head checkout. The verify-by-reading step (which needs the PR's files) is + # the separate `adjudicate` job below. permissions: contents: read pull-requests: read @@ -321,18 +287,19 @@ jobs: mkdir -p /tmp/ai-review jq 'del(.["$schema"])' .github/ai-review/findings.schema.json > /tmp/ai-review/findings.schema.json - # Safety strategy (drop-sudo + read-only), verified against the pinned - # openai/codex-action@52fe01ec…'s action.yml + src/runCodexExec.ts — see - # the adjudicate job below for the full rationale. In short: Codex runs as - # a non-sudo-capable user, in a sandbox with no filesystem writes and no - # network, with no `codex-args`/`--sandbox` duplication. + # Safety strategy (drop-sudo + read-only) — see the "Run Codex adjudication" step below for + # the full rationale (verified against the pinned openai/codex-action's action.yml + + # src/runCodexExec.ts). In short: non-sudo user, no filesystem writes, no network. - name: Run Codex independent review - # Pinned to v1.11, NOT v1.12: v1.12 has a confirmed regression where a - # heavy Linux run never returns after Codex finishes the turn and writes - # its output file — the step sits idle until the job timeout, discarding - # a completed review (openai/codex-action#150). v1.11 handles the same - # heavy workload cleanly. There is no released fix above v1.12 yet. - uses: openai/codex-action@86365089eb2b84e0a8fb0717b304f8bdcb13b20e # v1.12 + # Pinned to v1.11 (52fe01ec…), not v1.12 (86365089…): v1.12 has two open upstream + # regressions on this workflow's config (safety-strategy: drop-sudo, sandbox: read-only, + # output-schema-file) — openai/codex-action#151 (a lingering descendant process keeps the + # step alive after Codex already wrote its output) and openai/codex-action#160 + # (drop-sudo's rewrite chmods root-owned /run sockets, breaking systemd-resolved and + # killing the job regardless of timeout-minutes). A dependabot.yml ignore entry for + # openai/codex-action stops an automated bump from silently regressing this pin; + # re-verify both issues are closed before ever re-bumping it. + uses: &codex-action-pin openai/codex-action@52fe01ec70a42f454c9d2ebd47598f9fd6893d56 # v1.11 with: openai-api-key: ${{ secrets.OPENAI_API_KEY }} prompt-file: .github/ai-review/codex-review-prompt.md @@ -340,8 +307,6 @@ jobs: effort: high output-schema-file: /tmp/ai-review/findings.schema.json output-file: /tmp/ai-review/codex-findings.json - # Pinned explicitly (verified via `npm view @openai/codex version`); - # never left floating. codex-version: "0.150.1" working-directory: ${{ github.workspace }} safety-strategy: drop-sudo @@ -377,21 +342,17 @@ jobs: - resolve - claude-review - codex-review - # Runs when AT LEAST ONE independent review succeeded — a single flaky model - # job must not sink the whole review. Each findings download below is guarded - # by its job's result, and the stage step substitutes an empty findings set - # for any review that didn't complete, so the adjudicator reconciles 1 or 2. + # Runs when at least one independent review succeeded — a single flaky model job must not + # sink the whole review. Findings download steps are guarded per job result, and staging + # substitutes an empty findings set for any review that didn't complete. if: ${{ !cancelled() && needs.resolve.outputs.should_run == 'true' && (needs.claude-review.result == 'success' || needs.codex-review.result == 'success') }} - # SECURITY-CRITICAL: this job checks out the PR head (untrusted subject - # matter) so Codex can VERIFY findings by reading the real files. Codex runs - # with its working directory at the workspace ROOT, which holds only the - # `pr/` and `trusted/` checkouts (no AGENTS.md/config of its own), and reads - # `pr/` read-only; the adjudicate prompt's injection guard treats every file - # under `pr/` (including any AGENTS.md/CLAUDE.md) as untrusted data. Every - # `bun` invocation runs from `trusted/`. Blast radius of a prompt-injected - # Codex here is bounded to review CONTENT: read-only sandbox, no network, - # key proxied by the action, and the output is secret-scrubbed before it - # leaves this job. + # Security-critical: this job checks out the PR head (untrusted subject matter) so Codex can + # verify findings by reading the real files. Codex's working directory is the workspace root, + # which holds only `pr/` and `trusted/` (no AGENTS.md/config of its own); it reads `pr/` + # read-only, and the adjudicate prompt's injection guard treats every file under `pr/` + # (including any AGENTS.md/CLAUDE.md) as untrusted data. Every `bun` invocation runs from + # `trusted/`. A prompt-injected Codex here is bounded to review content: read-only sandbox, + # no network, key proxied by the action, and secret-scrubbed output. permissions: contents: read pull-requests: read @@ -473,33 +434,23 @@ jobs: mkdir -p /tmp/ai-review jq 'del(.["$schema"])' .github/ai-review/merged-review.schema.json > /tmp/ai-review/merged-review.schema.json - # Safety strategy, verified against the pinned - # openai/codex-action@52fe01ec…'s action.yml + src/runCodexExec.ts: - # - `safety-strategy: read-only` forces codex-exec's legacy sandbox to - # read-only, but Codex still runs as the action's default, - # sudo-capable user — the action's own docs/security.md calls this - # combination out as unsafe, since a sudo-capable process can read - # secrets like OPENAI_API_KEY out of memory (e.g. via procfs) even - # under a read-only filesystem sandbox with no network. - # - `safety-strategy: drop-sudo` (the action's default) removes sudo - # from the user running Codex, closing that hole, but says nothing - # on its own about Codex's filesystem/network sandbox. - # - `determinePermissionSelection()` only forces the legacy read-only - # sandbox when `safety-strategy === "read-only"`; otherwise it honors - # a separately-set `sandbox` input as-is. So setting BOTH - # `safety-strategy: drop-sudo` and `sandbox: read-only` composes them - # safely: non-sudo user, no filesystem writes, no network — with no - # `codex-args`/`--sandbox` duplication. - # `working-directory` is the workspace root so Codex's cwd holds no - # untrusted AGENTS.md/config; it reads the PR from `pr/` and executes - # nothing from it. + # Safety strategy, per the pinned openai/codex-action's action.yml + src/runCodexExec.ts: + # - `safety-strategy: read-only` forces the legacy sandbox read-only, but Codex still + # runs as the action's default sudo-capable user — the action's own docs call this + # unsafe, since a sudo-capable process can read secrets like OPENAI_API_KEY out of + # memory even under a read-only, no-network sandbox. + # - `safety-strategy: drop-sudo` (the action's default) removes sudo from the user + # running Codex but says nothing on its own about the filesystem/network sandbox. + # - The action only forces its legacy read-only sandbox when `safety-strategy === + # "read-only"`; otherwise it honors a separately-set `sandbox` input as-is. Setting + # `safety-strategy: drop-sudo` and `sandbox: read-only` together composes them safely: + # non-sudo user, no filesystem writes, no network. + # `working-directory` is the workspace root so Codex's cwd holds no untrusted AGENTS.md/ + # config; it reads the PR from `pr/` and executes nothing from it. - name: Run Codex adjudication - # Pinned to v1.11, NOT v1.12: v1.12 has a confirmed regression where a - # heavy Linux run never returns after Codex finishes the turn and writes - # its output file — the step sits idle until the job timeout, discarding - # a completed review (openai/codex-action#150). v1.11 handles the same - # heavy workload cleanly. There is no released fix above v1.12 yet. - uses: openai/codex-action@86365089eb2b84e0a8fb0717b304f8bdcb13b20e # v1.12 + # Same v1.11 pin as codex-review's step above (see its comment for the full rationale); + # YAML-aliased so the SHA only needs to change in one place. + uses: *codex-action-pin with: openai-api-key: ${{ secrets.OPENAI_API_KEY }} prompt-file: trusted/.github/ai-review/adjudicate-prompt.md @@ -540,23 +491,22 @@ jobs: needs: - resolve - adjudicate - # Runs only when adjudication succeeded (it produced the merged review this - # job posts). `!cancelled()` is required here because an explicit `if` - # replaces the default "all needed jobs succeeded" check. + # Runs only when adjudication succeeded (it produced the merged review this job posts). + # `!cancelled()` is required since an explicit `if` replaces the default "all needed jobs + # succeeded" check. if: ${{ !cancelled() && needs.resolve.outputs.should_run == 'true' && needs.adjudicate.result == 'success' }} runs-on: ubuntu-latest timeout-minutes: 10 permissions: pull-requests: write steps: - # SECURITY-CRITICAL: this is the only job with write permission, so it - # must only ever execute trusted base-branch code — never the PR head. - # Checking out `develop` explicitly (never `needs.resolve.outputs.head_ref`) - # keeps a malicious PR from smuggling a script change into the one job - # that can write back to the PR. (For `pull_request` events GitHub runs - # the workflow FILE from the PR's own ref; acceptable because the auto - # path only admits same-repo PRs, whose authors hold write access - # anyway, and fork PRs run with a read-only token and no secrets.) + # Security-critical: this is the only job with write permission, so it must only ever + # execute trusted base-branch code — never the PR head. Checking out `develop` explicitly + # (never `needs.resolve.outputs.head_ref`) keeps a malicious PR from smuggling a script + # change into the one job that can write back to the PR. (For `pull_request` events GitHub + # runs the workflow file from the PR's own ref; acceptable since the auto path only admits + # same-repo PRs, whose authors hold write access anyway, and fork PRs run with a read-only + # token and no secrets.) - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: develop @@ -579,7 +529,6 @@ jobs: MERGED_REVIEW_PATH: /tmp/ai-review/merged-review.json TRIGGER: ${{ needs.resolve.outputs.trigger }} RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} - # CLAUDE_MODEL / CODEX_MODEL are inherited from the workflow-level - # `env:` block above — the same values passed to `claude`/ - # `codex-action` — so the footer never drifts from what actually ran. + # CLAUDE_MODEL / CODEX_MODEL are inherited from the workflow-level `env:` block above, + # so the footer never drifts from what actually ran. run: bun .github/scripts/ai-review/post-review.ts post diff --git a/.github/workflows/apply-release-notes.yml b/.github/workflows/apply-release-notes.yml index 054fc20d26..0a0b7e60e0 100644 --- a/.github/workflows/apply-release-notes.yml +++ b/.github/workflows/apply-release-notes.yml @@ -1,14 +1,9 @@ name: Apply release notes -# Approval-based publish. When a member of the supabase/cli team approves a -# release-notes PR (head ref `release-notes/v`), this workflow pushes -# the proposed notes to the GitHub Release body for the corresponding tag, -# comments the release URL on the PR, and closes the PR without merging. The -# release-notes PR targets `develop` (not `main`) so an accidental merge can -# never rewrite `main`'s history; the file is not meant to land on any branch. -# -# Mirrors the fast-forward job in release.yml, which already gates on a -# `pull_request_review` + `approved` event. +# Approval-based publish: when a supabase/cli team member approves a release-notes PR (head ref +# `release-notes/v`), this pushes the notes to the GitHub Release body for that tag, +# comments the release URL on the PR, and closes it without merging. The PR targets `develop` +# (not `main`) so an accidental merge can never rewrite `main`'s history. on: pull_request_review: @@ -32,9 +27,8 @@ jobs: outputs: authorized: ${{ steps.check.outputs.authorized }} steps: - # App token: needs `orgs/.../teams/.../memberships` read (the org-installed - # App has it), repo write to edit the release, and PR write to comment - # and close. Matches release.yml's fast-forward step. + # App token needs org membership read, repo write to edit the release, and PR write to + # comment and close. - id: app-token uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 with: @@ -47,9 +41,8 @@ jobs: GH_TOKEN: ${{ steps.app-token.outputs.token }} APPROVER: ${{ github.event.review.user.login }} PR_NUMBER: ${{ github.event.pull_request.number }} - # Fail closed: any response other than an active membership means the - # approval is ignored. We post a comment so the reviewer sees why their - # approval didn't apply, then exit 0 so the workflow isn't flagged red. + # Fail closed: any state other than active membership is ignored. Exit 0 (after + # commenting why) so the workflow isn't flagged red. run: | set -euo pipefail status=$(gh api \ @@ -79,9 +72,8 @@ jobs: client-id: ${{ vars.GH_APP_CLIENT_ID }} private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} - # Checkout the PR head so any reviewer edits made in the GitHub UI before - # approval are captured. apply-release-notes.ts reads from the working - # tree. + # Checkout the PR head so reviewer edits made before approval are captured; + # apply-release-notes.ts reads from the working tree. - uses: useblacksmith/checkout@6fd481652155169ed4d2f25ebaf97464f685175f # v1 with: ref: ${{ github.event.pull_request.head.sha }} @@ -98,8 +90,7 @@ jobs: HEAD_REF: ${{ github.event.pull_request.head.ref }} PR_NUMBER: ${{ github.event.pull_request.number }} APPROVER: ${{ github.event.review.user.login }} - # The branch is named `release-notes/v`, so the tag is just - # the basename. apply-release-notes.ts validates the file's existence. + # The branch is named `release-notes/v`, so the tag is just the basename. run: | set -euo pipefail tag="${HEAD_REF##release-notes/}" diff --git a/.github/workflows/build-cli-artifacts.yml b/.github/workflows/build-cli-artifacts.yml index d4024a5ae8..2e8c2a32db 100644 --- a/.github/workflows/build-cli-artifacts.yml +++ b/.github/workflows/build-cli-artifacts.yml @@ -7,10 +7,6 @@ on: description: CLI package version to build required: true type: string - shell: - description: CLI shell to package as the shipped supabase binary - required: true - type: string ref: description: Optional git ref or SHA to check out before building required: false @@ -55,7 +51,6 @@ jobs: runs-on: ${{ inputs.runner }} timeout-minutes: ${{ inputs.timeout_minutes }} env: - BUN_SHELL: ${{ inputs.shell }} VERSION: ${{ inputs.version }} SENTRY_DSN: ${{ secrets.SENTRY_DSN }} POSTHOG_API_KEY: ${{ secrets.POSTHOG_API_KEY }} @@ -111,9 +106,9 @@ jobs: - name: Sync versions run: pnpm exec bun apps/cli/scripts/sync-versions.ts --version "${VERSION}" - - name: Build selected shell + - name: Build CLI artifacts timeout-minutes: ${{ inputs.build_timeout_minutes }} - run: pnpm exec bun apps/cli/scripts/build.ts --version "${VERSION}" --shell "${BUN_SHELL}" + run: pnpm exec bun apps/cli/scripts/build.ts --version "${VERSION}" - name: Verify build artifacts run: | @@ -131,7 +126,7 @@ jobs: path: | packages/cli-*/bin/ dist/ - key: cli-build-${{ github.run_id }}-${{ inputs.shell }}-${{ inputs.version }}${{ inputs.cache_key_suffix }}-v1 + key: cli-build-${{ github.run_id }}-${{ inputs.version }}${{ inputs.cache_key_suffix }}-v1 enableCrossOsArchive: true lookup-only: true @@ -142,5 +137,5 @@ jobs: path: | packages/cli-*/bin/ dist/ - key: cli-build-${{ github.run_id }}-${{ inputs.shell }}-${{ inputs.version }}${{ inputs.cache_key_suffix }}-v1 + key: cli-build-${{ github.run_id }}-${{ inputs.version }}${{ inputs.cache_key_suffix }}-v1 enableCrossOsArchive: true diff --git a/.github/workflows/cli-go-api-sync.yml b/.github/workflows/cli-go-api-sync.yml index 7efdb8a4c4..7593731443 100644 --- a/.github/workflows/cli-go-api-sync.yml +++ b/.github/workflows/cli-go-api-sync.yml @@ -4,6 +4,12 @@ on: repository_dispatch: types: - api-sync + # Fallback for a lost dispatch, per docs/openapi-sync.md. This matters more + # now that the per-pull-request Codegen check reads the committed snapshot: + # this workflow is the only thing that looks at the live specification, so a + # dropped dispatch would otherwise leave drift undetected indefinitely. + schedule: + - cron: "17 6 * * *" workflow_dispatch: # allow manual triggering permissions: @@ -23,13 +29,30 @@ jobs: go-version-file: apps/cli-go/go.mod cache: true + # The only place that reads the live spec. Every other consumer, including + # the per-PR Codegen check, regenerates from the committed snapshot, so + # upstream drift turns into this one pull request instead of a failure on + # every open pull request that touches apps/cli-go. + - name: Refresh the OpenAPI snapshot + run: curl -fsSL "$API_SPEC_URL" -o api/v1-openapi.yaml + env: + API_SPEC_URL: https://api.supabase.green/api/v1-yaml + - name: Run codegen run: go generate + # Covers the snapshot as well as the generated client: an upstream edit + # that codegen ignores (a description, an example) would otherwise be + # refetched and discarded on every run, leaving the snapshot stale. The + # snapshot is compared byte-for-byte for the same reason -- under + # --ignore-space-at-eol a trailing-whitespace-only upstream edit would be + # refetched and discarded forever, which is the loop this check prevents. + # pkg keeps the flag: it is oapi-codegen output, not upstream bytes. - name: Check for changes id: check run: | - if git diff --ignore-space-at-eol --exit-code --quiet pkg; then + if git diff --exit-code --quiet api/v1-openapi.yaml && + git diff --ignore-space-at-eol --exit-code --quiet pkg; then echo "No changes detected" echo "has_changes=false" >> $GITHUB_OUTPUT else @@ -57,7 +80,7 @@ jobs: body: | This PR was automatically created to sync API types from the infrastructure repository. - Changes were detected in the generated API code after syncing with the latest spec from infrastructure. + Changes were detected after refreshing `apps/cli-go/api/v1-openapi.yaml` from the latest spec from infrastructure, and `apps/cli-go/pkg/api` is regenerated from it. The snapshot diff is the upstream API change and is worth reading: it is marked `linguist-generated`, so GitHub collapses it behind a "Load diff" click rather than hiding it. branch: sync/api-types base: develop diff --git a/.github/workflows/cli-go-ci.yml b/.github/workflows/cli-go-ci.yml index ffa727aea5..a4e60bfab4 100644 --- a/.github/workflows/cli-go-ci.yml +++ b/.github/workflows/cli-go-ci.yml @@ -119,6 +119,12 @@ jobs: go-version-file: apps/cli-go/go.mod cache: true + # Regenerates from the committed api/v1-openapi.yaml snapshot, so this + # check is hermetic: it fails only when a pull request leaves pkg/api out + # of sync with the snapshot it was generated from. It deliberately does + # not compare against the live upstream spec -- doing so made unrelated + # pull requests fail together whenever staging drifted ahead of the + # committed snapshot. That drift is the API Sync workflow's job. - run: go generate - run: | if ! git diff --ignore-space-at-eol --exit-code --quiet pkg; then diff --git a/.github/workflows/cli-go-codeql.yml b/.github/workflows/cli-go-codeql.yml index e079be038c..3f19507312 100644 --- a/.github/workflows/cli-go-codeql.yml +++ b/.github/workflows/cli-go-codeql.yml @@ -1,14 +1,3 @@ -# For most projects, this workflow file will not need changing; you simply need -# to commit it to your repository. -# -# You may wish to alter this file to override the set of languages analyzed, -# or to provide custom queries or build logic. -# -# ******** NOTE ******** -# We have attempted to detect the languages in your repository. Please check -# the `language` matrix defined below to confirm you have the correct set of -# supported CodeQL languages. -# name: "CodeQL" on: @@ -25,11 +14,7 @@ on: jobs: analyze: name: Analyze (${{ matrix.language }}) - # Runner size impacts CodeQL analysis time. To learn more, please see: - # - https://gh.io/recommended-hardware-resources-for-running-codeql - # - https://gh.io/supported-runners-and-hardware-resources - # - https://gh.io/using-larger-runners (GitHub.com only) - # Consider using larger runners or machines with greater resources for possible analysis time improvements. + # Swift requires a macOS runner; every other language in the matrix uses blacksmith. runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'blacksmith-8vcpu-ubuntu-2404' }} timeout-minutes: ${{ (matrix.language == 'swift' && 120) || 360 }} permissions: @@ -51,39 +36,19 @@ jobs: build-mode: autobuild - language: javascript-typescript build-mode: none - # CodeQL supports the following values keywords for 'language': 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'swift' - # Use `c-cpp` to analyze code written in C, C++ or both - # Use 'java-kotlin' to analyze code written in Java, Kotlin or both - # Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both - # To learn more about changing the languages that are analyzed or customizing the build mode for your analysis, - # see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning. - # If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how - # your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages steps: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL uses: github/codeql-action/init@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} - # If you wish to specify custom queries, you can do so here or in a config file. - # By default, queries listed here will override any specified in a config file. - # Prefix the list here with "+" to use these queries and those in the config file. - # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs - # queries: security-extended,security-and-quality - - # If the analyze step fails for one of the languages you are analyzing with - # "We were unable to automatically build your code", modify the matrix above - # to set the build mode to "manual" for that language. Then modify this step - # to build your code. - # ℹ️ Command-line programs to run using the OS shell. - # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun + # Placeholder manual-build step; only runs if a language's build-mode above is set to "manual". - if: matrix.build-mode == 'manual' shell: bash run: | diff --git a/.github/workflows/dispatch-cli-e2e-ci.yml b/.github/workflows/dispatch-cli-e2e-ci.yml index 109b85cf49..ed5aa0f22a 100644 --- a/.github/workflows/dispatch-cli-e2e-ci.yml +++ b/.github/workflows/dispatch-cli-e2e-ci.yml @@ -1,20 +1,9 @@ name: Dispatch cli-e2e-ci -# Asks the supabase/cli-e2e-ci harness to run the cli `test:live` suite against -# a full supabox stack, built from THIS PR's head commit (CLI-1825 / CLI-1831). -# -# This is distinct from `live-e2e.yml`, which runs the collocated live suite -# against managed staging (api.supabase.green). Here the same `apps/cli` suite -# runs against a local Supabox stack stood up inside the private cli-e2e-ci repo; -# we only fire the trigger and pass our head SHA — cli-e2e-ci checks that SHA out -# into its `cli` submodule. -# -# Opt-in by label to keep the expensive full-stack run off every PR: add the -# `run-live-e2e-ci` label (re-dispatches on each subsequent push while labeled). -# cli-e2e-ci reports a `cli-e2e-ci / live` commit status back onto the head SHA. -# -# Fork PRs cannot dispatch (no access to the App secret); run cli-e2e-ci's own -# workflow_dispatch with `cli_ref` for those. +# Triggers supabase/cli-e2e-ci to run the cli `test:live` suite against a full Supabox stack +# built from this PR's head commit — distinct from live-e2e.yml, which targets managed staging. +# Opt in with the `run-live-e2e-ci` label; fork PRs cannot dispatch (no access to the App +# secret) and use cli-e2e-ci's own workflow_dispatch with `cli_ref` instead. on: pull_request: types: [labeled, synchronize, reopened] @@ -24,9 +13,7 @@ permissions: jobs: dispatch: - # Same-repo PRs only: fork PRs don't receive secrets (GH_APP_PRIVATE_KEY), so - # the App-token step would fail and leave a red check. Skip them cleanly — - # fork PRs use cli-e2e-ci's workflow_dispatch with `cli_ref` instead. + # Fork PRs don't receive secrets (GH_APP_PRIVATE_KEY), so the App-token step would fail; skip them. if: >- contains(github.event.pull_request.labels.*.name, 'run-live-e2e-ci') && github.event.pull_request.head.repo.full_name == github.repository diff --git a/.github/workflows/lint-pull-request.yml b/.github/workflows/lint-pull-request.yml index c70377a830..dc04c45eaf 100644 --- a/.github/workflows/lint-pull-request.yml +++ b/.github/workflows/lint-pull-request.yml @@ -3,9 +3,9 @@ name: Lint Pull Request # Release-notes PRs (head ref `release-notes/*`) skip CI; only # apply-release-notes.yml runs for those. # -# Draft PRs skip this check unless they carry `run-ci` (same opt-in as Test -# and preview packages). Label events retrigger this cheap check so adding -# `run-ci` on a draft starts lint without waiting for a push. +# Draft PRs skip this check unless they carry `run-ci` (same opt-in as Test). +# Label events retrigger this cheap check so adding `run-ci` on a draft starts +# lint without waiting for a push. on: pull_request_target: types: @@ -44,5 +44,21 @@ jobs: uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Keep this scope list in sync with commitlint.config.js. + with: + scopes: | + api + cli + cli-e2e + cli-go + cli-test-helpers + config + docs + process-compose + stack + ci + repo + misc + release - if: github.event_name == 'merge_group' run: echo "Merge queue entry does not have a pull request payload; reporting success for the required lint check." diff --git a/.github/workflows/live-e2e-gate.yml b/.github/workflows/live-e2e-gate.yml new file mode 100644 index 0000000000..80c5f8985b --- /dev/null +++ b/.github/workflows/live-e2e-gate.yml @@ -0,0 +1,85 @@ +name: Live E2E Gate + +on: + workflow_call: + inputs: + sha: + description: Exact commit to verify + required: true + type: string + secrets: + SUPABASE_E2E_CLI_LIVE_STAGING_ACCESS_TOKEN: + required: true + +permissions: + actions: read + contents: read + +jobs: + lookup: + name: Find exact-SHA live result + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + reuse: ${{ steps.lookup.outputs.reuse }} + steps: + - name: Check latest relevant run + id: lookup + env: + GH_TOKEN: ${{ github.token }} + REPOSITORY: ${{ github.repository }} + SHA: ${{ inputs.sha }} + run: | + set -euo pipefail + response="$(gh api --paginate "/repos/${REPOSITORY}/actions/workflows/live-e2e.yml/runs?branch=develop&head_sha=${SHA}&per_page=100")" + latest="$(jq -s -c 'map(.workflow_runs[]) | sort_by(.run_number) | reverse | .[0] // empty' <<< "$response")" + if [[ -z "$latest" ]]; then + echo "No live run exists for ${SHA}; the stable gate will run the suite." + echo "reuse=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + status="$(jq -r '.status' <<< "$latest")" + conclusion="$(jq -r '.conclusion // empty' <<< "$latest")" + run_id="$(jq -r '.id' <<< "$latest")" + echo "Latest live run ${run_id}: status=${status}, conclusion=${conclusion}" + jobs="$(gh api --paginate "/repos/${REPOSITORY}/actions/runs/${run_id}/jobs?per_page=100")" + # Keep this exact job suffix and step name aligned with the producer + # and notifier; renaming either changes reuse semantics. + executed="$(jq -s -r 'any(.[].jobs[]; (.name == "Live e2e" or (.name | endswith(" / Live e2e"))) and .conclusion == "success" and any(.steps[]?; .name == "Run live e2e" and .conclusion == "success"))' <<< "$jobs")" + if [[ "$status" == completed && "$conclusion" == success && "$executed" == true ]]; then + echo "reuse=true" >> "$GITHUB_OUTPUT" + else + echo "Latest live run did not pass; the stable gate will run the suite." + echo "reuse=false" >> "$GITHUB_OUTPUT" + fi + + run: + name: Run live e2e for stable gate + needs: lookup + if: needs.lookup.outputs.reuse != 'true' + uses: ./.github/workflows/live-e2e-suite.yml + with: + ref: ${{ inputs.sha }} + concurrency_group: live-e2e-stable-${{ inputs.sha }} + # Forward the one secret required by live-e2e-suite.yml. + secrets: + SUPABASE_E2E_CLI_LIVE_STAGING_ACCESS_TOKEN: ${{ secrets.SUPABASE_E2E_CLI_LIVE_STAGING_ACCESS_TOKEN }} + + result: + name: Verify live e2e gate + needs: [lookup, run] + if: ${{ !cancelled() }} + runs-on: ubuntu-latest + timeout-minutes: 2 + steps: + - name: Require a successful result + env: + REUSE: ${{ needs.lookup.outputs.reuse }} + RUN_RESULT: ${{ needs.run.result }} + run: | + set -euo pipefail + if [[ "$REUSE" == true || "$RUN_RESULT" == success ]]; then + exit 0 + fi + echo "::error::live e2e did not produce a successful result" + exit 1 diff --git a/.github/workflows/live-e2e-notify.yml b/.github/workflows/live-e2e-notify.yml new file mode 100644 index 0000000000..5307ebb2ec --- /dev/null +++ b/.github/workflows/live-e2e-notify.yml @@ -0,0 +1,123 @@ +name: Live E2E Notifications + +on: + workflow_run: + workflows: [Live E2E, Release] + types: [completed] + +permissions: + actions: read + contents: read + +jobs: + notify: + if: github.event.workflow_run.conclusion == 'failure' || github.event.workflow_run.conclusion == 'success' + runs-on: ubuntu-latest + timeout-minutes: 5 + continue-on-error: true + steps: + - name: Notify Slack + env: + GH_TOKEN: ${{ github.token }} + REPOSITORY: ${{ github.repository }} + RUN_ID: ${{ github.event.workflow_run.id }} + RUN_NUMBER: ${{ github.event.workflow_run.run_number }} + RUN_ATTEMPT: ${{ github.event.workflow_run.run_attempt }} + WORKFLOW_ID: ${{ github.event.workflow_run.workflow_id }} + WORKFLOW_NAME: ${{ github.event.workflow_run.name }} + SHA: ${{ github.event.workflow_run.head_sha }} + BRANCH: ${{ github.event.workflow_run.head_branch }} + CONCLUSION: ${{ github.event.workflow_run.conclusion }} + SLACK_WEBHOOK: ${{ secrets.SLACK_RELEASE_WEBHOOK }} + run: | + set -euo pipefail + + # A reusable suite job is named "Live e2e" or ends with the exact + # " / Live e2e" suffix. Keep this aligned with the producer and gate. + suite_conclusion() { + jq -r '([.jobs[]? | select(.name == "Live e2e" or (.name | endswith(" / Live e2e"))) | .conclusion] | if any(.[]; . == "failure") then "failure" elif any(.[]; . == "success") then "success" else "none" end)' + } + + if ! current_jobs="$(gh api --paginate "/repos/${REPOSITORY}/actions/runs/${RUN_ID}/attempts/${RUN_ATTEMPT}/jobs?per_page=100")"; then + echo "::warning::Could not inspect jobs for ${WORKFLOW_NAME} run ${RUN_ID}; suppressing notification because the suite result is unknown." + exit 0 + fi + current_suite="$(printf '%s\n' "$current_jobs" | jq -s '{jobs: map(.jobs[])}' | suite_conclusion)" + startup_failure=false + if [[ "$current_suite" == none && "$WORKFLOW_NAME" == "Live E2E" && "$CONCLUSION" == failure ]]; then + current_suite=failure + startup_failure=true + elif [[ "$current_suite" == none || ("$current_suite" == failure && "$WORKFLOW_NAME" == "Release") ]]; then + # Release's generic notification covers release failures and runs + # that reused a staging result without creating a suite job. + exit 0 + fi + + prior_conclusion="" + if (( RUN_ATTEMPT > 1 )); then + if ! prior_jobs="$(gh api --paginate "/repos/${REPOSITORY}/actions/runs/${RUN_ID}/attempts/$((RUN_ATTEMPT - 1))/jobs?per_page=100")"; then + echo "::warning::Could not inspect the prior attempt for ${WORKFLOW_NAME} run ${RUN_ID}; suppressing transition notification." + exit 0 + fi + prior_conclusion="$(printf '%s\n' "$prior_jobs" | jq -s '{jobs: map(.jobs[])}' | suite_conclusion)" + [[ "$prior_conclusion" != none ]] || prior_conclusion="" + fi + + if ! history="$(gh api -X GET "/repos/${REPOSITORY}/actions/workflows/${WORKFLOW_ID}/runs" -f "branch=${BRANCH}" -F per_page=25)"; then + if [[ "$current_suite" == success && -z "$prior_conclusion" ]]; then + echo "::warning::Could not inspect recent ${WORKFLOW_NAME} history; suppressing recovery notification." + exit 0 + fi + echo "::warning::Could not inspect recent ${WORKFLOW_NAME} history; continuing with the confirmed current result." + else + candidates="$(jq -r --argjson current_number "$RUN_NUMBER" --argjson current_attempt "$RUN_ATTEMPT" '(.workflow_runs // []) | map(select((.run_number != $current_number or (.run_attempt // 1) != $current_attempt) and .status == "completed" and (.conclusion == "failure" or .conclusion == "success"))) | sort_by(.run_number, (.run_attempt // 1)) | reverse | .[] | [.id, (.run_attempt // 1), .run_number, .conclusion] | @tsv' <<< "$history")" + while IFS=$'\t' read -r candidate_id candidate_attempt candidate_number candidate_conclusion; do + [[ -z "$candidate_id" ]] && continue + if (( candidate_number > RUN_NUMBER || (candidate_number == RUN_NUMBER && candidate_attempt > RUN_ATTEMPT) )); then + relation=newer + else + relation=older + fi + if [[ "$relation" == newer || -z "$prior_conclusion" ]]; then + if ! candidate_jobs="$(gh api --paginate "/repos/${REPOSITORY}/actions/runs/${candidate_id}/attempts/${candidate_attempt}/jobs?per_page=100")"; then + echo "::warning::Could not inspect candidate run ${candidate_id}; suppressing notification because history is incomplete." + exit 0 + fi + candidate_suite="$(printf '%s\n' "$candidate_jobs" | jq -s '{jobs: map(.jobs[])}' | suite_conclusion)" + if [[ "$candidate_suite" == none && "$WORKFLOW_NAME" == "Live E2E" && "$candidate_conclusion" == failure ]]; then + candidate_suite=failure + fi + if [[ "$candidate_suite" == failure || "$candidate_suite" == success ]]; then + if [[ "$relation" == newer ]]; then + echo "A newer completed relevant suite exists; suppressing stale notification." + exit 0 + fi + prior_conclusion="$candidate_suite" + break + fi + fi + done <<< "$candidates" + fi + + if [[ "$current_suite" == success ]]; then + [[ "$prior_conclusion" == failure ]] || exit 0 + header="✅ Live E2E recovered" + text="Live E2E recovered" + detail="The previous relevant ${WORKFLOW_NAME} live suite failed; this run passed." + else + [[ "$prior_conclusion" != failure ]] || exit 0 + if [[ "$startup_failure" == true ]]; then + header="❌ Live E2E failed to start" + text="Live E2E failed to start" + detail="The Live E2E workflow failed before it produced a Live e2e job." + else + header="❌ Live E2E failed" + text="Live E2E failed" + detail="The ${WORKFLOW_NAME} live suite failed." + fi + fi + + run_url="https://github.com/${REPOSITORY}/actions/runs/${RUN_ID}" + commit_url="https://github.com/${REPOSITORY}/commit/${SHA}" + payload="$(jq -n --arg text "$text" --arg header "$header" --arg detail "$detail" --arg run_url "$run_url" --arg commit_url "$commit_url" --arg branch "$BRANCH" --arg sha "${SHA:0:7}" '{text:$text,blocks:[{type:"header",text:{type:"plain_text",text:$header,emoji:true}},{type:"section",text:{type:"mrkdwn",text:($detail + "\n*Branch:* `" + $branch + "`\n*Commit:* <" + $commit_url + "|" + $sha + ">\n*Workflow run:* <" + $run_url + "|view logs and failed tests>")}}]}')" + curl -fsSL -X POST -H 'Content-type: application/json' --data "$payload" "$SLACK_WEBHOOK" diff --git a/.github/workflows/live-e2e-suite.yml b/.github/workflows/live-e2e-suite.yml new file mode 100644 index 0000000000..1740a25051 --- /dev/null +++ b/.github/workflows/live-e2e-suite.yml @@ -0,0 +1,64 @@ +name: Live E2E Suite + +on: + workflow_call: + inputs: + ref: + description: Git ref to test + required: true + type: string + concurrency_group: + description: Concurrency group for this suite + required: false + type: string + default: live-e2e + cancel_in_progress: + description: Cancel an older run in the same group + required: false + type: boolean + default: false + secrets: + SUPABASE_E2E_CLI_LIVE_STAGING_ACCESS_TOKEN: + required: true + +permissions: + contents: read + +jobs: + live-e2e: + # Consumers identify this reusable job by the exact " / Live e2e" suffix. + # Keep the suffix aligned with live-e2e-gate.yml and live-e2e-notify.yml. + name: Live e2e + runs-on: blacksmith-8vcpu-ubuntu-2404 + timeout-minutes: 30 + concurrency: + group: ${{ inputs.concurrency_group }} + cancel-in-progress: ${{ inputs.cancel_in_progress }} + env: + SUPABASE_LIVE_API_URL: https://api.supabase.green + SUPABASE_LIVE_PROJECT_NAME: supabase-cli-live + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ inputs.ref }} + persist-credentials: false + + - name: Setup + uses: ./.github/actions/setup + + - name: Docker preflight + run: docker info + + # Consumers also use this exact step name when deciding whether a suite ran. + - name: Run live e2e + timeout-minutes: 20 + env: + SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_E2E_CLI_LIVE_STAGING_ACCESS_TOKEN }} + run: pnpm run test:live + + - name: Cleanup leftover projects + if: always() + env: + SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_E2E_CLI_LIVE_STAGING_ACCESS_TOKEN }} + run: bash apps/cli/scripts/sweep-live-projects.sh "supabase-cli-live-${GITHUB_RUN_ID}-" diff --git a/.github/workflows/live-e2e.yml b/.github/workflows/live-e2e.yml index 180ca71787..3817b9eff1 100644 --- a/.github/workflows/live-e2e.yml +++ b/.github/workflows/live-e2e.yml @@ -1,150 +1,26 @@ name: Live E2E -# Live e2e suite. Runs the collocated `apps/cli` tests against the real staging -# Management API + Docker bundler, then invokes deployed functions over HTTP. -# -# Non-blocking by construction: this is a standalone workflow, NOT part of the -# required-checks set, and it never runs on the default PR path of test.yml. -# -# Triggers: -# - workflow_dispatch — manual run. The Actions UI branch picker selects the -# ref (github.ref), always a same-repo branch. We deliberately take NO -# free-form `ref` input: that would let a manual run check out arbitrary -# (e.g. external PR) code while the staging token is in the job env. -# - schedule (daily) — exercises the `@beta` channel. `develop` is the default -# branch AND the beta release source, so a scheduled run checks it out and -# builds from source. The `gate` job skips the run unless the published -# `supabase@beta` version changed since the last green run (an actions/cache -# marker keyed on the version), so we only spend a staging project when there -# is actually a new beta to test. -# -# Secrets: workflow_dispatch and schedule both run on trusted same-repo refs, so -# the staging token is never exposed to fork code. on: + push: + branches: [develop] workflow_dispatch: schedule: - # Daily, offset from other scheduled workflows. Cron timing is best-effort. - cron: "23 6 * * *" permissions: contents: read jobs: - # Decide whether to run. Manual dispatch always runs. Scheduled runs only fire - # when the latest published `@beta` is newer than the last one we tested green. - gate: - name: Gate (newer @beta?) - runs-on: ubuntu-latest - outputs: - should_run: ${{ steps.decide.outputs.should_run }} - version: ${{ steps.ver.outputs.version }} - steps: - - name: Resolve latest @beta version - id: ver - run: | - set -euo pipefail - version="$(npm view supabase@beta version)" - # Validate the shape before it becomes a cache key (defense-in-depth - # against a garbage/poisoned registry value). - if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$ ]]; then - echo "::error::unexpected supabase@beta version '$version'"; exit 1 - fi - echo "version=$version" >> "$GITHUB_OUTPUT" - - # Marker presence == "this beta already tested green". lookup-only so we - # download nothing; the marker is written by `finalize` after a green run. - - name: Check tested marker - id: cache - if: github.event_name == 'schedule' - uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: .beta-marker - key: live-e2e-beta-${{ steps.ver.outputs.version }} - lookup-only: true - - - name: Decide - id: decide - run: | - if [ "${{ github.event_name }}" != "schedule" ]; then - echo "manual dispatch -> run" - echo "should_run=true" >> "$GITHUB_OUTPUT" - elif [ "${{ steps.cache.outputs.cache-hit }}" = "true" ]; then - echo "beta ${{ steps.ver.outputs.version }} already tested green -> skip" - echo "should_run=false" >> "$GITHUB_OUTPUT" - else - echo "new beta ${{ steps.ver.outputs.version }} -> run" - echo "should_run=true" >> "$GITHUB_OUTPUT" - fi - live-e2e: - needs: gate - if: needs.gate.outputs.should_run == 'true' - name: Live e2e (${{ matrix.target }}) - runs-on: blacksmith-8vcpu-ubuntu-2404 - # Serialize a target against itself across runs. Per-job-scoped project names - # mean this is mostly belt-and-braces. - concurrency: - group: live-e2e-${{ matrix.target }}-${{ github.ref }} - cancel-in-progress: true - strategy: - # Each target is an independent green/red signal. - fail-fast: false - matrix: - # ts-legacy is the only shipped CLI shell, so it's the sole live-e2e - # target. It still shells out to the Go binary for the handful of - # commands the TS port proxies (db diff, db pull, db branch *, db - # remote *, gen keys, functions download). ts-next is a later axis. - target: - - ts-legacy - # Non-secret config is job-level; the staging token is scoped to only the two - # steps that need it (run + cleanup) so build/checkout/docker never see it. - env: - SUPABASE_LIVE_API_URL: https://api.supabase.green - SUPABASE_LIVE_PROJECT_NAME: supabase-cli-live-${{ matrix.target }} - CLI_HARNESS_TARGET: ${{ matrix.target }} - steps: - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - - name: Setup - uses: ./.github/actions/setup - - # Docker is a hard requirement for the --use-docker bundler cell. - - name: Docker preflight - run: docker info - - - name: Run live e2e - timeout-minutes: 20 - env: - SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_E2E_CLI_LIVE_STAGING_ACCESS_TOKEN }} - run: pnpm run test:live - - # Backstop: delete any project this job created that survived a crash. - # The script exits non-zero (failing this step) if any delete failed. - - name: Cleanup leftover projects - if: always() - env: - SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_E2E_CLI_LIVE_STAGING_ACCESS_TOKEN }} - run: bash apps/cli/scripts/sweep-live-projects.sh "supabase-cli-live-${CLI_HARNESS_TARGET}-${GITHUB_RUN_ID}-" - - # Record that this beta tested green so the next scheduled run skips it. Needs - # the whole matrix: the marker is saved only if the ts-legacy leg passed (a - # red leg leaves no marker, so the next day re-runs the same beta). - finalize: - needs: [gate, live-e2e] - if: github.event_name == 'schedule' && needs.live-e2e.result == 'success' - name: Mark @beta tested - runs-on: ubuntu-latest - steps: - - name: Write marker - run: | - mkdir -p .beta-marker - echo "${{ needs.gate.outputs.version }}" > .beta-marker/version - - - name: Save tested marker - uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: .beta-marker - key: live-e2e-beta-${{ needs.gate.outputs.version }} + # The reusable suite requires only this explicitly forwarded secret. + name: Live e2e + uses: ./.github/workflows/live-e2e-suite.yml + with: + ref: ${{ github.sha }} + # Pushes to develop share a branch group and cancel superseded pushes. + # Manual, scheduled, and stable-gate runs use independent groups because + # they each own separate temporary projects and may run concurrently. + concurrency_group: live-e2e-${{ github.event_name == 'push' && github.ref || github.run_id }} + cancel_in_progress: ${{ github.event_name == 'push' }} + secrets: + SUPABASE_E2E_CLI_LIVE_STAGING_ACCESS_TOKEN: ${{ secrets.SUPABASE_E2E_CLI_LIVE_STAGING_ACCESS_TOKEN }} diff --git a/.github/workflows/mirror-slim-image.yml b/.github/workflows/mirror-slim-image.yml index 454dc49c71..7f48630a15 100644 --- a/.github/workflows/mirror-slim-image.yml +++ b/.github/workflows/mirror-slim-image.yml @@ -1,23 +1,14 @@ name: Mirror Slim Image -# Mirrors slim service images published by supabase/slim-services from -# ghcr.io/supabase/cli/: to -# public.ecr.aws/supabase/cli/:. +# Mirrors slim service images published by supabase/slim-services from ghcr.io/supabase/cli to +# public.ecr.aws/supabase/cli. The sender polls the ECR Public destination and fails its release +# unless it resolves to the exact index digest published, so the copy must be digest-preserving +# via `regctl image copy` (byte-for-byte); do not switch to `docker buildx imagetools create`, +# which can rewrite the index and change its digest. # -# The slim-services release pipeline sends a `mirror-slim-image` -# repository_dispatch to this repo, then anonymously polls the ECR Public -# destination (15-minute timeout) and fails its release unless the destination -# resolves to the exact index digest it published. The copy must therefore be -# digest-preserving: we use `regctl image copy`, which moves the whole OCI -# index (all platform manifests and referrers) byte-for-byte. Do NOT switch -# this to `docker buildx imagetools create` — it can rewrite the index and -# change its digest, breaking the sender's verification. -# -# The payload arrives with whatever authority holds the dispatch token, so it -# is validated as untrusted input: names are pattern-checked, source and -# destination are derived here rather than trusted from the payload, and the -# source must resolve to the digest claimed by the sender before anything is -# copied. +# The payload arrives with whatever authority holds the dispatch token, so it is treated as +# untrusted: names are pattern-checked, source/destination are derived here rather than trusted +# from the payload, and the source must resolve to the claimed digest before anything is copied. # # Full contract: docs/design/ecr-mirror-dispatch.md in supabase/slim-services. @@ -103,8 +94,7 @@ jobs: } >> "$GITHUB_OUTPUT" - name: Install regctl - # Installed under $RUNNER_TEMP (always writable by the job user) and - # exposed to later steps via $GITHUB_PATH. + # $RUNNER_TEMP is always writable by the job user. run: | set -euo pipefail install -d "${RUNNER_TEMP}/regctl-bin" @@ -174,16 +164,10 @@ jobs: SOURCE: ${{ steps.validate.outputs.source }} DESTINATION: ${{ steps.validate.outputs.destination }} DIGEST: ${{ steps.validate.outputs.digest }} - # Copy by digest so the copy cannot race a tag move on the source; the - # whole index, all child manifests, and any referrers move as-is. - # - # The copy runs unconditionally, with no early exit when the - # destination tag already resolves to the digest: regctl's copy is - # incremental, so a re-dispatch after a complete copy is a cheap - # verification pass, while re-running after a partial failure (root - # index pushed but referrers or digest-tags missing) completes the - # copy instead of skipping it. Re-dispatches therefore still exit - # successfully with the destination digest unchanged. + # Copies by digest (not tag) so it can't race a tag move on the source, and runs + # unconditionally with no early exit: regctl's copy is incremental, so re-dispatching + # after a complete copy is a cheap no-op, and after a partial failure it finishes the + # copy instead of skipping it. run: | set -euo pipefail regctl image copy --referrers --digest-tags \ diff --git a/.github/workflows/publish-preview-cli-packages.yml b/.github/workflows/publish-preview-cli-packages.yml index 5282341687..73e2477257 100644 --- a/.github/workflows/publish-preview-cli-packages.yml +++ b/.github/workflows/publish-preview-cli-packages.yml @@ -3,58 +3,54 @@ name: Publish Preview CLI Packages # Release-notes PRs (head ref `release-notes/*`) are markdown-only and are not # meant to produce installable preview packages. # -# Default path: ready (non-draft) PRs targeting `develop`. -# `run-ci.yml` calls this workflow for drafts and stacked / non-develop PRs. +# Opt-in by label: pkg.pr.new hosts large CLI binaries, so publish only when +# someone needs a shareable install. Add `run-preview-packages` to publish +# (and re-publish on each subsequent push while labeled); remove it to cancel +# in-progress runs via this workflow's concurrency group. on: pull_request: types: - opened - synchronize - reopened - - ready_for_review - - converted_to_draft - branches: - - develop - workflow_call: - inputs: - force: - description: Publish even when the PR is a draft (used by run-ci.yml) - type: boolean - default: false - secrets: - DF_FIREWALL_TOKEN: - required: true + - labeled + - unlabeled permissions: actions: read contents: read -# Literal prefix: called workflows inherit github.workflow from the caller -# (`run-ci`). `inputs.force` separates this call from a skipped native run on -# a develop draft. +# Unrelated label events still start a run; give them a unique group so they +# cannot cancel an in-progress publish. Removing `run-preview-packages` stays +# on the main group and cancels via cancel-in-progress. concurrency: - group: publish-preview-cli-packages.yml-${{ github.event.pull_request.number || github.head_ref }}-${{ inputs.force && 'run-ci' || 'direct' }} + group: >- + publish-preview-cli-packages.yml-${{ github.event.pull_request.number || github.ref }}${{ + ((github.event.action == 'labeled' || github.event.action == 'unlabeled') + && github.event.label.name != 'run-preview-packages' + && format('-noop-{0}', github.run_id)) + || '' + }} cancel-in-progress: true jobs: build: if: | !startsWith(github.head_ref, 'release-notes/') && - (inputs.force || github.event.pull_request.draft == false) + contains(github.event.pull_request.labels.*.name, 'run-preview-packages') && + ((github.event.action != 'labeled' && + github.event.action != 'unlabeled') || + github.event.label.name == 'run-preview-packages') name: Build preview CLI packages uses: ./.github/workflows/build-cli-artifacts.yml with: version: 0.0.0-pr.${{ github.event.pull_request.number }} - shell: legacy secrets: DF_FIREWALL_TOKEN: ${{ secrets.DF_FIREWALL_TOKEN }} publish: needs: build - if: | - !startsWith(github.head_ref, 'release-notes/') && - (inputs.force || github.event.pull_request.draft == false) && - needs.build.result == 'success' + if: needs.build.result == 'success' name: Publish preview package runs-on: blacksmith-8vcpu-ubuntu-2404 outputs: @@ -79,7 +75,7 @@ jobs: path: | packages/cli-*/bin/ dist/ - key: cli-build-${{ github.run_id }}-legacy-${{ env.PREVIEW_VERSION }}-v1 + key: cli-build-${{ github.run_id }}-${{ env.PREVIEW_VERSION }}-v1 enableCrossOsArchive: true fail-on-cache-miss: true @@ -135,10 +131,7 @@ jobs: comment: needs: publish - if: | - !startsWith(github.head_ref, 'release-notes/') && - (inputs.force || github.event.pull_request.draft == false) && - needs.publish.result == 'success' + if: needs.publish.result == 'success' name: Post preview command comment runs-on: ubuntu-latest permissions: diff --git a/.github/workflows/release-config.yml b/.github/workflows/release-config.yml index c002f39c8f..d3d2bc24ff 100644 --- a/.github/workflows/release-config.yml +++ b/.github/workflows/release-config.yml @@ -7,16 +7,12 @@ on: 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. + # 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. # - # There is deliberately no `version` input: the publish job's registry probe - # skips versions that already exist on npm (after verifying the registry - # bytes match the reviewed artifact), 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. + # There is no `version` input: the publish job's registry probe already skips versions that + # exist on npm with matching bytes, so recovery from stale published bytes is just landing a + # new releasable commit — not worth a second cut-forward code path here. workflow_dispatch: inputs: dry_run: @@ -45,11 +41,9 @@ jobs: 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. + # semantic-release runs `git push --dry-run HEAD:` as part of verifyAuth even in + # `dry_run: true` mode, so the token needs push access to the protected develop branch — + # the default GITHUB_TOKEN doesn't have it. - id: app-token uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 with: @@ -57,11 +51,9 @@ jobs: 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. + # `persist-credentials: false` is required: otherwise checkout caches the default + # GITHUB_TOKEN in git config, and its Authorization header overrides the App token + # semantic-release puts in the push URL, making the dry-push get rejected by branch protection. - uses: useblacksmith/checkout@6fd481652155169ed4d2f25ebaf97464f685175f # v1 with: fetch-depth: 0 @@ -101,20 +93,16 @@ jobs: trap 'echo "::${resume_token}::"' EXIT pnpm exec bun packages/config/scripts/release-plan.ts --notes-out "$RUNNER_TEMP/config-release-notes.md" - # The build, gate, and pack steps also run on private-blocked pushes - # (should_release=false, version set): if `private` were ever flipped - # back on, every config push would still rehearse the plan half of the - # release train while the publish half stays parked. + # Build/gate/pack also run on private-blocked pushes (should_release=false, version set): + # if `private` is ever flipped back on, the plan half of the release train is already + # rehearsed on every config push. - name: Build @supabase/config if: steps.plan.outputs.version != '' run: pnpm exec turbo run @supabase/config#build - # Pack the exact tarball the approver's evidence (the gate summary - # below) 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. + # Packs the exact tarball the approver's evidence (the gate summary below) describes, + # rather than rebuilding: builds aren't byte-reproducible across jobs, so a rebuild could + # let the approved bytes and published bytes differ. - name: Pack the release tarball if: steps.plan.outputs.version != '' env: @@ -127,11 +115,9 @@ jobs: pnpm pack --pack-destination "$RUNNER_TEMP/config-release-artifact" cp "$RUNNER_TEMP/config-release-notes.md" "$RUNNER_TEMP/config-release-artifact/" - # The gate diffs the declarations INSIDE the packed tarball (not the - # dist/ build directory it was packed from), so the approver's evidence - # is generated from the same bytes the publish job ships — a packlist - # regression that drops .d.ts files from the tarball fails here instead - # of shipping a surface the approver never saw. + # Diffs the declarations inside the packed tarball (not the dist/ directory it was packed + # from), so a packlist regression that drops .d.ts files fails here instead of shipping a + # surface the approver never saw. - name: Run type-surface release gate against the packed tarball if: steps.plan.outputs.version != '' env: @@ -153,13 +139,10 @@ jobs: 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 — this only gates real releases. + # An environment referenced by a workflow is auto-created without protection rules, so if + # `config-release`'s required-reviewers rule (repo config, not code) goes missing, the + # publish job would run straight through unreviewed. Fail closed here before a real + # (non-dry) release can reach it; private-blocked rehearsals are unaffected. - name: Assert the release approval gate is armed if: steps.plan.outputs.should_release == 'true' && steps.plan.outputs.dry_run != 'true' env: @@ -194,26 +177,21 @@ jobs: 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. + # npm provenance verification rejects non-GitHub-hosted runners with E422; Blacksmith runners + # count as self-hosted from sigstore's POV, so this job must stay on a github-hosted runner + # (it's short and not compute-bound, so the 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. + # 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 what 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. + # OIDC trusted publishing + provenance — same as release-shared.yml; no NPM_TOKEN anywhere. + # This job 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 @@ -237,9 +215,8 @@ jobs: persist-credentials: true token: ${{ steps.app-token.outputs.token }} - # npm only — no `pnpm install`, no workspace toolchain. npm ≥ 11.5.1 is - # required for OIDC trusted publishing, newer than the runner image's - # system npm, and node 24 bundles it. + # npm only — no `pnpm install`, no workspace toolchain. npm ≥ 11.5.1 (required for OIDC + # trusted publishing) is newer than the runner image's system npm; node 24 bundles it. - name: Install node uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: @@ -267,12 +244,9 @@ jobs: exit 1 fi - # Idempotent, mirroring publish.ts's registry-probe intent: a re-run - # after a post-publish failure must not die on EPUBLISHCONFLICT — but - # the skip is only safe when the registry's bytes ARE the reviewed - # artifact, since the tag push below would otherwise bless foreign - # bytes (e.g. a previous run's tag push failed, new commits landed, - # and a fresh plan recomputed the same version from a newer tree). + # Idempotent, mirroring publish.ts's registry-probe intent: a re-run after a post-publish + # failure must not die on EPUBLISHCONFLICT. The skip is only safe when the registry's bytes + # match the reviewed artifact, since the tag push below would otherwise bless foreign bytes. - name: Publish to npm working-directory: ${{ runner.temp }}/config-release run: | @@ -298,18 +272,12 @@ jobs: npm publish "./${tarball}" --ignore-scripts --provenance --tag "${NPM_TAG}" fi - # "Published successfully" for the notification jobs below means - # REGISTRY-VISIBLE with the reviewed bytes: probe npm until the version - # resolves (the registry can lag a publish by a few seconds) and its - # integrity matches the reviewed tarball. Runs before the tag push so a - # tag is never blessed for bytes this run couldn't confirm on the - # registry; the dist-tag assertion deliberately lives AFTER the tag push - # so a dist-tag propagation hiccup can't strand the release live on npm - # with origin untagged. A transient failure here is safe to re-run — the - # publish step's registry probe skips the republish and this check - # repeats — though a re-run of this job re-arms the config-release - # approval gate and costs a second human approval — hence the generous - # (~2 minute) visibility budget. + # "Published successfully" for the notification jobs means registry-visible with the + # reviewed bytes: probe npm until the version resolves and its integrity matches. Runs + # before the tag push so a tag is never blessed for unconfirmed bytes; the dist-tag + # assertion instead lives after the tag push so a propagation hiccup can't strand the + # release published but origin-untagged. A re-run of this job re-arms the approval gate and + # costs a second human approval, hence the generous (~2 minute) visibility budget. - name: Verify the release is live on npm working-directory: ${{ runner.temp }}/config-release run: | @@ -347,12 +315,10 @@ jobs: 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). + # Pushes the tag as soon as npm has the bytes, before any downstream step that can fail — + # otherwise a later failure leaves origin untagged for a version already live on npm, and a + # subsequent plan would recompute the same version against stale bytes. Idempotent: skips + # if the tag is already on origin. - name: Push version tag run: | set -euo pipefail @@ -364,11 +330,10 @@ jobs: git push origin "${tag}" fi - # Asserted after the tag push on purpose: npm already holds the reviewed - # bytes (verified above), so a dist-tag propagation hiccup must not - # strand the release npm-published but origin-untagged. Retries until - # the tag points at THIS version — a stale packument still echoing the - # previous version is a retryable state, not a terminal mismatch. + # Asserted after the tag push on purpose: npm already holds the reviewed bytes (verified + # above), so a dist-tag propagation hiccup must not strand the release npm-published but + # origin-untagged. Retries until the tag points at this version, since a stale packument is + # a retryable state, not a terminal mismatch. - name: Verify the npm dist-tag run: | set -euo pipefail @@ -406,13 +371,10 @@ jobs: # become the repo's "latest" release. make_latest: "false" - # Pages the release Slack channel the moment a real run arms the - # config-release approval gate. This job only needs `plan`, so it runs in - # parallel with the publish job's `waiting` state — the ping and the pending - # deployment appear together. The approval itself stays on GitHub: the run - # page hosts the Approve button and the plan job's evidence summary; the - # webhook is one-way and cannot host an interactive approval. Nothing - # depends on this job, so a Slack/webhook failure can't block the release. + # Pages Slack the moment a real run arms the config-release approval gate. Runs in parallel + # with the publish job's `waiting` state (needs only `plan`), so the ping and the pending + # deployment appear together — the approval itself stays on GitHub, since the webhook is + # one-way and can't host an interactive approval. notify-slack-approval: name: Notify Slack (approval needed) needs: plan @@ -426,12 +388,9 @@ jobs: secrets: SLACK_RELEASE_WEBHOOK: ${{ secrets.SLACK_RELEASE_WEBHOOK }} - # Posts once the publish job has verified the release is registry-visible - # with the reviewed bytes. No `if:` needed: the implicit success() gate - # means this only runs when plan and publish both succeeded, and publish - # itself only runs for real (non-dry) releases — dry runs and no-release - # pushes skip publish, which skips this too. Nothing depends on this job, - # so a Slack/webhook failure can't affect the already-completed release. + # Posts once publish has verified the release is registry-visible with the reviewed bytes. No + # `if:` needed: the implicit success() gate means this only runs when plan and publish both + # succeeded, and publish itself only runs for real releases. notify-slack: name: Notify Slack needs: [plan, publish] @@ -445,14 +404,10 @@ jobs: secrets: SLACK_RELEASE_WEBHOOK: ${{ secrets.SLACK_RELEASE_WEBHOOK }} - # Distinguishes "a reviewer rejected the pending deployment" from a real - # pipeline failure before paging the channel: a rejection marks the publish - # job failed, and announcing that as a broken release would page people - # about a deliberate decision. The run's approvals record is the only place - # the distinction is visible from inside the workflow. The dry-run guard - # reads the dispatch input directly rather than plan's output, so a plan - # job that dies before recording dry_run still can't page for an - # operator-watched dry run. + # Distinguishes a reviewer's rejection from a real pipeline failure before paging the channel — + # a rejection marks the publish job failed, and announcing that as a broken release would page + # people about an intentional call. The dry-run guard reads the dispatch input directly, so a + # plan job that dies before recording `dry_run` still can't page for an operator-watched run. classify-failure: name: Classify failure needs: [plan, publish] @@ -493,14 +448,10 @@ jobs: echo "status=failure" >> "$GITHUB_OUTPUT" fi - # Reports a failed (or reviewer-declined) release. `failure()` on the - # classify job evaluates against its `needs` chain, so this pair fires - # whenever `plan` or `publish` fails — including an approval rejection — - # but stays quiet for dry runs and no-release pushes (skipped needs don't - # count as failures). When `plan` fails its outputs are empty, so the - # message falls back to the workflow run link as the actionable detail. - # Fails open: if the classifier itself breaks, the page still goes out as a - # plain failure (its status output is empty, so the expression falls back). + # Reports a failed (or reviewer-declined) release. `failure()` on the classify job evaluates + # against its `needs` chain, so this pair fires whenever `plan` or `publish` fails — including + # an approval rejection — but stays quiet for dry runs and no-release pushes. Fails open: if + # the classifier itself breaks, the page still goes out as a plain failure. notify-slack-failure: name: Notify Slack (failure) needs: [plan, publish, classify-failure] diff --git a/.github/workflows/release-shared.yml b/.github/workflows/release-shared.yml index 3ad7fec72e..57b1d3e8a5 100644 --- a/.github/workflows/release-shared.yml +++ b/.github/workflows/release-shared.yml @@ -7,10 +7,6 @@ on: description: npm package version to publish required: true type: string - shell: - description: CLI shell to package as the shipped supabase binary - required: true - type: string npm_tag: description: npm dist-tag to publish under required: true @@ -59,13 +55,14 @@ on: required: false LINEAR_CLI_BETA_RELEASE_ACCESS_KEY: required: false + SUPABASE_E2E_CLI_LIVE_STAGING_ACCESS_TOKEN: + required: false jobs: build-blacksmith: name: Build CLI artifacts (Blacksmith) uses: ./.github/workflows/build-cli-artifacts.yml with: version: ${{ inputs.version }} - shell: ${{ inputs.shell }} secrets: SENTRY_DSN: ${{ secrets.SENTRY_DSN }} POSTHOG_API_KEY: ${{ secrets.POSTHOG_API_KEY }} @@ -77,7 +74,6 @@ jobs: uses: ./.github/workflows/build-cli-artifacts.yml with: version: ${{ inputs.version }} - shell: ${{ inputs.shell }} runner: large-linux-x86 cache_key_suffix: -github timeout_minutes: 45 @@ -119,16 +115,14 @@ jobs: path: | packages/cli-*/bin/ dist/ - key: cli-build-${{ github.run_id }}-${{ inputs.shell }}-${{ inputs.version }}-v1 + key: cli-build-${{ github.run_id }}-${{ inputs.version }}-v1 enableCrossOsArchive: true fail-on-cache-miss: true - # Docker's classic image store keeps a single platform manifest per - # tag, so pulling `alpine:3.21` for amd64 and again for arm64 leaves - # only the most recent one in the local store — and `docker save` - # captures only that one. Enabling the containerd snapshotter lets - # the store hold multi-platform manifests under a single tag, which - # is what makes the cache step below work for both arches. + # Docker's classic image store keeps a single platform manifest per tag, so pulling + # `alpine:3.21` for amd64 then arm64 leaves only the most recent in the local store, and + # `docker save` captures only that one. The containerd snapshotter lets the store hold + # multi-platform manifests under a single tag instead. - name: Enable containerd image store if: runner.os == 'Linux' run: | @@ -137,17 +131,12 @@ jobs: sudo systemctl restart docker docker info --format '{{.DriverStatus}}' - # The host's binfmt_misc must be mounted BEFORE installing QEMU. On cold - # Blacksmith VMs it is not mounted by default; the privileged - # tonistiigi/binfmt installer that setup-qemu-action runs then registers - # the qemu interpreters inside its own mount namespace — it prints - # "installing: arm64 OK" but the handlers vanish when the container exits, - # so the host kernel never gains arm64 emulation and every - # `docker run --platform linux/arm64` dies with "exec format error". - # Warm/reused VMs already had it mounted, which is why this only failed - # intermittently (on cache-miss, i.e. cold-VM, runs). Mount it on the host - # after the docker restart so the registration lands in the host kernel - # and persists through to the smoke tests. + # The host's binfmt_misc must be mounted before installing QEMU. On cold Blacksmith VMs it + # isn't mounted by default, so setup-qemu-action's privileged installer registers the qemu + # interpreters inside its own mount namespace — they vanish when the container exits, and + # `docker run --platform linux/arm64` fails with "exec format error" (only on cold-VM runs, + # since warm VMs already have it mounted). Mount it on the host after the docker restart so + # the registration lands in the host kernel. - name: Ensure binfmt_misc is mounted on the host if: runner.os == 'Linux' run: | @@ -160,7 +149,7 @@ jobs: - name: Setup QEMU for cross-platform Docker if: runner.os == 'Linux' - uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0 + uses: docker/setup-qemu-action@1f40c72289eff860ee54a304f1438e3cff362e0a # v4.3.0 - name: Verify linux/arm64 emulation is registered if: runner.os == 'Linux' @@ -171,11 +160,10 @@ jobs: # the host kernel. Reads binfmt_misc directly — no image pull, no network. grep -q enabled /proc/sys/fs/binfmt_misc/qemu-aarch64 - # Cache the smoke-test base images across runs. Without this, eight - # parallel `docker run` calls in smoke-test-linux.ts race on first-time - # pulls and surface as docker exit 125 ("daemon could not start the - # container") on a subset of jobs. Bump the `-v` suffix to invalidate - # the cache when the image list or tags change. + # Caches the smoke-test base images across runs — without it, eight parallel `docker run` + # calls in smoke-test-linux.ts race on first-time pulls and fail with docker exit 125 + # ("daemon could not start the container"). Bump the `-v` suffix to invalidate when the + # image list or tags change. - name: Cache smoke-test docker images if: runner.os == 'Linux' id: smoke-docker-cache @@ -223,9 +211,8 @@ jobs: run: chmod +x packages/cli-*/bin/supabase || true - name: Run smoke tests - # Force bash so ${VERSION}/${NPM_TAG} expand identically across the - # ubuntu/macos/windows matrix — windows-latest defaults to pwsh, which - # treats those as empty PowerShell variables (env vars are `$env:VAR`). + # Force bash so ${VERSION}/${NPM_TAG} expand identically across the ubuntu/macos/windows + # matrix — windows-latest defaults to pwsh, which treats those as empty PowerShell variables. shell: bash run: pnpm run test:smoke -- --version "${VERSION}" --tag "${NPM_TAG}" working-directory: apps/cli @@ -255,7 +242,7 @@ jobs: path: | packages/cli-*/bin/ dist/ - key: cli-build-${{ github.run_id }}-${{ inputs.shell }}-${{ inputs.version }}-github-v1 + key: cli-build-${{ github.run_id }}-${{ inputs.version }}-github-v1 enableCrossOsArchive: true fail-on-cache-miss: true @@ -267,17 +254,25 @@ jobs: run: pnpm run test:smoke -- --version "${VERSION}" --tag "${NPM_TAG}" working-directory: apps/cli + live-e2e-gate: + name: Verify live e2e before stable publication + if: ${{ inputs.channel == 'stable' && !inputs.dry_run }} + uses: ./.github/workflows/live-e2e-gate.yml + with: + sha: ${{ github.sha }} + secrets: + SUPABASE_E2E_CLI_LIVE_STAGING_ACCESS_TOKEN: ${{ secrets.SUPABASE_E2E_CLI_LIVE_STAGING_ACCESS_TOKEN }} + publish: needs: - build-github - smoke-test - smoke-test-macos - if: ${{ !inputs.dry_run }} - # 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. + - live-e2e-gate + if: ${{ !cancelled() && !inputs.dry_run && needs.build-github.result == 'success' && needs.smoke-test.result == 'success' && needs.smoke-test-macos.result == 'success' && (inputs.channel != 'stable' || needs.live-e2e-gate.result == 'success') }} + # npm provenance verification rejects non-GitHub-hosted runners with E422; Blacksmith runners + # count as self-hosted from sigstore's POV, so this job must stay on a github-hosted runner + # (it's short and not compute-bound, so the cost is negligible). runs-on: ubuntu-latest timeout-minutes: 45 env: @@ -317,7 +312,7 @@ jobs: path: | packages/cli-*/bin/ dist/ - key: cli-build-${{ github.run_id }}-${{ inputs.shell }}-${{ inputs.version }}-github-v1 + key: cli-build-${{ github.run_id }}-${{ inputs.version }}-github-v1 enableCrossOsArchive: true fail-on-cache-miss: true @@ -332,13 +327,10 @@ jobs: git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - # Push the version 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 (or anything after) leaves origin with no tag for the version that - # is now live on npm — and `semantic-release --dry-run` on the next push - # will keep recomputing the same version, causing the publish job to no-op - # 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). + # Pushes the tag as soon as npm has the bytes, before any downstream step that can fail — + # otherwise a later failure leaves origin untagged for a version already live on npm, and + # `semantic-release --dry-run` on the next push keeps recomputing the same version. + # Idempotent: skips if the tag is already on origin. - name: Push version tag run: | set -euo pipefail @@ -350,37 +342,32 @@ jobs: git push origin "${tag}" fi - # semantic-release matches tags against branch.channel using git-notes - # under refs/notes/semantic-release-. Without this note, the develop - # plan job recomputes the same prerelease version every push (see - # docs/ci-beta-tags-investigation.md). Stable releases on main don't - # need this — main's branch config has no explicit channel. + # semantic-release matches tags against branch.channel using git-notes under + # refs/notes/semantic-release-; without this note, the develop plan job recomputes + # the same prerelease version every push (see docs/ci-beta-tags-investigation.md). Stable + # releases on main don't need this, since main's branch config has no explicit channel. # - # The note must be attached to the underlying commit, not the annotated - # tag object created by "Push version tag" above. semantic-release reads - # notes via `git log --no-walk --tags=* --notes=...`, which looks for - # notes on commits; a note on the tag object is invisible to it and - # leaves the tag with channels:[null], so the prerelease arm of - # getLastRelease's filter rejects it and the plan falls back to the - # last stable. Hence the `${tag}^{commit}` deref. + # The note must attach to the underlying commit, not the annotated tag object created by + # "Push version tag" above — semantic-release reads notes via `git log --no-walk + # --tags=* --notes=...`, which looks at commits, so a note on the tag object leaves the tag + # with channels:[null] and the plan falls back to the last stable. Hence the + # `${tag}^{commit}` deref. - name: Push channel note for prerelease tags if: ${{ inputs.prerelease }} run: | set -euo pipefail tag="v${VERSION}" - channel="${CHANNEL}" note_ref="refs/notes/semantic-release-${tag}" if git ls-remote origin "${note_ref}" | grep -q .; then echo "Channel note ${note_ref} already on origin; skipping push." else - git notes --ref="${note_ref}" add -f -m "{\"channels\":[\"${channel}\"]}" "${tag}^{commit}" + git notes --ref="${note_ref}" add -f -m "{\"channels\":[\"${CHANNEL}\"]}" "${tag}^{commit}" git push origin "${note_ref}" fi - # goreleaser used to publish both supabase___.tar.gz and - # supabase__.tar.gz; setup-cli, install scripts, docs and various - # third-party tools point at releases/latest/download/.tar.gz. - # Re-publish the alias so those callers keep working. See supabase/cli#5257. + # setup-cli, install scripts, docs, and other tools reference + # releases/latest/download/.tar.gz, so republish that alias alongside the + # versioned tarball. - name: Create unversioned tarball aliases run: | set -euo pipefail @@ -426,7 +413,7 @@ jobs: - name: Sync stable release to Linear if: ${{ inputs.channel == 'stable' && env.LINEAR_CLI_STABLE_RELEASE_ACCESS_KEY != '' }} - uses: linear/linear-release-action@3f31fcf14c110cc53579fcc3575a26d469c413b4 # v0 + uses: linear/linear-release-action@53ad0f863963e7f8e270fba18426bbb55ef55384 # v0 with: access_key: ${{ env.LINEAR_CLI_STABLE_RELEASE_ACCESS_KEY }} version: v${{ inputs.version }} @@ -437,7 +424,7 @@ jobs: - name: Sync beta release to Linear if: ${{ inputs.channel == 'beta' && env.LINEAR_CLI_BETA_RELEASE_ACCESS_KEY != '' }} - uses: linear/linear-release-action@3f31fcf14c110cc53579fcc3575a26d469c413b4 # v0 + uses: linear/linear-release-action@53ad0f863963e7f8e270fba18426bbb55ef55384 # v0 with: access_key: ${{ env.LINEAR_CLI_BETA_RELEASE_ACCESS_KEY }} version: v${{ inputs.version }} @@ -459,11 +446,10 @@ jobs: secrets: DF_FIREWALL_TOKEN: ${{ secrets.DF_FIREWALL_TOKEN }} - # Once the raw semantic-release block is in the release body, ask Claude to - # rewrite it into user-centric notes and open a PR for human approval. Stable - # releases only on this path — prereleases keep the raw body. Non-blocking so - # an LLM hiccup never gates a published release; reviewers can propose beta - # notes manually from the Actions tab (workflow_dispatch). + # Once the raw semantic-release block is in the release body, asks Claude to rewrite it into + # user-centric notes and open a PR for human approval. Stable only — prereleases keep the raw + # body. Non-blocking so an LLM hiccup never gates a published release; reviewers can propose + # beta notes manually via workflow_dispatch. propose-release-notes: uses: ./.github/workflows/propose-release-notes.yml needs: backfill-release-notes @@ -497,19 +483,17 @@ jobs: with: dependency-firewall-token: ${{ secrets.DF_FIREWALL_TOKEN }} - # Must restore the github-hosted build (-github-v1), the same artifacts - # the publish job uploads to the GitHub Release. The Bun-compiled binaries - # are not byte-for-byte reproducible across the blacksmith and github - # builds, so the blacksmith dist/checksums.txt does not match the released - # tarballs. Reading it here produced a formula whose sha256 rejected the - # downloaded archive ("Formula reports different checksum"). + # Restores the github-hosted build (-github-v1) — the same artifacts the publish job + # uploads to the GitHub Release. Bun-compiled binaries aren't byte-for-byte reproducible + # across the blacksmith and github builds, so the blacksmith checksums.txt doesn't match + # the released tarballs, and a formula built from it rejects the downloaded archive. - name: Restore build artifacts cache uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | packages/cli-*/bin/ dist/ - key: cli-build-${{ github.run_id }}-${{ inputs.shell }}-${{ inputs.version }}-github-v1 + key: cli-build-${{ github.run_id }}-${{ inputs.version }}-github-v1 enableCrossOsArchive: true fail-on-cache-miss: true @@ -560,19 +544,17 @@ jobs: with: dependency-firewall-token: ${{ secrets.DF_FIREWALL_TOKEN }} - # Must restore the github-hosted build (-github-v1), the same artifacts - # the publish job uploads to the GitHub Release. The Bun-compiled binaries - # are not byte-for-byte reproducible across the blacksmith and github - # builds, so the blacksmith dist/checksums.txt does not match the released - # tarballs. Reading it here would produce a manifest whose hash rejects the - # downloaded archive. + # Restores the github-hosted build (-github-v1) — the same artifacts the publish job + # uploads to the GitHub Release. Bun-compiled binaries aren't byte-for-byte reproducible + # across the blacksmith and github builds, so the blacksmith checksums.txt doesn't match + # the released tarballs, and a manifest built from it rejects the downloaded archive. - name: Restore build artifacts cache uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | packages/cli-*/bin/ dist/ - key: cli-build-${{ github.run_id }}-${{ inputs.shell }}-${{ inputs.version }}-github-v1 + key: cli-build-${{ github.run_id }}-${{ inputs.version }}-github-v1 enableCrossOsArchive: true fail-on-cache-miss: true @@ -602,18 +584,12 @@ jobs: GH_TOKEN: ${{ steps.app-token.outputs.token }} GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} - # Post-publish smoke test for the `supabase/setup-cli` GitHub Action against - # the just-released CLI. Runs last and intentionally does not gate - # publish-homebrew / publish-scoop — by the time the smoke runs, the npm - # package and GitHub release are already live and the brew/scoop pushes - # have either succeeded or skipped, so a setup-cli regression surfaces as - # a red post-release signal without holding back the rest of the channel. - # - # Depends on the publish jobs only via `needs` for ordering; the `if` - # uses `always() && needs.publish.result == 'success'` so the smoke still - # runs when publish-homebrew / publish-scoop are skipped or fail. - # The reusable workflow can also be dispatched manually against any - # already-published version when debugging setup-cli regressions. + # Post-publish smoke test for the `supabase/setup-cli` GitHub Action against the just-released + # CLI. Runs last and intentionally doesn't gate publish-homebrew/publish-scoop — by the time it + # runs, the npm package and GitHub release are already live, so a setup-cli regression surfaces + # as a red post-release signal without holding back the rest of the channel. `needs` is for + # ordering only; `always() && needs.publish.result == 'success'` lets it run even when + # publish-homebrew/publish-scoop are skipped or fail. setup-cli-smoke: needs: [publish, publish-homebrew, publish-scoop] if: ${{ always() && !inputs.dry_run && needs.publish.result == 'success' }} @@ -621,16 +597,11 @@ jobs: with: version: ${{ inputs.version }} - # Post-publish end-to-end check that the Homebrew tap, Scoop bucket, and the - # curl|bash install script actually install the just-released CLI. brew/scoop - # verify the published checksum against the downloaded tarball, so this is the - # signal that would have caught CLI v2.107.0 (mismatched brew/scoop sha256s). - # - # Only runs when brew/scoop were published (beta/stable) and both pushes - # succeeded. A channel that skipped brew/scoop entirely is covered by the GitHub - # Release download path in setup-cli-smoke. Like setup-cli-smoke, it runs last - # and does not gate the rest of the channel: by the time it runs the manifests - # are already live, so a failure surfaces as a red post-release signal. + # Post-publish check that the Homebrew tap, Scoop bucket, and curl|bash install script + # actually install the just-released CLI — brew/scoop verify the published checksum against + # the downloaded tarball, catching a mismatch like CLI v2.107.0. Only runs when brew/scoop were + # published and both pushes succeeded; a skipped channel is covered by the GitHub Release + # download path in setup-cli-smoke. Runs last and doesn't gate the rest of the channel. verify-install-channels: needs: [publish, publish-homebrew, publish-scoop] if: ${{ always() && !inputs.dry_run && inputs.publish_brew_scoop && needs.publish-homebrew.result == 'success' && needs.publish-scoop.result == 'success' }} diff --git a/.github/workflows/release-smoke-test.yml b/.github/workflows/release-smoke-test.yml index a21df3e0cf..a9581a9e1e 100644 --- a/.github/workflows/release-smoke-test.yml +++ b/.github/workflows/release-smoke-test.yml @@ -8,13 +8,6 @@ on: required: false type: string default: 0.0.0-smoke - shell: - description: CLI shell to package as the shipped supabase binary - required: false - type: choice - options: - - legacy - default: legacy npm_tag: description: npm tag to use for local package smoke tests required: false @@ -28,8 +21,10 @@ on: permissions: # release-shared.yml declares privileged publish jobs. They are gated by # dry_run here, but GitHub validates nested-workflow permissions at startup. + actions: read contents: write id-token: write + pull-requests: write jobs: smoke: @@ -37,7 +32,6 @@ jobs: uses: ./.github/workflows/release-shared.yml with: version: ${{ inputs.version }} - shell: ${{ inputs.shell }} npm_tag: ${{ inputs.npm_tag }} channel: beta prerelease: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b3703b7f8d..7f043e20c4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -25,18 +25,16 @@ on: description: npm package version to publish (must be unique on npm; pick the next unused version) required: true type: string - # Defaults to `true` so a stray "Run workflow" click can't accidentally - # publish — operators recovering from a stale-bytes run must consciously - # untick this when dispatching. + # Defaults to `true` so a stray "Run workflow" click can't accidentally publish; operators + # must consciously untick this when dispatching. dry_run: description: Dry run (skip actual publishing) required: false type: boolean default: true -# The release pipeline mutates external package registries, git tags, GitHub -# Releases, Homebrew, and Scoop. Keep one release per ref active at a time so -# duplicate push events cannot race the same computed version. +# The release pipeline mutates external registries, git tags, GitHub Releases, Homebrew, and +# Scoop, so keep one release per ref active at a time to avoid duplicate push events racing. concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: false @@ -82,7 +80,6 @@ jobs: outputs: should_release: ${{ steps.compute.outputs.should_release }} version: ${{ steps.compute.outputs.version }} - shell: ${{ steps.compute.outputs.shell }} npm_tag: ${{ steps.compute.outputs.npm_tag }} prerelease: ${{ steps.compute.outputs.prerelease }} brew_name: ${{ steps.compute.outputs.brew_name }} @@ -91,11 +88,9 @@ jobs: dry_run: ${{ steps.compute.outputs.dry_run }} channel: ${{ steps.compute.outputs.channel }} 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`/`main` branches. The default - # GITHUB_TOKEN doesn't, so we mint an App-installation token from the - # same App used for fast-forward + brew/scoop pushes. + # semantic-release runs `git push --dry-run HEAD:` as part of verifyAuth even in + # `dry_run: true` mode, so the token needs push access to the protected develop/main + # branches — the default GITHUB_TOKEN doesn't have it. - id: app-token if: github.event_name == 'push' uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 @@ -103,11 +98,9 @@ jobs: 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. + # `persist-credentials: false` is required: otherwise checkout caches the default + # GITHUB_TOKEN in git config, and its Authorization header overrides the App token + # semantic-release puts in the push URL, making the dry-push get rejected by branch protection. - uses: useblacksmith/checkout@6fd481652155169ed4d2f25ebaf97464f685175f # v1 with: fetch-depth: 0 @@ -152,7 +145,6 @@ jobs: fi case "$channel" in beta) - shell=legacy npm_tag=beta prerelease=true brew_name=supabase-beta @@ -160,7 +152,6 @@ jobs: publish_brew_scoop=true ;; stable) - shell=legacy npm_tag=latest prerelease=false brew_name=supabase @@ -175,7 +166,6 @@ jobs: { echo "should_release=$should_release" echo "version=$version" - echo "shell=$shell" echo "npm_tag=$npm_tag" echo "prerelease=$prerelease" echo "brew_name=$brew_name" @@ -189,19 +179,17 @@ jobs: name: Release needs: plan if: needs.plan.outputs.should_release == 'true' - # pull-requests: write is required by the nested propose-release-notes - # workflow (release-shared.yml -> propose-release-notes.yml). For nested - # reusable workflows, a called job's permissions can't exceed those granted - # to the calling job, so this must be declared here even though the propose - # job uses an App token for its actual PR creation. + # pull-requests: write is required by the nested propose-release-notes workflow + # (release-shared.yml -> propose-release-notes.yml): a called job's permissions can't exceed + # those granted to the calling job, so this must be declared here too. permissions: + actions: read contents: write id-token: write pull-requests: write uses: ./.github/workflows/release-shared.yml with: version: ${{ needs.plan.outputs.version }} - shell: ${{ needs.plan.outputs.shell }} npm_tag: ${{ needs.plan.outputs.npm_tag }} prerelease: ${{ needs.plan.outputs.prerelease == 'true' }} publish_brew_scoop: ${{ needs.plan.outputs.publish_brew_scoop == 'true' }} @@ -218,13 +206,11 @@ jobs: DF_FIREWALL_TOKEN: ${{ secrets.DF_FIREWALL_TOKEN }} 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 }} + SUPABASE_E2E_CLI_LIVE_STAGING_ACCESS_TOKEN: ${{ secrets.SUPABASE_E2E_CLI_LIVE_STAGING_ACCESS_TOKEN }} - # 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. + # Republishes the supabase.com CLI reference. 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] @@ -258,9 +244,8 @@ jobs: 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` 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. shell: bash env: GH_TOKEN: ${{ steps.app-token.outputs.token }} @@ -274,12 +259,10 @@ jobs: 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. - # The `if:` then filters to real (non-dry-run) stable cuts; beta and dry - # runs stay silent. Nothing depends on this job, so a Slack/webhook - # failure can't affect the already-completed release. + # 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; the `if:` then filters to real + # (non-dry-run) stable cuts. Nothing depends on this job, so a Slack failure can't affect the + # already-completed release. notify-slack: name: Notify Slack needs: [plan, release] @@ -295,15 +278,11 @@ jobs: secrets: SLACK_RELEASE_WEBHOOK: ${{ secrets.SLACK_RELEASE_WEBHOOK }} - # Reports a broken release on every channel. `failure()` evaluates against the - # `needs` chain, so this fires whenever `plan` or `release` (and anything in - # the reusable release-shared workflow) fails. Skipped jobs — e.g. the - # fast-forward path or a release that never started — don't count as failures, - # so this stays quiet there. Dry runs are excluded — the guard reads the - # dispatch input directly, so even a plan job that dies before recording its - # dry_run output stays quiet; an operator running one is already watching it - # live. When `plan` fails its outputs are empty, so the message falls back to - # the workflow run link as the actionable detail. + # Reports a broken release on every channel. `failure()` evaluates against the full `needs` + # chain, so this fires whenever `plan` or `release` (including the reusable release-shared + # workflow) fails. Skipped jobs — e.g. the fast-forward path, or a release that never started — + # don't count as failures. Dry runs are excluded via the dispatch input directly, so even a + # plan job that dies before recording its `dry_run` output stays quiet. notify-slack-failure: name: Notify Slack (failure) needs: [plan, release] diff --git a/.github/workflows/run-ci.yml b/.github/workflows/run-ci.yml index c3dffb9788..49abc78abd 100644 --- a/.github/workflows/run-ci.yml +++ b/.github/workflows/run-ci.yml @@ -1,11 +1,12 @@ name: run-ci -# Opt-in full develop CI for PRs that Test.yml / preview do not already cover: -# stacked PRs (base is not develop) and drafts. Ready develop PRs stay on the -# existing workflows so required check names and concurrency are unchanged. +# Opt-in full develop CI for PRs that Test.yml does not already cover: stacked +# PRs (base is not develop) and drafts. Ready develop PRs stay on the existing +# workflows so required check names and concurrency are unchanged. # # Add the `run-ci` label to start the suite; remove it to cancel in-progress # runs via this workflow's concurrency group. Other labels do not retrigger. +# Preview packages are a separate opt-in (`run-preview-packages`). on: pull_request: types: @@ -20,7 +21,6 @@ on: permissions: actions: read contents: read - pull-requests: write # Unrelated label events still start a run; give them a unique group so they # cannot cancel an in-progress suite. Removing `run-ci` stays on the main @@ -50,18 +50,3 @@ jobs: force: true secrets: DF_FIREWALL_TOKEN: ${{ secrets.DF_FIREWALL_TOKEN }} - - preview: - name: Preview packages - if: | - !startsWith(github.head_ref, 'release-notes/') && - contains(github.event.pull_request.labels.*.name, 'run-ci') && - (github.event.pull_request.draft || github.base_ref != 'develop') && - ((github.event.action != 'labeled' && - github.event.action != 'unlabeled') || - github.event.label.name == 'run-ci') - uses: ./.github/workflows/publish-preview-cli-packages.yml - with: - force: true - secrets: - DF_FIREWALL_TOKEN: ${{ secrets.DF_FIREWALL_TOKEN }} diff --git a/.github/workflows/setup-cli-smoke-test.yml b/.github/workflows/setup-cli-smoke-test.yml index 1725e4ada2..b0b17fcd30 100644 --- a/.github/workflows/setup-cli-smoke-test.yml +++ b/.github/workflows/setup-cli-smoke-test.yml @@ -1,13 +1,9 @@ name: setup-cli Smoke Test -# Smoke test for the `supabase/setup-cli` GitHub Action against a specific -# published CLI version. Run automatically after every beta release (called -# from release-shared.yml's `setup-cli-smoke` job) and also manually via -# workflow_dispatch when debugging setup-cli regressions or verifying that -# a previously broken environment (e.g. Alpine) is back to green. -# -# Exists primarily to catch regressions like supabase/setup-cli#427 where -# musl libc + archive layout changes broke the action on Alpine silently. +# Smoke test for the `supabase/setup-cli` GitHub Action against a specific published CLI +# version. Runs automatically after every beta release and also manually via workflow_dispatch +# when debugging setup-cli regressions. Catches regressions like supabase/setup-cli#427, where +# musl libc + archive layout changes silently broke the action on Alpine. on: workflow_call: @@ -36,9 +32,8 @@ jobs: - ubuntu-latest - macos-latest - windows-latest - # GitHub Actions doesn't allow expressions in `uses:`, so the action - # ref is selected via two `if:`-gated install steps below rather than - # interpolating `matrix.major-version` into a single `uses:` line. + # GitHub Actions doesn't allow expressions in `uses:`, so the action ref is selected via + # two `if:`-gated install steps below instead of interpolating `matrix.major-version`. major-version: - v1 - v2 @@ -72,14 +67,10 @@ jobs: exit 1 fi - # Alpine leg of the setup-cli smoke test. Kept as a separate job because - # GitHub Actions only honours the `container:` field on Linux runners, and - # mixing container + non-container entries in a single matrix via - # `container: ${{ matrix.container }}` is fragile when the value is empty. - # - # The musl-vs-glibc + archive layout regressions tracked in - # supabase/setup-cli#427 only reproduce inside a real Alpine container, so - # this job is the actual signal the workflow was created for. + # A separate job because GitHub Actions only honours `container:` on Linux runners, and mixing + # container + non-container entries in one matrix is fragile when the value is empty. The + # musl-vs-glibc + archive layout regressions from supabase/setup-cli#427 only reproduce inside + # a real Alpine container. setup-cli-smoke-test-alpine: name: setup-cli ${{ matrix.major-version }} (alpine) strategy: @@ -90,9 +81,8 @@ jobs: - v2 runs-on: ubuntu-latest timeout-minutes: 10 - # `node:20-alpine` ships a musl-linked Node, which is required for - # JavaScript-based actions (setup-cli included) to launch inside an - # Alpine container — the runner's mounted glibc Node won't execute here. + # `node:20-alpine` ships a musl-linked Node, required for JavaScript-based actions to launch + # here — the runner's mounted glibc Node won't execute inside an Alpine container. container: image: node:20-alpine env: diff --git a/.github/workflows/sync-stack-service-versions.yml b/.github/workflows/sync-stack-service-versions.yml deleted file mode 100644 index b8cdb52e6e..0000000000 --- a/.github/workflows/sync-stack-service-versions.yml +++ /dev/null @@ -1,61 +0,0 @@ -name: Sync Stack Service Versions - -on: - pull_request: - types: - - opened - - synchronize - - reopened - paths: - - apps/cli-go/pkg/config/templates/Dockerfile - -permissions: - contents: read - -concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number }} - cancel-in-progress: true - -jobs: - sync: - name: Sync stack service versions - runs-on: blacksmith-2vcpu-ubuntu-2404 - if: github.event.pull_request.user.login == 'dependabot[bot]' && github.repository == github.event.pull_request.head.repo.full_name - steps: - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ github.event.pull_request.head.ref }} - persist-credentials: false - - - name: Setup - uses: ./.github/actions/setup - with: - dependency-firewall-token: ${{ secrets.DF_FIREWALL_TOKEN }} - - - name: Sync stack service versions - run: pnpm sync:versions - working-directory: packages/stack - - - name: Generate 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 - - - name: Commit synced stack service versions - env: - GH_APP_TOKEN: ${{ steps.app-token.outputs.token }} - run: | - if git diff --quiet -- packages/stack/src/ServiceCatalog.ts; then - echo "Stack service versions are already synced." - exit 0 - fi - - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add packages/stack/src/ServiceCatalog.ts - git commit -m "chore(stack): sync service version manifest" - git push "https://x-access-token:${GH_APP_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" "HEAD:${GITHUB_HEAD_REF}" diff --git a/.github/workflows/sync-stack-workload-catalog.yml b/.github/workflows/sync-stack-workload-catalog.yml new file mode 100644 index 0000000000..4ff9f7df1c --- /dev/null +++ b/.github/workflows/sync-stack-workload-catalog.yml @@ -0,0 +1,117 @@ +name: Sync Stack Workload Catalog + +# Pins `packages/stack/src/model/WorkloadCatalog.ts` to slim-services releases, +# off the same `mirror-slim-image` dispatch that drives the ECR mirror. +# Dependabot owns the Dockerfile and cannot own this table: these pins carry +# image digests, which tag resolution never produces (ADR 0017). +# +# Separate from `mirror-slim-image.yml` on purpose — that mirror runs against +# the sender's 15-minute verification poll and must not be delayed or reddened +# by a catalog PR. Opens a PR rather than pushing: `develop` is protected and a +# version pin is reviewable. Re-dispatches are no-ops, so retries do not open +# duplicate PRs. + +on: + repository_dispatch: + types: + - mirror-slim-image + workflow_dispatch: + inputs: + service: + description: "Service name (e.g. postgrest)" + required: true + type: string + version: + description: "Image tag (e.g. v16.2)" + required: true + type: string + digest: + description: "Expected index digest (sha256:<64 hex chars>)" + required: true + type: string + +permissions: + contents: read + +concurrency: + # Per service, so two releases of one service cannot race onto the shared + # branch. Not cancel-in-progress: a superseded run may already have opened it. + group: sync-stack-workload-catalog-${{ github.event.client_payload.service || inputs.service }} + cancel-in-progress: false + +jobs: + sync: + name: Sync workload catalog + runs-on: blacksmith-2vcpu-ubuntu-2404 + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Setup + uses: ./.github/actions/setup + with: + dependency-firewall-token: ${{ secrets.DF_FIREWALL_TOKEN }} + + # Payload is untrusted; the script revalidates it. Passed via env, never + # interpolated into the shell. + - name: Sync workload catalog + env: + SLIM_SERVICE: ${{ github.event.client_payload.service || inputs.service }} + SLIM_VERSION: ${{ github.event.client_payload.version || inputs.version }} + SLIM_DIGEST: ${{ github.event.client_payload.digest || inputs.digest }} + run: bun .github/scripts/sync-workload-catalog.ts + + - name: Format catalog + run: pnpm run fmt:fix + + - name: Check for catalog changes + id: check + run: | + if git diff --exit-code --quiet packages/stack/src/model/WorkloadCatalog.ts; then + echo "Workload catalog is already in sync." + echo "has_changes=false" >> "$GITHUB_OUTPUT" + else + echo "Workload catalog updated." + echo "has_changes=true" >> "$GITHUB_OUTPUT" + fi + + # Prove the rewritten pin compiles and passes the catalog's own tests. + - name: Type-check stack + if: steps.check.outputs.has_changes == 'true' + run: pnpm types:check + working-directory: packages/stack + + - name: Test catalog + if: steps.check.outputs.has_changes == 'true' + run: pnpm run test:unit && pnpm run test:integration + working-directory: packages/stack + + - name: Generate token + if: steps.check.outputs.has_changes == 'true' + 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-pull-requests: write + permission-contents: write + + - name: Create Pull Request + if: steps.check.outputs.has_changes == 'true' + uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 + with: + token: ${{ steps.app-token.outputs.token }} + commit-message: "chore(stack): pin ${{ github.event.client_payload.service || inputs.service }} to ${{ github.event.client_payload.version || inputs.version }}" + title: "chore(stack): pin ${{ github.event.client_payload.service || inputs.service }} to ${{ github.event.client_payload.version || inputs.version }}" + body: | + Pins `packages/stack/src/model/WorkloadCatalog.ts` to the + `${{ github.event.client_payload.service || inputs.service }}` `${{ github.event.client_payload.version || inputs.version }}` + slim-services release and its image digest. + + Opened from the `mirror-slim-image` dispatch. `artifactFor` derives the + native release tag, asset names, and URLs from service + version, so this + pin is the whole change. + branch: sync/stack-workload-catalog-${{ github.event.client_payload.service || inputs.service }} + base: develop diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index de3dec7ff4..5b482ac2fe 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -35,9 +35,8 @@ permissions: actions: read # Literal prefix: called workflows inherit github.workflow from the caller -# (`run-ci`), which would cancel the caller and the preview call. `inputs.force` -# keeps a skipped native Test run on a develop draft from cancelling the -# forced run-ci call. +# (`run-ci`), which would cancel the caller. `inputs.force` keeps a skipped +# native Test run on a develop draft from cancelling the forced run-ci call. concurrency: group: test.yml-${{ github.event.pull_request.number || github.head_ref || github.ref }}-${{ inputs.force && 'run-ci' || 'direct' }} cancel-in-progress: true @@ -122,6 +121,40 @@ jobs: - name: Run integration tests run: pnpm run test:integration --coverage.enabled + test-stack-ports: + if: | + !startsWith(github.head_ref, 'release-notes/') && + (github.event_name == 'merge_group' || + inputs.force || + github.event.pull_request.draft == false) + name: Run stack port tests (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [blacksmith-8vcpu-ubuntu-2404, macos-latest, windows-latest] + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Setup + uses: ./.github/actions/setup + with: + dependency-firewall-token: ${{ secrets.DF_FIREWALL_TOKEN }} + + - name: Run stack integration tests + working-directory: packages/stack + run: >- + pnpm test:integration + src/state/ports.integration.test.ts + src/state/registry-lease.integration.test.ts + src/state/state-store.integration.test.ts + src/supervisor/host-listener.integration.test.ts + src/supervisor/ingress.integration.test.ts + src/gateway/startup-transports.integration.test.ts + test-summary: if: | always() && @@ -130,17 +163,21 @@ jobs: inputs.force || github.event.pull_request.draft == false) name: Run unit and integration tests - needs: [test-unit, test-integration] + needs: [test-unit, test-integration, test-stack-ports] runs-on: ubuntu-latest steps: - name: Verify unit and integration tests succeeded run: | if [ "${{ needs.test-unit.result }}" = "failure" ] || [ "${{ needs.test-unit.result }}" = "cancelled" ] || \ [ "${{ needs.test-integration.result }}" = "failure" ] || [ "${{ needs.test-integration.result }}" = "cancelled" ]; then - echo "::error ::Unit or integration tests failed: unit=${{ needs.test-unit.result }}, integration=${{ needs.test-integration.result }}" + echo "::error ::Unit or integration tests failed: unit=${{ needs.test-unit.result }}, integration=${{ needs.test-integration.result }}, stack-ports=${{ needs.test-stack-ports.result }}" + exit 1 + fi + if [ "${{ needs.test-stack-ports.result }}" = "failure" ] || [ "${{ needs.test-stack-ports.result }}" = "cancelled" ]; then + echo "::error ::Stack port tests failed: stack-ports=${{ needs.test-stack-ports.result }}" exit 1 fi - echo "Unit and integration tests reported: unit=${{ needs.test-unit.result }}, integration=${{ needs.test-integration.result }}" + echo "Unit and integration tests reported: unit=${{ needs.test-unit.result }}, integration=${{ needs.test-integration.result }}, stack-ports=${{ needs.test-stack-ports.result }}" test-e2e: if: | @@ -148,7 +185,7 @@ jobs: (github.event_name == 'merge_group' || inputs.force || github.event.pull_request.draft == false) - name: Run end-to-end tests (shard ${{ matrix.shard }}/3) + name: Run CLI end-to-end tests (shard ${{ matrix.shard }}/3) runs-on: blacksmith-8vcpu-ubuntu-2404 strategy: fail-fast: false @@ -178,24 +215,46 @@ jobs: run: go build -o supabase-go . working-directory: apps/cli-go - # The ts-legacy harness invokes `node apps/cli/dist/supabase.js` with - # `SUPABASE_CLI_BINARY_OVERRIDE` pointing at the compiled legacy binary - # in `apps/cli/dist/`. Build the CLI explicitly before invoking every + # The e2e harness invokes `node apps/cli/dist/supabase.js` with + # `SUPABASE_CLI_BINARY_OVERRIDE` pointing at the compiled binary in + # `apps/cli/dist/`. Build the CLI explicitly before invoking every # package-local e2e suite. - name: Build CLI run: pnpm exec turbo run supabase#build - name: Run end-to-end tests - run: pnpm exec turbo run test:e2e:run --only --concurrency=1 -- --shard=${{ matrix.shard }}/3 + run: pnpm exec turbo run test:e2e:run --only --concurrency=1 --filter=supabase --filter=@supabase/cli-e2e -- --shard=${{ matrix.shard }}/3 env: - CLI_HARNESS_TARGET: ts-legacy SUPABASE_GO_BINARY: ${{ github.workspace }}/apps/cli-go/supabase-go - # Summary job that gates branch protection. The matrix `test-e2e` job - # produces per-shard check names (`Run end-to-end tests (shard N/3)`), so - # this job preserves the original `Run end-to-end tests` check name that - # branch protection rules already require. It succeeds iff every shard - # succeeded (or skipped — `success()` is true for skipped jobs). + test-stack-e2e: + if: | + !startsWith(github.head_ref, 'release-notes/') && + (github.event_name == 'merge_group' || + inputs.force || + github.event.pull_request.draft == false) + name: Run stack end-to-end tests (${{ matrix.runtime }}) + runs-on: blacksmith-8vcpu-ubuntu-2404 + strategy: + fail-fast: false + matrix: + runtime: [native, container] + steps: + - name: Checkout + uses: useblacksmith/checkout@6fd481652155169ed4d2f25ebaf97464f685175f # v1.0.0-beta + + - name: Setup + uses: ./.github/actions/setup + with: + dependency-firewall-token: ${{ secrets.DF_FIREWALL_TOKEN }} + + - name: Run stack end-to-end tests + run: pnpm --filter @supabase/stack test:e2e:run + env: + SUPABASE_STACK_E2E_RUNTIME: ${{ matrix.runtime }} + + # Summary job that gates branch protection. It preserves the original check + # name and succeeds iff every CLI shard and stack runtime succeeded. test-e2e-summary: if: | always() && @@ -204,13 +263,13 @@ jobs: inputs.force || github.event.pull_request.draft == false) name: Run end-to-end tests - needs: test-e2e + needs: [test-e2e, test-stack-e2e] runs-on: ubuntu-latest steps: - name: Verify all shards succeeded run: | - if [ "${{ needs.test-e2e.result }}" = "failure" ] || [ "${{ needs.test-e2e.result }}" = "cancelled" ]; then - echo "::error ::One or more e2e shards failed: ${{ needs.test-e2e.result }}" + if [ "${{ needs.test-e2e.result }}" = "failure" ] || [ "${{ needs.test-e2e.result }}" = "cancelled" ] || [ "${{ needs.test-stack-e2e.result }}" = "failure" ] || [ "${{ needs.test-stack-e2e.result }}" = "cancelled" ]; then + echo "::error ::One or more e2e jobs failed: cli=${{ needs.test-e2e.result }}, stack=${{ needs.test-stack-e2e.result }}" exit 1 fi - echo "All e2e shards reported: ${{ needs.test-e2e.result }}" + echo "All e2e jobs reported: cli=${{ needs.test-e2e.result }}, stack=${{ needs.test-stack-e2e.result }}" diff --git a/.github/workflows/verify-install-channels.yml b/.github/workflows/verify-install-channels.yml index cf5a6983bb..a3a94bc4fa 100644 --- a/.github/workflows/verify-install-channels.yml +++ b/.github/workflows/verify-install-channels.yml @@ -1,35 +1,14 @@ name: Verify Install Channels -# Post-publish end-to-end verification that the *published* install channels -# (Homebrew, Scoop, and the curl|bash install script) actually install the -# just-released CLI and serve artifacts whose checksums match what the channel -# manifests declare. Runs automatically after every brew/scoop publish (called -# from release-shared.yml's `verify-install-channels` job) and can also be -# dispatched manually against any already-published version when debugging an -# install regression. +# Post-publish verification that the published install channels (Homebrew, Scoop, curl|bash) +# install the just-released CLI and serve artifacts matching the channel manifests' checksums. +# Runs automatically after every brew/scoop publish, and can also be dispatched manually against +# an already-published version. # -# Exists primarily to catch regressions like CLI v2.107.0, where the Homebrew -# formula and Scoop manifest shipped sha256 checksums that did not match the -# tarballs on the GitHub Release, so `brew install` / `scoop install` failed -# for every user with "Formula reports different checksum". brew, scoop, and -# the install script all verify the declared checksum against the downloaded -# bytes before installing, so a real install reproduces that failure exactly -# instead of trusting the manifest the publish step wrote. -# -# Each leg goes beyond `supabase --version` (handled by the Bun wrapper without -# touching the sidecar) and directly checks that the `supabase-go` sidecar -# binary is present and executable in the channel's install directory, so a -# package that omits or misplaces it fails here instead of silently shipping -# a CLI whose still-Go-proxied commands (see docs/go-cli-porting-status.md) -# would fail for every user. -# -# This used to run `supabase completion bash`, since that command was -# Go-proxied. It no longer is (CLI-1965 ported shell completion to native -# TypeScript, and the Go CLI's own completion command was subsequently -# removed too), so that probe silently stopped testing the sidecar at all. -# Checking for the sidecar file directly instead of routing through some -# still-proxied command avoids repeating that mistake as more commands get -# natively ported. +# Performs a real install so a manifest/tarball checksum mismatch (as in CLI v2.107.0) reproduces +# here instead of being trusted from the publish step. Each leg also directly checks that the +# `supabase-go` sidecar binary is present and executable in the install directory, checking the +# file rather than a CLI command, since a command could later be natively ported off the sidecar. on: workflow_call: @@ -68,10 +47,8 @@ permissions: jobs: homebrew: - # macOS and Linux exercise different stanzas of the formula - # (`on_macos` vs `on_linux`), each with its own URL + sha256, so both must - # install for the tap to be considered verified. Homebrew is preinstalled - # on GitHub-hosted Ubuntu runners. + # macOS and Linux exercise different formula stanzas (`on_macos` vs `on_linux`), each with + # its own URL + sha256, so both must install for the tap to be considered verified. name: Homebrew ${{ inputs.brew_name }} (${{ matrix.runner }}) strategy: fail-fast: false @@ -84,14 +61,11 @@ jobs: env: VERSION: ${{ inputs.version }} BREW_NAME: ${{ inputs.brew_name }} - # Don't auto-update Homebrew itself before installing; `brew install - # //` taps supabase/homebrew-tap from its git HEAD - # regardless, so the freshly-pushed formula is picked up either way. + # Skips Homebrew's own auto-update; `brew install //` taps + # supabase/homebrew-tap from git HEAD regardless, so the fresh formula is picked up anyway. # - # NB: do NOT set HOMEBREW_NO_INSTALL_FROM_API here. It only affects the - # homebrew/core + cask taps (third-party taps are always read from git), - # and on Linux runners it forces a large, slow local checkout of - # homebrew/core that makes this job hang for 10+ minutes. + # Do not also set HOMEBREW_NO_INSTALL_FROM_API: it only affects homebrew/core + cask taps, + # and on Linux it forces a slow homebrew/core checkout that hangs this job for 10+ minutes. HOMEBREW_NO_AUTO_UPDATE: "1" steps: - name: Set up Homebrew on PATH @@ -205,9 +179,8 @@ jobs: scoop bucket add supabase https://github.com/supabase/scoop-bucket scoop install "supabase/$env:SCOOP_NAME" - name: Verify supabase --version - # Force bash so ${VERSION} expands the same way it does on the other - # legs — windows-latest defaults to pwsh, which treats it as an empty - # PowerShell variable (env vars are `$env:VAR`). + # Force bash so ${VERSION} expands the same way as the other legs — windows-latest + # defaults to pwsh, which treats it as an empty PowerShell variable. shell: bash run: | set -euo pipefail diff --git a/.gitignore b/.gitignore index 5a892f3aba..a1d49ff0cf 100644 --- a/.gitignore +++ b/.gitignore @@ -7,8 +7,10 @@ coverage/ !.env.example .claude/ .agents/.repos/effect-v3 +.repos/slim-services/ .worktrees/ .supabase/ +erl_crash.dump # Stray `supabase` project dir created by running the CLI at the repo root # (e.g. supabase/.temp/linked-project.json). This monorepo has no top-level # Supabase project — real fixtures live under apps/cli-e2e/fixtures/. @@ -23,6 +25,7 @@ packages/cli-*/bin/ # Turbo .turbo/ +apps/cli/.supabase/ # Transient render dir created by packages/api/scripts/generated-output-sync.unit.test.ts packages/api/.generated-output-sync-*/ diff --git a/.gitmodules b/.gitmodules index ec13d73342..084b300883 100644 --- a/.gitmodules +++ b/.gitmodules @@ -13,9 +13,6 @@ [submodule ".repos/cheffect"] path = .repos/cheffect url = https://github.com/tim-smart/cheffect.git -[submodule ".repos/process-compose"] - path = .repos/process-compose - url = https://github.com/F1bonacc1/process-compose.git [submodule ".repos/t3code"] path = .repos/t3code url = https://github.com/pingdotgg/t3code.git diff --git a/.husky/commit-msg b/.husky/commit-msg new file mode 100755 index 0000000000..2e6b87e2fc --- /dev/null +++ b/.husky/commit-msg @@ -0,0 +1 @@ +pnpm exec commitlint --edit "$1" diff --git a/.oxfmtrc.json b/.oxfmtrc.json index 0e2a3a42c0..cdddd6ab52 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -1,6 +1,7 @@ { "ignorePatterns": [ ".repos/", + "apps/cli/src/shared/feedback/database.types.ts", "apps/cli-go/", "apps/cli-e2e/fixtures/", "apps/docs/content/docs/commands/", diff --git a/.oxlintrc.effect.json b/.oxlintrc.effect.json index 7a1c87954f..9af397b453 100644 --- a/.oxlintrc.effect.json +++ b/.oxlintrc.effect.json @@ -4,5 +4,15 @@ "options": { "denyWarnings": true }, - "ignorePatterns": [] + "ignorePatterns": [ + "**", + "!packages/stack/**", + "!apps/cli/src/commands/experimental/stack/**", + "!apps/cli/src/commands/experimental/compute/**", + "!apps/cli/src/shared/compute/**", + // Last match wins, so this stays after the entry above. + "apps/cli/src/shared/compute/stacks/**", + "!apps/cli/tests/helpers/compute.ts", + "!apps/cli/src/command-internal/experimental-feature.ts" + ] } diff --git a/.oxlintrc.json b/.oxlintrc.json index 4d587a2f9a..8c10b88c04 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -7,9 +7,9 @@ "ignorePatterns": [ ".repos", "apps/cli-go", + // Generated by `supabase gen types` (see `gen:feedback-types` in apps/cli). + "apps/cli/src/shared/feedback/database.types.ts", "apps/cli-e2e/fixtures", - "packages/stack", - "packages/process-compose", "**/testdata", "**/dist", "**/coverage", @@ -17,11 +17,10 @@ "**/.source" ], "rules": { - // Spreading an Effect Data.Class instance into its constructor is the - // idiomatic copy-with-update pattern (`new State({ ...state, ... })`). + // `new State({ ...state, ... })` is the idiomatic Data.Class copy-with-update pattern. "typescript/no-misused-spread": "off", - // Effect combinators are passed as references by design, e.g. - // `.pipe(semaphore.withPermit)`; they do not rely on `this`. + // Effect combinators like `.pipe(semaphore.withPermit)` are passed as references by + // design and don't rely on `this`. "typescript/unbound-method": "off", // Too strict for CLI string building; `no-base-to-string` still guards // against `[object Object]` interpolation bugs. @@ -29,7 +28,7 @@ }, "overrides": [ { - // The legacy CLI is the stable, shipped shell; a lint-driven rewrite of + // The CLI is the stable, shipped tool; a lint-driven rewrite of // its existing string-building code isn't worth the churn. "files": ["apps/cli/src/**"], "rules": { @@ -37,13 +36,9 @@ } }, { - // `.github/scripts` is the only `bun:test` consumer in the repo (every - // package test suite uses vitest). `@types/bun`'s test matcher types - // reuse the same sync `Matchers` interface for `expect(x).rejects`, - // so `.rejects.toThrow(...)` types as returning `void` even though it - // must be awaited at runtime — a `@types/bun` typing gap, not a real - // `await`-of-non-Promise bug. Verified: `bun test` and `tsc --noEmit` - // both pass; removing the `await` would make the assertion racy. + // `.github/scripts` is the only `bun:test` consumer; `@types/bun`'s shared `Matchers` + // types `.rejects.toThrow(...)` as returning `void`, even though it must be awaited at + // runtime. Removing the `await` would make the assertion racy. "files": [".github/scripts/**"], "rules": { "typescript/await-thenable": "off" diff --git a/.repos/process-compose b/.repos/process-compose deleted file mode 160000 index a4038d6698..0000000000 --- a/.repos/process-compose +++ /dev/null @@ -1 +0,0 @@ -Subproject commit a4038d669818c35fc68fc7fc240b39e371ce0e7a diff --git a/AGENTS.md b/AGENTS.md index 9e313dc366..fd890afc1e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,395 +1,193 @@ # Supabase -Bun monorepo with workspaces under `apps/` and `packages/`. - -## Package Manager - -`pnpm` is the package manager. Use `pnpm