diff --git a/.github/scripts/sync-workload-catalog.test.ts b/.github/scripts/sync-workload-catalog.test.ts new file mode 100644 index 0000000000..9f4f129795 --- /dev/null +++ b/.github/scripts/sync-workload-catalog.test.ts @@ -0,0 +1,297 @@ +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}"`); + // The additional line must survive untouched. + 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}"`); + // The 17.x default is the regression this guards: it must not move. + 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"); + // postgres keeps both of its own pins. + 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, so postgres (multi-line) skips while + // every single-line service bumps. Either way it must be recognised. + 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/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/apps/cli/src/shared/services/slim-images.unit.test.ts b/apps/cli/src/shared/services/slim-images.unit.test.ts index ad80353d25..2f1e8de860 100644 --- a/apps/cli/src/shared/services/slim-images.unit.test.ts +++ b/apps/cli/src/shared/services/slim-images.unit.test.ts @@ -43,19 +43,19 @@ describe("toSlimImage", () => { ); }); - it("maps current docker.io pins onto the published slim tags", () => { - expect(toSlimImage("pg", dockerfileServiceImageRaw("pg"))).toBe( - "ghcr.io/supabase/cli/postgres:17.6.1.167", - ); - expect(toSlimImage("supavisor", dockerfileServiceImageRaw("supavisor"))).toBe( - "ghcr.io/supabase/cli/pooler:v2.9.12", - ); - expect(toSlimImage("realtime", dockerfileServiceImageRaw("realtime"))).toBe( + // Fixed pins, not manifest pins: dependabot bumps the manifest, so spelling + // out a current pin here would fail on every bump. The `it.each` above covers + // the part that must track it (the repository each alias maps to). + it("keeps a single v on pins already prefixed on docker.io", () => { + expect(toSlimImage("realtime", "supabase/realtime:v2.130.0")).toBe( "ghcr.io/supabase/cli/realtime:v2.130.0", ); - expect(toSlimImage("storage", dockerfileServiceImageRaw("storage"))).toBe( + expect(toSlimImage("storage", "supabase/storage-api:v1.72.1")).toBe( "ghcr.io/supabase/cli/storage:v1.72.1", ); + expect(toSlimImage("gotrue", "supabase/gotrue:V2.196.0")).toBe( + "ghcr.io/supabase/cli/auth:v2.196.0", + ); }); it("v-prefixes pins whose slim tag scheme differs from docker.io's", () => { diff --git a/packages/stack/src/model/WorkloadCatalog.ts b/packages/stack/src/model/WorkloadCatalog.ts index 20077420ac..4a3e880524 100644 --- a/packages/stack/src/model/WorkloadCatalog.ts +++ b/packages/stack/src/model/WorkloadCatalog.ts @@ -52,7 +52,13 @@ const native = ( containerAlias: options.containerAlias ?? `supabase-${service}`, }); -/** The single authoritative private workload identity table. */ +/** + * The single authoritative private workload identity table. + * + * Pins track the slim-services release feed (ADR 0017), not the Dockerfile, and + * deliberately diverge from it — do not "reconcile" the two. Maintained by + * `.github/workflows/sync-stack-workload-catalog.yml`. + */ const workloadCatalog = { "database:database": native( "postgres",