diff --git a/.github/scripts/slim-mirror-payload.ts b/.github/scripts/slim-mirror-payload.ts index 72f9587e8e..74191dfebf 100644 --- a/.github/scripts/slim-mirror-payload.ts +++ b/.github/scripts/slim-mirror-payload.ts @@ -4,8 +4,8 @@ */ const SERVICE_PATTERN = /^[a-z][a-z0-9-]*$/; -const VERSION_PATTERN = /^[A-Za-z0-9._-]+$/; -const DIGEST_PATTERN = /^sha256:[0-9a-f]{64}$/; +export const VERSION_PATTERN = /^[A-Za-z0-9._-]+$/; +export const DIGEST_PATTERN = /^sha256:[0-9a-f]{64}$/; export const SOURCE_REGISTRY = "ghcr.io/supabase/cli"; export const DEST_REGISTRY = "public.ecr.aws/supabase/cli"; @@ -31,7 +31,7 @@ export const nativeTagPattern = (version: string): RegExp => new RegExp(`^${escapeRegExp(version)}-native-(${NATIVE_TARGETS.join("|")})$`); /** `service` / `version` / `digest` are the image fields. Extra keys such as `natives[]` are ignored. */ -export const validatePayload = (input: { +const validatePayload = (input: { readonly service: string; readonly version: string; readonly digest: string; diff --git a/.github/scripts/sync-artifacts-catalog.test.ts b/.github/scripts/sync-artifacts-catalog.test.ts new file mode 100644 index 0000000000..809dce551f --- /dev/null +++ b/.github/scripts/sync-artifacts-catalog.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, test } from "bun:test"; + +import { InvalidPayloadError } from "./slim-mirror-payload.ts"; +import { + CATALOG_PATH, + planArtifactCatalogUpdate, + type ReleasePublication, +} from "./sync-artifacts-catalog.ts"; + +const DIGEST_B = `sha256:${"b".repeat(64)}`; +const POSTGRES_17 = `ghcr.io/supabase/cli/postgres:17.6.1.168@sha256:${"9".repeat(64)}`; +const POSTGRES_15 = `ghcr.io/supabase/cli/postgres:15.14.1.168@sha256:${"f".repeat(64)}`; + +const fixture = `const workloadCatalog = { + database: definition( + "postgres", + "17.6.1.168", + "${POSTGRES_17}", + "bin/supabase-postgres-start", + ["bin/supabase-postgres-start"], + { "15.14.1.168": "${POSTGRES_15}" }, + ), + rest: definition("postgrest", "v16.2", "ghcr.io/supabase/cli/postgrest:v16.2", "bin/postgrest"), + analytics: definition( + "analytics", + "v1.50.9", + "ghcr.io/supabase/cli/analytics:v1.50.9@sha256:${"a".repeat(64)}", + "bin/logflare", + ), +}; +`; + +const published = (digest?: string): ReleasePublication => ({ status: "published", digest }); + +describe("planArtifactCatalogUpdate", () => { + test("pins Dockerfile tags that changed and leaves the other postgres line", async () => { + const plan = await planArtifactCatalogUpdate({ + baseDockerfile: `FROM supabase/postgres:17.6.1.168 AS pg +FROM postgrest/postgrest:v16.2 AS postgrest +FROM supabase/logflare:1.50.9 AS logflare +`, + dockerfile: `FROM supabase/postgres:17.6.1.171 AS pg +FROM library/kong:2.8.1 AS kong +FROM postgrest/postgrest:v16.3 AS postgrest +FROM supabase/logflare:1.50.9 AS logflare +`, + catalog: fixture, + publication: async (service) => published(service === "postgres" ? DIGEST_B : undefined), + }); + + expect(plan.updates.map((update) => `${update.service} ${update.version}`)).toEqual([ + "postgres 17.6.1.171", + "postgrest v16.3", + ]); + expect(plan.source).toContain(`"ghcr.io/supabase/cli/postgres:17.6.1.171@${DIGEST_B}"`); + expect(plan.source).toContain(`"${POSTGRES_15}"`); + expect(plan.source).toContain(`"ghcr.io/supabase/cli/postgrest:v16.3"`); + expect(plan.source).toContain(`"v1.50.9"`); + expect(plan.skipped).toEqual([]); + }); + + test("a missing release blocks the commit and OrioleDB does not", async () => { + const movesPostgres = await planArtifactCatalogUpdate({ + baseDockerfile: "FROM supabase/postgres:17.6.1.168 AS pg\n", + dockerfile: `FROM supabase/postgres:17.6.1.171 AS pg +FROM postgrest/postgrest:v16.3 AS postgrest +`, + catalog: fixture, + publication: async () => ({ status: "missing" }), + }); + expect(movesPostgres.updates).toEqual([]); + expect(movesPostgres.source).toBe(fixture); + expect(movesPostgres.skipped.every((skip) => skip.blocking)).toBe(true); + + const oriole = await planArtifactCatalogUpdate({ + baseDockerfile: + "FROM supabase/postgres:17.6.1.168 AS pg\nFROM postgrest/postgrest:v16.2 AS postgrest\n", + dockerfile: `FROM supabase/postgres:16.0.0.1-orioledb AS pg +FROM postgrest/postgrest:v16.3 AS postgrest +`, + catalog: fixture, + publication: async () => published(), + }); + expect(oriole.updates.map((update) => update.service)).toEqual(["postgrest"]); + expect(oriole.skipped).toEqual([ + { + alias: "pg", + reason: "pg supabase/postgres:16.0.0.1-orioledb has no slim image.", + blocking: false, + }, + ]); + expect(oriole.source).toContain(`"${POSTGRES_17}"`); + }); + + test("refuses a backward pin and a tag that would escape the catalog string", async () => { + const backward = await planArtifactCatalogUpdate({ + baseDockerfile: "FROM supabase/postgres:17.6.1.170 AS pg\n", + dockerfile: "FROM supabase/postgres:17.6.1.171 AS pg\n", + catalog: fixture.replaceAll("17.6.1.168", "17.6.1.173"), + publication: async () => { + throw new Error("publication lookup"); + }, + }); + expect(backward.updates).toEqual([]); + expect(backward.skipped.map((skip) => skip.blocking)).toEqual([true]); + expect(backward.source).toContain(`"17.6.1.173"`); + + await expect( + planArtifactCatalogUpdate({ + dockerfile: 'FROM supabase/gotrue:v1" AS gotrue\n', + catalog: fixture, + publication: async () => published(), + }), + ).rejects.toThrow(InvalidPayloadError); + }); +}); + +describe("against the real catalog", () => { + test("every slim Dockerfile alias is a catalog entry", async () => { + const dockerfile = await Bun.file("apps/cli/src/shared/services/Dockerfile").text(); + const catalog = await Bun.file(CATALOG_PATH).text(); + const plan = await planArtifactCatalogUpdate({ + dockerfile, + catalog, + publication: async () => published(`sha256:${"a".repeat(64)}`), + }); + + expect( + plan.skipped.filter((skip) => skip.blocking && !skip.reason.includes("older than")), + ).toEqual([]); + }); +}); diff --git a/.github/scripts/sync-artifacts-catalog.ts b/.github/scripts/sync-artifacts-catalog.ts new file mode 100644 index 0000000000..838f2c5b75 --- /dev/null +++ b/.github/scripts/sync-artifacts-catalog.ts @@ -0,0 +1,398 @@ +/** + * Pins `packages/stack/src/Artifacts.ts` to the slim workloads named by + * `apps/cli/src/shared/services/Dockerfile`. Native archive URLs are derived + * from service + version. An entry that already carries a digest keeps one: + * the published `ghcr.io/supabase/cli/:` manifest digest. + * + * Run: `bun .github/scripts/sync-artifacts-catalog.ts [base-dockerfile]` + */ + +import { parseDockerfileServiceImages } from "../../apps/cli/src/shared/services/parse-dockerfile-service-images.ts"; +import { isOrioleImage, slimCatalogPin } from "../../apps/cli/src/shared/services/slim-images.ts"; +import { + DIGEST_PATTERN, + InvalidPayloadError, + SOURCE_REGISTRY, + VERSION_PATTERN, + escapeRegExp, +} from "./slim-mirror-payload.ts"; + +export const CATALOG_PATH = "packages/stack/src/Artifacts.ts"; +const DOCKERFILE_PATH = "apps/cli/src/shared/services/Dockerfile"; +const NATIVE_RELEASES = "https://api.github.com/repos/supabase/slim-services/releases/tags"; + +const SLIM_IMAGE_PREFIX = `${SOURCE_REGISTRY}/`; + +/** Leading numeric component, `v` stripped. Only postgres carries more than one line. */ +function releaseLine(version: string): string { + const withoutPrefix = version.replace(/^[vV]/, ""); + const separator = withoutPrefix.indexOf("."); + return separator === -1 ? withoutPrefix : withoutPrefix.slice(0, separator); +} + +/** True when every numeric prefix of `next` is older than `current`. Equal prefixes are not older. */ +function isOlderRelease(next: string, current: string): boolean { + const nextParts = next.replace(/^[vV]/, "").split("."); + const currentParts = current.replace(/^[vV]/, "").split("."); + const count = Math.max(nextParts.length, currentParts.length); + for (let index = 0; index < count; index++) { + const nextValue = leadingInteger(nextParts[index] ?? "0"); + const currentValue = leadingInteger(currentParts[index] ?? "0"); + if (nextValue === undefined || currentValue === undefined) return false; + if (nextValue !== currentValue) return nextValue < currentValue; + } + return false; +} + +function leadingInteger(part: string): number | undefined { + const match = /^(\d+)/.exec(part); + return match === null ? undefined : Number(match[1]); +} + +export interface CatalogPinUpdate { + readonly service: string; + readonly version: string; + readonly previousVersion: string; + readonly target: "default" | "additional"; +} + +export interface SkippedCatalogPin { + readonly alias: string; + readonly reason: string; + /** OrioleDB has no slim image and must not fail the other pins. */ + readonly blocking: boolean; +} + +export interface CatalogPlan { + readonly source: string; + readonly updates: ReadonlyArray; + readonly skipped: ReadonlyArray; +} + +export type ReleasePublication = + | { readonly status: "published"; readonly digest?: string } + | { readonly status: "missing" | "lookup-failed" }; + +/** `definition("", "", ""`. */ +function defaultEntryPattern(service: string): RegExp { + const s = escapeRegExp(service); + return new RegExp( + `(definition\\(\\s*"${s}",\\s*")([^"]+)("\\s*,\\s*")(${escapeRegExp(SLIM_IMAGE_PREFIX)}${s}:[^"]+)(")`, + ); +} + +/** Additional release entries: `"": ""`. */ +function additionalEntryPattern(service: string, version?: string): RegExp { + const s = escapeRegExp(service); + const key = version === undefined ? `[^"]+` : escapeRegExp(version); + return new RegExp( + `"(${key})"(\\s*:\\s*)"(${escapeRegExp(SLIM_IMAGE_PREFIX)}${s}:[^"]+)"`, + version === undefined ? "g" : "", + ); +} + +function imageHasDigest(image: string): boolean { + return /@sha256:[0-9a-f]{64}$/.test(image); +} + +function desiredImage( + service: string, + version: string, + currentImage: string, + digest: string, +): string { + const tagged = `${SLIM_IMAGE_PREFIX}${service}:${version}`; + if (!imageHasDigest(currentImage)) return tagged; + if (!DIGEST_PATTERN.test(digest)) { + throw new InvalidPayloadError(`missing slim digest for ${service}:${version}`); + } + return `${tagged}@${digest}`; +} + +type SelectedEntry = + | { readonly kind: "default" | "additional"; readonly version: string; readonly image: string } + | { readonly kind: "unmodelled-service" } + | { readonly kind: "unmodelled-release-line"; readonly known: ReadonlyArray }; + +function selectEntry(source: string, service: string, version: string): SelectedEntry { + const defaultMatch = defaultEntryPattern(service).exec(source); + if (defaultMatch === null) return { kind: "unmodelled-service" }; + + const currentDefaultVersion = defaultMatch[2] ?? ""; + const currentDefaultImage = defaultMatch[4] ?? ""; + const additional = [...source.matchAll(additionalEntryPattern(service))].map((match) => ({ + version: match[1] ?? "", + image: match[3] ?? "", + })); + const bumpsDefault = + additional.length === 0 || releaseLine(version) === releaseLine(currentDefaultVersion); + if (bumpsDefault) { + return { kind: "default", version: currentDefaultVersion, image: currentDefaultImage }; + } + + 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)], + }; + } + return { kind: "additional", version: sameLine.version, image: sameLine.image }; +} + +function skipReason(alias: string, version: string, entry: SelectedEntry): string | undefined { + if (entry.kind === "unmodelled-service") { + return `${CATALOG_PATH} has no slim entry for ${alias}.`; + } + if (entry.kind === "unmodelled-release-line") { + return `${alias} ${version} is not on a release line ${CATALOG_PATH} carries (${entry.known.join(", ")}).`; + } + return undefined; +} + +function slimVersions(dockerfile: string): ReadonlyMap { + const versions = new Map(); + for (const from of parseDockerfileServiceImages(dockerfile)) { + const pin = slimCatalogPin(from.alias, from.image); + if (pin !== undefined) versions.set(from.alias, pin.version); + } + return versions; +} + +type PinResult = + | { + readonly kind: "updated"; + readonly source: string; + readonly previousVersion: string; + readonly target: "default" | "additional"; + } + | { readonly kind: "unchanged" }; + +function pinService( + source: string, + service: string, + version: string, + digest: string | undefined, +): PinResult { + const entry = selectEntry(source, service, version); + if (entry.kind !== "default" && entry.kind !== "additional") { + throw new InvalidPayloadError(`${service} ${version} is not a catalog entry.`); + } + if (imageHasDigest(entry.image) && (digest === undefined || !DIGEST_PATTERN.test(digest))) { + throw new InvalidPayloadError(`missing slim digest for ${service}:${version}`); + } + const desired = desiredImage(service, version, entry.image, digest ?? ""); + if (entry.version === version && entry.image === desired) return { kind: "unchanged" }; + + if (entry.kind === "default") { + return { + kind: "updated", + source: source.replace( + defaultEntryPattern(service), + (_full, prefix: string, _version: string, mid: string, _image: string, suffix: string) => + `${prefix}${version}${mid}${desired}${suffix}`, + ), + previousVersion: entry.version, + target: "default", + }; + } + + return { + kind: "updated", + source: source.replace( + additionalEntryPattern(service, entry.version), + (_full, _key: string, separator: string) => `"${version}"${separator}"${desired}"`, + ), + previousVersion: entry.version, + target: "additional", + }; +} + +/** + * Rewrites `catalog` from Dockerfile tags that differ from `baseDockerfile`. + * With no base, every slim tag is in scope. A pin that cannot be applied is + * reported in `skipped`. OrioleDB is the only non-blocking skip. + */ +export async function planArtifactCatalogUpdate(input: { + readonly dockerfile: string; + readonly baseDockerfile?: string; + readonly catalog: string; + readonly publication: (service: string, version: string) => Promise; +}): Promise { + let source = input.catalog; + const updates: CatalogPinUpdate[] = []; + const skipped: SkippedCatalogPin[] = []; + const baseVersions = + input.baseDockerfile === undefined ? undefined : slimVersions(input.baseDockerfile); + const changed = (alias: string, version: string | undefined): boolean => + baseVersions === undefined || baseVersions.get(alias) !== version; + + for (const from of parseDockerfileServiceImages(input.dockerfile)) { + if (isOrioleImage(from.image)) { + if (!changed(from.alias, undefined)) continue; + skipped.push({ + alias: from.alias, + reason: `${from.alias} ${from.image} has no slim image.`, + blocking: false, + }); + continue; + } + const pin = slimCatalogPin(from.alias, from.image); + if (pin === undefined || !changed(from.alias, pin.version)) continue; + if (!VERSION_PATTERN.test(pin.version)) { + throw new InvalidPayloadError(`invalid version for ${from.alias}: '${pin.version}'`); + } + + const entry = selectEntry(source, pin.service, pin.version); + const reason = skipReason(from.alias, pin.version, entry); + if (reason !== undefined) { + skipped.push({ alias: from.alias, reason, blocking: true }); + continue; + } + if (entry.kind !== "default" && entry.kind !== "additional") continue; + if (isOlderRelease(pin.version, entry.version)) { + skipped.push({ + alias: from.alias, + reason: `${pin.service} ${pin.version} is older than the catalog pin ${entry.version}.`, + blocking: true, + }); + continue; + } + if (entry.version === pin.version && !imageHasDigest(entry.image)) continue; + + const release = await input.publication(pin.service, pin.version); + if (release.status !== "published") { + skipped.push({ + alias: from.alias, + reason: + release.status === "missing" + ? `${pin.service}:${pin.version} has no published slim image and native release.` + : `${pin.service}:${pin.version} publication check failed.`, + blocking: true, + }); + continue; + } + const digest = imageHasDigest(entry.image) ? release.digest : undefined; + if (imageHasDigest(entry.image) && (digest === undefined || !DIGEST_PATTERN.test(digest))) { + skipped.push({ + alias: from.alias, + reason: `${pin.service}:${pin.version} has no published slim manifest.`, + blocking: true, + }); + continue; + } + const result = pinService(source, pin.service, pin.version, digest); + if (result.kind === "unchanged") continue; + source = result.source; + updates.push({ + service: pin.service, + version: pin.version, + previousVersion: result.previousVersion, + target: result.target, + }); + } + + return { source, updates, skipped }; +} + +type Probe = "published" | "missing" | "lookup-failed"; + +async function probeManifest(reference: string): Promise<{ probe: Probe; digest?: string }> { + const proc = Bun.spawn(["regctl", "manifest", "head", reference], { + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exit] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]); + const digest = stdout.trim(); + if (exit === 0 && DIGEST_PATTERN.test(digest)) return { probe: "published", digest }; + const detail = stderr.toLowerCase(); + if ( + detail.includes("manifest unknown") || + detail.includes("name unknown") || + detail.includes("not found") || + detail.includes("404") + ) { + return { probe: "missing" }; + } + const message = stderr.trim(); + console.log( + `::warning ::${reference} publication check failed${message === "" ? "" : `: ${message}`}`, + ); + return { probe: "lookup-failed" }; +} + +async function probeNativeRelease(service: string, version: string): Promise { + const tag = `${service}-${version}`; + const headers: Record = { + Accept: "application/vnd.github+json", + "User-Agent": "supabase-cli-catalog-sync", + }; + const token = process.env.GITHUB_TOKEN; + if (token !== undefined && token !== "") headers.Authorization = `Bearer ${token}`; + try { + const response = await fetch(`${NATIVE_RELEASES}/${encodeURIComponent(tag)}`, { headers }); + if (response.status === 200) return "published"; + if (response.status === 404) return "missing"; + console.log(`::warning ::${tag} native release check returned HTTP ${response.status}.`); + return "lookup-failed"; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.log(`::warning ::${tag} native release check failed: ${message}`); + return "lookup-failed"; + } +} + +async function lookupPublication(service: string, version: string): Promise { + const reference = `${SLIM_IMAGE_PREFIX}${service}:${version}`; + const [manifest, native] = await Promise.all([ + probeManifest(reference), + probeNativeRelease(service, version), + ]); + if (manifest.probe === "lookup-failed" || native === "lookup-failed") { + return { status: "lookup-failed" }; + } + if (manifest.probe === "missing" || native === "missing") return { status: "missing" }; + return { status: "published", digest: manifest.digest }; +} + +async function main(argv: ReadonlyArray): Promise { + const [dockerfilePath = DOCKERFILE_PATH, catalogPath = CATALOG_PATH, baseDockerfilePath] = argv; + const dockerfile = await Bun.file(dockerfilePath).text(); + const catalog = await Bun.file(catalogPath).text(); + const baseDockerfile = + baseDockerfilePath === undefined ? undefined : await Bun.file(baseDockerfilePath).text(); + const plan = await planArtifactCatalogUpdate({ + dockerfile, + baseDockerfile, + catalog, + publication: lookupPublication, + }); + for (const skip of plan.skipped) { + console.log(`::warning ::Left ${skip.alias} unchanged: ${skip.reason}`); + } + if (plan.skipped.some((skip) => skip.blocking)) { + console.log("::error ::Refusing to commit a partial catalog update."); + process.exit(1); + } + await Bun.write(catalogPath, plan.source); + if (plan.updates.length === 0) { + console.log("Workload catalog already matches the Dockerfile."); + return; + } + for (const update of plan.updates) { + console.log( + `Pinned ${update.service} ${update.target} ${update.previousVersion} -> ${update.version}.`, + ); + } +} + +if (import.meta.main) { + main(process.argv.slice(2)).catch((error: unknown) => { + console.log(`::error ::${error instanceof Error ? error.message : String(error)}`); + process.exit(1); + }); +} diff --git a/.github/scripts/sync-workload-catalog.test.ts b/.github/scripts/sync-workload-catalog.test.ts deleted file mode 100644 index 63419fa8da..0000000000 --- a/.github/scripts/sync-workload-catalog.test.ts +++ /dev/null @@ -1,289 +0,0 @@ -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": definition( - "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"], - { - "15.14.1.168": - "ghcr.io/supabase/cli/postgres:15.14.1.168@sha256:${"f".repeat(64)}", - }, - ), - "rest:rest": definition( - "postgrest", - "v16.2", - "ghcr.io/supabase/cli/postgrest:v16.2", - "bin/postgrest", - ["bin/postgrest"], - ), - "auth:auth": definition("auth", "v2.196.0", "ghcr.io/supabase/cli/auth:v2.196.0", "bin/auth", [ - "bin/auth", - ]), - "studio:studio": definition( - "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( - `definition("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(/definition\(\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 deleted file mode 100644 index d6a290712b..0000000000 --- a/.github/scripts/sync-workload-catalog.ts +++ /dev/null @@ -1,183 +0,0 @@ -/** - * Pins one workload in `packages/stack/src/Artifacts.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 — `slim-mirror-payload.ts` - * keeps `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. - */ - -import { - InvalidPayloadError, - SOURCE_REGISTRY, - escapeRegExp, - validatePayload, -} from "./slim-mirror-payload.ts"; - -export { InvalidPayloadError, validatePayload } from "./slim-mirror-payload.ts"; - -export const CATALOG_PATH = "packages/stack/src/Artifacts.ts"; - -const SLIM_IMAGE_PREFIX = `${SOURCE_REGISTRY}/`; - -/** 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 }; - -/** `definition("", "", ""` — image anchored so postgres != postgrest. */ -function defaultEntryPattern(service: string): RegExp { - const s = escapeRegExp(service); - return new RegExp( - `(definition\\(\\s*"${s}",\\s*")([^"]+)("\\s*,\\s*")(${escapeRegExp(SLIM_IMAGE_PREFIX)}${s}:[^"]+)(")`, - ); -} - -/** Additional release 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/github-scripts-ci.yml b/.github/workflows/github-scripts-ci.yml index 5d4af05b36..c2f12fa74b 100644 --- a/.github/workflows/github-scripts-ci.yml +++ b/.github/workflows/github-scripts-ci.yml @@ -9,6 +9,9 @@ on: pull_request: paths: - ".github/scripts/**" + - "apps/cli/src/shared/services/Dockerfile" + - "apps/cli/src/shared/services/parse-dockerfile-service-images.ts" + - "apps/cli/src/shared/services/slim-images.ts" - "packages/stack/src/Artifacts.ts" - ".github/workflows/ai-review.yml" - ".github/workflows/github-scripts-ci.yml" diff --git a/.github/workflows/sync-artifacts-catalog.yml b/.github/workflows/sync-artifacts-catalog.yml new file mode 100644 index 0000000000..d57b5d7b28 --- /dev/null +++ b/.github/workflows/sync-artifacts-catalog.yml @@ -0,0 +1,98 @@ +name: Sync Artifacts catalog + +# The script is the base revision. The pull request only supplies the Dockerfile +# and catalog text, and the job runs for Dependabot so that pull request cannot +# edit this workflow and then execute it with a write token. The catalog commit +# is pushed with the app token so the new head triggers CI. Dependabot's +# GITHUB_TOKEN would not. +on: + pull_request: + paths: + - apps/cli/src/shared/services/Dockerfile + +permissions: + contents: read + +concurrency: + group: sync-artifacts-catalog-${{ github.event.pull_request.number }} + cancel-in-progress: false + +jobs: + sync: + name: Pin workload catalog + if: github.actor == 'dependabot[bot]' && github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.pull_request.base.sha }} + persist-credentials: true + fetch-depth: 1 + + # Bun only. The script imports no workspace packages. The firewall token + # is empty on a Dependabot run; setup is not required to format one file. + - name: Install toolchains + uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4.3.0 + with: + version: 2026.9.0 + + - name: Install regctl + run: | + set -euo pipefail + install -d "${RUNNER_TEMP}/regctl-bin" + curl -fsSLo "${RUNNER_TEMP}/regctl-bin/regctl" \ + https://github.com/regclient/regclient/releases/download/v0.11.5/regctl-linux-amd64 + echo "c93aa7638749f5aaac1a8e01787321889c78f0101809bb2880343478d0ba0467 ${RUNNER_TEMP}/regctl-bin/regctl" | sha256sum -c - + chmod +x "${RUNNER_TEMP}/regctl-bin/regctl" + echo "${RUNNER_TEMP}/regctl-bin" >> "$GITHUB_PATH" + "${RUNNER_TEMP}/regctl-bin/regctl" version + + - 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: Pin the catalog to the Dockerfile + env: + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + HEAD_REF: ${{ github.event.pull_request.head.ref }} + PR_NUMBER: ${{ github.event.pull_request.number }} + APP_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + set -euo pipefail + git fetch --no-tags origin "pull/${PR_NUMBER}/head" + fetched="$(git rev-parse FETCH_HEAD)" + if [ "$fetched" != "$HEAD_SHA" ]; then + echo "::error ::pull/${PR_NUMBER} head is ${fetched}, expected ${HEAD_SHA}." + exit 1 + fi + dockerfile="$(mktemp)" + catalog="$(mktemp)" + original="$(mktemp)" + git show "$HEAD_SHA:apps/cli/src/shared/services/Dockerfile" > "$dockerfile" + git show "$HEAD_SHA:packages/stack/src/Artifacts.ts" > "$catalog" + cp "$catalog" "$original" + bun .github/scripts/sync-artifacts-catalog.ts "$dockerfile" "$catalog" apps/cli/src/shared/services/Dockerfile + if cmp -s "$original" "$catalog"; then + echo "Workload catalog already matches the Dockerfile." + exit 0 + fi + git checkout --detach "$HEAD_SHA" + cp "$catalog" packages/stack/src/Artifacts.ts + bun x oxfmt@0.65.0 --config .oxfmtrc.json packages/stack/src/Artifacts.ts + git add -- packages/stack/src/Artifacts.ts + if git diff --cached --quiet -- packages/stack/src/Artifacts.ts; then + echo "Workload catalog already matches the Dockerfile." + exit 0 + fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git commit -m "chore(stack): pin the workload catalog to the Dockerfile image tags" + git remote set-url origin "https://x-access-token:${APP_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" + git push origin "HEAD:refs/heads/${HEAD_REF}" diff --git a/.github/workflows/sync-stack-workload-catalog.yml b/.github/workflows/sync-stack-workload-catalog.yml deleted file mode 100644 index 02cb67690c..0000000000 --- a/.github/workflows/sync-stack-workload-catalog.yml +++ /dev/null @@ -1,125 +0,0 @@ -name: Sync Stack Workload Catalog - -# Pins `packages/stack/src/Artifacts.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. Extra payload fields such as natives[] are - # ignored here; this workflow pins the image digest only. - - 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/Artifacts.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 the affected catalog surfaces pass. - - 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:github-scripts - - - name: Test affected stack preparation - if: steps.check.outputs.has_changes == 'true' - run: >- - pnpm --filter @supabase/stack test:integration:run - src/preparation/artifacts.integration.test.ts - src/preparation/slim-services.integration.test.ts - --passWithNoTests=false - - - 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/Artifacts.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/dockerfile-images.ts b/apps/cli/src/shared/services/dockerfile-images.ts index 4caf635140..70ca17a9b2 100644 --- a/apps/cli/src/shared/services/dockerfile-images.ts +++ b/apps/cli/src/shared/services/dockerfile-images.ts @@ -1,33 +1,11 @@ import serviceImagesDockerfile from "./Dockerfile" with { type: "text" }; +import { + parseDockerfileServiceImages, + type DockerfileImageSpec, +} from "./parse-dockerfile-service-images.ts"; import { slimImageForAlias } from "./slim-images.ts"; -export interface DockerfileImageSpec { - readonly alias: string; - readonly image: string; -} - -const FROM_LINE_PATTERN = /^FROM\s+(.+):([^:\s]+)\s+AS\s+([^\s#]+)/i; - -export function parseDockerfileServiceImages( - dockerfile: string, -): ReadonlyArray { - return dockerfile - .split("\n") - .map((line) => line.trim()) - .flatMap((line) => { - const match = FROM_LINE_PATTERN.exec(line); - if (match === null) { - return []; - } - - const [, repository, tag, alias] = match; - if (repository === undefined || tag === undefined || alias === undefined) { - return []; - } - - return [{ alias, image: `${repository}:${tag}` }]; - }); -} +export { parseDockerfileServiceImages, type DockerfileImageSpec }; export const dockerfileServiceImages = parseDockerfileServiceImages(serviceImagesDockerfile); diff --git a/apps/cli/src/shared/services/parse-dockerfile-service-images.ts b/apps/cli/src/shared/services/parse-dockerfile-service-images.ts new file mode 100644 index 0000000000..f2560d5822 --- /dev/null +++ b/apps/cli/src/shared/services/parse-dockerfile-service-images.ts @@ -0,0 +1,28 @@ +export interface DockerfileImageSpec { + readonly alias: string; + readonly image: string; +} + +const FROM_LINE_PATTERN = /^FROM\s+(.+):([^:\s]+)\s+AS\s+([^\s#]+)/i; + +/** Reads `FROM : AS ` lines from the service-image manifest. */ +export function parseDockerfileServiceImages( + dockerfile: string, +): ReadonlyArray { + return dockerfile + .split("\n") + .map((line) => line.trim()) + .flatMap((line) => { + const match = FROM_LINE_PATTERN.exec(line); + if (match === null) { + return []; + } + + const [, repository, tag, alias] = match; + if (repository === undefined || tag === undefined || alias === undefined) { + return []; + } + + return [{ alias, image: `${repository}:${tag}` }]; + }); +} diff --git a/apps/cli/src/shared/services/slim-images.ts b/apps/cli/src/shared/services/slim-images.ts index 9a27bc024e..af0635fc11 100644 --- a/apps/cli/src/shared/services/slim-images.ts +++ b/apps/cli/src/shared/services/slim-images.ts @@ -4,7 +4,7 @@ const SLIM_IMAGE_PREFIX = "ghcr.io/supabase/cli/"; /** * Maps embedded-Dockerfile aliases onto the slim service catalog. Aliases with * no slim build (kong, the `differ`/`migra`/`pgprove` job images) are absent and - * keep their docker.io reference. + * keep their docker.io reference. OrioleDB tags are excluded in `slimCatalogPin`. */ const SLIM_SERVICE_BY_ALIAS = { pg: "postgres", @@ -60,8 +60,37 @@ function slimTagForService(service: SlimServiceName, rawTag: string): string { return tag; } -function slimImageRef(service: SlimServiceName, rawTag: string): string { - return `${SLIM_IMAGE_PREFIX}${service}:${slimTagForService(service, rawTag)}`; +export interface SlimCatalogPin { + readonly service: SlimServiceName; + readonly version: string; +} + +/** OrioleDB tags are docker.io-only; slim-services does not publish them. */ +export function isOrioleImage(image: string): boolean { + const tag = imageTag(image); + return tag !== undefined && tag.toLowerCase().includes("orioledb"); +} + +/** + * Slim service and tag for a Dockerfile alias. Absent when that alias has no + * slim build (kong, the one-shot job images, and OrioleDB tags). + */ +export function slimCatalogPin(alias: string, image: string): SlimCatalogPin | undefined { + if (isOrioleImage(image)) { + return undefined; + } + const service = SLIM_SERVICE_LOOKUP[alias]; + if (service === undefined) { + return undefined; + } + + const rawTag = imageTag(image); + if (rawTag === undefined) { + return undefined; + } + + const tag = alias === "vector" ? rawTag.replace(/-alpine$/, "") : rawTag; + return { service, version: slimTagForService(service, tag) }; } /** @@ -74,19 +103,11 @@ function slimImageRef(service: SlimServiceName, rawTag: string): string { * pin on any other service is a real tag, not a variant marker. */ export function toSlimImage(alias: string, image: string): string { - const service = SLIM_SERVICE_LOOKUP[alias]; - if (service === undefined) { + const pin = slimCatalogPin(alias, image); + if (pin === undefined) { return image; } - - const tagSeparator = image.lastIndexOf(":"); - if (tagSeparator === -1) { - return image; - } - - const rawTag = image.slice(tagSeparator + 1); - const tag = alias === "vector" ? rawTag.replace(/-alpine$/, "") : rawTag; - return slimImageRef(service, tag); + return `${SLIM_IMAGE_PREFIX}${pin.service}:${pin.version}`; } /** `toSlimImage` behind the feature flag; a no-op while the flag is off. */ 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 2f1e8de860..4e082a3b7c 100644 --- a/apps/cli/src/shared/services/slim-images.unit.test.ts +++ b/apps/cli/src/shared/services/slim-images.unit.test.ts @@ -3,6 +3,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { dockerfileServiceImageRaw } from "./dockerfile-images.ts"; import { pinMatchesCurrentImage, + slimCatalogPin, slimImageForAlias, slimImageForCurrentPin, slimImagesEnabled, @@ -70,6 +71,16 @@ describe("toSlimImage", () => { ); }); + it("keeps OrioleDB tags on docker.io", () => { + expect(toSlimImage("pg", "supabase/postgres:16.0.0.1-orioledb")).toBe( + "supabase/postgres:16.0.0.1-orioledb", + ); + expect(toSlimImage("pg", "supabase/postgres:orioledb-15.1.0.55")).toBe( + "supabase/postgres:orioledb-15.1.0.55", + ); + expect(slimCatalogPin("pg", "supabase/postgres:16.0.0.1-orioledb")).toBeUndefined(); + }); + it("strips vector's docker.io -alpine variant suffix", () => { expect(toSlimImage("vector", "timberio/vector:0.53.0-alpine")).toBe( "ghcr.io/supabase/cli/vector:0.53.0", diff --git a/docs/adr/0026-slim-artifact-mirrors.md b/docs/adr/0026-slim-artifact-mirrors.md index 59fa5be703..004e40294e 100644 --- a/docs/adr/0026-slim-artifact-mirrors.md +++ b/docs/adr/0026-slim-artifact-mirrors.md @@ -13,7 +13,7 @@ Some consumers cannot reach GitHub release assets or a single registry blob CDN. Publish each slim **image** to GHCR and AWS ECR Public (`public.ecr.aws/supabase/cli/`), digest-preserving, via `mirror-slim-image.yml` and `.github/scripts/mirror-slim-image.ts`. Publish each slim **native** archive as an OCI artifact on the same two repositories under `:version-native-` (not `:version-linux-*`, which are image platform tags). GitHub Releases remain the human HTTPS copy and `--clobber` on force. -`force=true` always requests the ECR copy, including when the image digest is unchanged (natives may have moved). The **image** destination digest gates `publish-release` once the dispatch token exists. Native ECR copy is best-effort (`continue-on-error`) and must not fail that release. Catalog sync consumes the image `service` / `version` / `digest` only; native tags ride in a separate `natives[]` payload field. Do not prune untagged GHCR or ECR manifests: already-shipped CLIs still pin old image digests until a catalog PR ships. Natives follow the moved tag immediately. +`force=true` always requests the ECR copy, including when the image digest is unchanged (natives may have moved). The **image** destination digest gates `publish-release` once the dispatch token exists. Native ECR copy is best-effort (`continue-on-error`) and must not fail that release. Native tags ride in a separate `natives[]` payload field. This mirror does not open a pull request to pin `packages/stack/src/Artifacts.ts`. A Dependabot Dockerfile bump commits that catalog pin onto the same pull request, so the docker.io tag, the slim image, and the native archive stay on one version. A PostgreSQL release line that is not the Dockerfile `pg` tag, and a same-version digest republish, stay put until that Dockerfile line changes. Do not prune untagged GHCR or ECR manifests: already-shipped CLIs still pin old image digests until that pull request merges. Natives follow the moved tag immediately. ECR Public tags are always mutable; `ecr-public create-repository` has no immutability flag. Reuse the existing `PROD_AWS_ROLE` in `supabase/cli`. Do not add a public S3 bucket or a second cloud vendor for this cut.