diff --git a/.github/workflows/sync-stack-service-versions.yml b/.github/workflows/sync-stack-service-versions.yml new file mode 100644 index 0000000000..95f15ef4c5 --- /dev/null +++ b/.github/workflows/sync-stack-service-versions.yml @@ -0,0 +1,59 @@ +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: 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: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: ${{ github.event.pull_request.head.ref }} + token: ${{ steps.app-token.outputs.token }} + + - 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: Commit synced stack service versions + run: | + if git diff --quiet -- packages/stack/src/versions.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/versions.ts + git commit -m "chore(stack): sync service version manifest" + git push diff --git a/packages/stack/package.json b/packages/stack/package.json index 1c97b1c98a..6795a55c34 100644 --- a/packages/stack/package.json +++ b/packages/stack/package.json @@ -15,6 +15,7 @@ "test": "nx run-many -t test:core test:e2e --projects=$npm_package_name", "test:core": "nx run-many -t test:unit test:integration --projects=$npm_package_name", "test:e2e:warmup": "bun run tests/warmup-e2e.ts", + "sync:versions": "bun run scripts/sync-versions-from-dockerfile.ts", "check:all": "nx run-many -t types:check lint:check fmt:check knip:check --projects=$npm_package_name", "fix:all": "nx run-many -t lint:fix fmt:fix knip:fix --projects=$npm_package_name" }, @@ -40,6 +41,7 @@ }, "knip": { "entry": [ + "scripts/**/*.ts", "src/**/*.test.ts", "src/daemon-node.ts", "tests/**/*.ts" diff --git a/packages/stack/scripts/sync-versions-from-dockerfile.ts b/packages/stack/scripts/sync-versions-from-dockerfile.ts new file mode 100644 index 0000000000..39e4592ea2 --- /dev/null +++ b/packages/stack/scripts/sync-versions-from-dockerfile.ts @@ -0,0 +1,132 @@ +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { + normalizeServiceVersion, + SERVICE_NAMES, + type ServiceName, + type VersionManifest, +} from "../src/versions.ts"; + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(scriptDir, "../../.."); +const dockerfilePath = path.join(repoRoot, "apps/cli-go/pkg/config/templates/Dockerfile"); +const versionsPath = path.join(repoRoot, "packages/stack/src/versions.ts"); + +const fromLinePattern = /^FROM\s+(.+):([^:\s]+)\s+AS\s+([^\s#]+)/i; + +const dockerfileAliases = new Map([ + ["pg", "postgres"], + ["postgrest", "postgrest"], + ["gotrue", "auth"], + ["edgeruntime", "edge-runtime"], + ["realtime", "realtime"], + ["storage", "storage"], + ["imgproxy", "imgproxy"], + ["mailpit", "mailpit"], + ["pgmeta", "pgmeta"], + ["studio", "studio"], + ["logflare", "analytics"], + ["vector", "vector"], + ["supavisor", "pooler"], +]); + +const ignoredAliases = new Set(["kong", "differ", "migra", "pgprove"]); + +function assertFullManifest( + versions: Partial>, +): asserts versions is VersionManifest { + const missing = SERVICE_NAMES.filter((service) => versions[service] === undefined); + if (missing.length > 0) { + throw new Error(`Missing Dockerfile versions for: ${missing.join(", ")}`); + } +} + +export function readVersionManifestFromDockerfile(dockerfile: string): VersionManifest { + const versions: Partial> = {}; + + for (const rawLine of dockerfile.split("\n")) { + const line = rawLine.trim(); + const match = fromLinePattern.exec(line); + if (match === null) { + continue; + } + + const [, , tag, alias] = match; + if (tag === undefined || alias === undefined) { + continue; + } + + if (ignoredAliases.has(alias)) { + continue; + } + + const service = dockerfileAliases.get(alias); + if (service === undefined) { + throw new Error(`Unknown Dockerfile image alias '${alias}'.`); + } + if (versions[service] !== undefined) { + throw new Error(`Duplicate Dockerfile version for '${service}'.`); + } + + versions[service] = normalizeServiceVersion(service, tag); + } + + assertFullManifest(versions); + return versions; +} + +function renderManifestKey(service: ServiceName): string { + return /^[a-zA-Z_$][\w$]*$/.test(service) ? service : JSON.stringify(service); +} + +export function renderDefaultVersions(versions: VersionManifest): string { + const lines = SERVICE_NAMES.map( + (service) => ` ${renderManifestKey(service)}: ${JSON.stringify(versions[service])},`, + ); + return ["export const DEFAULT_VERSIONS: VersionManifest = {", ...lines, "} as const;"].join("\n"); +} + +export function syncDefaultVersionsSource(source: string, versions: VersionManifest): string { + const startMarker = "export const DEFAULT_VERSIONS: VersionManifest = {"; + const endMarker = "\n} as const;"; + const start = source.indexOf(startMarker); + if (start === -1) { + throw new Error("Could not find DEFAULT_VERSIONS declaration."); + } + + const end = source.indexOf(endMarker, start); + if (end === -1) { + throw new Error("Could not find DEFAULT_VERSIONS declaration end."); + } + + return `${source.slice(0, start)}${renderDefaultVersions(versions)}${source.slice( + end + endMarker.length, + )}`; +} + +async function main() { + const checkOnly = process.argv.includes("--check"); + const dockerfile = await readFile(dockerfilePath, "utf8"); + const versionsSource = await readFile(versionsPath, "utf8"); + const versions = readVersionManifestFromDockerfile(dockerfile); + const syncedSource = syncDefaultVersionsSource(versionsSource, versions); + + if (syncedSource === versionsSource) { + console.log("DEFAULT_VERSIONS is already synced with the Dockerfile manifest."); + return; + } + + if (checkOnly) { + console.error("DEFAULT_VERSIONS is out of sync with the Dockerfile manifest."); + process.exitCode = 1; + return; + } + + await Bun.write(versionsPath, syncedSource); + console.log("Synced DEFAULT_VERSIONS with the Dockerfile manifest."); +} + +if (import.meta.main) { + await main(); +} diff --git a/packages/stack/src/BinaryResolver.unit.test.ts b/packages/stack/src/BinaryResolver.unit.test.ts index bc09b3b901..b0d63ccc77 100644 --- a/packages/stack/src/BinaryResolver.unit.test.ts +++ b/packages/stack/src/BinaryResolver.unit.test.ts @@ -5,6 +5,7 @@ import { DEFAULT_VERSIONS } from "./versions.ts"; const postgresVersion = DEFAULT_VERSIONS.postgres; const postgrestVersion = DEFAULT_VERSIONS.postgrest; const authVersion = DEFAULT_VERSIONS.auth; +const authRcVersion = "2.188.0-rc.15"; const edgeRuntimeVersion = DEFAULT_VERSIONS["edge-runtime"]; describe("BinaryResolver.downloadUrl", () => { @@ -44,11 +45,11 @@ describe("BinaryResolver.downloadUrl", () => { it("constructs auth URL for rc releases", () => { const url = BinaryResolver.downloadUrl({ service: "auth", - version: authVersion, + version: authRcVersion, assetName: "arm64", }); expect(url).toBe( - `https://github.com/supabase/auth/releases/download/rc${authVersion}/auth-v${authVersion}-arm64.tar.gz`, + `https://github.com/supabase/auth/releases/download/rc${authRcVersion}/auth-v${authRcVersion}-arm64.tar.gz`, ); }); diff --git a/packages/stack/src/StackBuilder.unit.test.ts b/packages/stack/src/StackBuilder.unit.test.ts index 3d3c2c0039..261ccd277c 100644 --- a/packages/stack/src/StackBuilder.unit.test.ts +++ b/packages/stack/src/StackBuilder.unit.test.ts @@ -456,8 +456,10 @@ describe("StackBuilder", () => { }); const realtimeDef = graph.startOrder.find((service) => service.name === "realtime"); - expect(realtimeDef?.args).toContain("supabase/realtime:v2.111.8"); - expect(realtimeDef?.args).not.toContain("public.ecr.aws/supabase/realtime:v2.111.8"); + expect(realtimeDef?.args).toContain(`supabase/realtime:v${DEFAULT_VERSIONS.realtime}`); + expect(realtimeDef?.args).not.toContain( + `public.ecr.aws/supabase/realtime:v${DEFAULT_VERSIONS.realtime}`, + ); }).pipe(Effect.provide(layer)); }); }); diff --git a/packages/stack/src/prefetch.unit.test.ts b/packages/stack/src/prefetch.unit.test.ts index bf0e66d50a..1cf509d074 100644 --- a/packages/stack/src/prefetch.unit.test.ts +++ b/packages/stack/src/prefetch.unit.test.ts @@ -14,6 +14,9 @@ import { prepareAssetsWithDependencies } from "./StackPreparation.ts"; import { DEFAULT_VERSIONS, SERVICE_NAMES } from "./versions.ts"; const encoder = new TextEncoder(); +const defaultAuthEcrImage = `public.ecr.aws/supabase/gotrue:v${DEFAULT_VERSIONS.auth}`; +const defaultAuthDockerHubImage = `supabase/gotrue:v${DEFAULT_VERSIONS.auth}`; +const defaultAuthGhcrImage = `ghcr.io/supabase/gotrue:v${DEFAULT_VERSIONS.auth}`; interface SpawnResult { readonly exitCode: number; @@ -112,15 +115,11 @@ describe("prefetch", () => { expect(result.auth).toEqual({ type: "docker", - image: "supabase/gotrue:v2.188.0-rc.15", + image: defaultAuthDockerHubImage, }); expect( spawner.spawned.filter((record) => record.args[0] === "pull").map((record) => record.args[1]), - ).toEqual([ - "public.ecr.aws/supabase/gotrue:v2.188.0-rc.15", - "public.ecr.aws/supabase/gotrue:v2.188.0-rc.15", - "supabase/gotrue:v2.188.0-rc.15", - ]); + ).toEqual([defaultAuthEcrImage, defaultAuthEcrImage, defaultAuthDockerHubImage]); }); test("falls back to GHCR after ECR and Docker Hub fail", async () => { @@ -149,15 +148,15 @@ describe("prefetch", () => { expect(result.auth).toEqual({ type: "docker", - image: "ghcr.io/supabase/gotrue:v2.188.0-rc.15", + image: defaultAuthGhcrImage, }); expect( spawner.spawned.filter((record) => record.args[0] === "pull").map((record) => record.args[1]), ).toEqual([ - "public.ecr.aws/supabase/gotrue:v2.188.0-rc.15", - "supabase/gotrue:v2.188.0-rc.15", - "supabase/gotrue:v2.188.0-rc.15", - "ghcr.io/supabase/gotrue:v2.188.0-rc.15", + defaultAuthEcrImage, + defaultAuthDockerHubImage, + defaultAuthDockerHubImage, + defaultAuthGhcrImage, ]); }); @@ -224,7 +223,7 @@ describe("prefetch", () => { expect(result.auth).toEqual({ type: "docker", - image: "public.ecr.aws/supabase/gotrue:v2.188.0-rc.15", + image: defaultAuthEcrImage, }); expect(events).toEqual([]); }); diff --git a/packages/stack/src/versions.ts b/packages/stack/src/versions.ts index 273e47ffcc..6f784e874c 100644 --- a/packages/stack/src/versions.ts +++ b/packages/stack/src/versions.ts @@ -46,19 +46,19 @@ export interface VersionManifest { } export const DEFAULT_VERSIONS: VersionManifest = { - postgres: "17.6.1.107", - postgrest: "14.5", - auth: "2.188.0-rc.15", - "edge-runtime": "1.73.13", - realtime: "2.111.8", - storage: "1.41.8", + postgres: "17.6.1.139", + postgrest: "14.13", + auth: "2.191.0", + "edge-runtime": "1.74.2", + realtime: "2.111.10", + storage: "1.61.4", imgproxy: "v3.8.0", mailpit: "v1.30.2", - pgmeta: "0.96.1", - studio: "2026.03.04-sha-0043607", - analytics: "1.34.7", - vector: "0.28.1-alpine", - pooler: "2.7.4", + pgmeta: "0.96.6", + studio: "2026.06.22-sha-2207d7f", + analytics: "1.45.4", + vector: "0.53.0-alpine", + pooler: "2.9.7", } as const; /** Default registry. Matches the Go CLI default (`public.ecr.aws`). */ diff --git a/packages/stack/src/versions.unit.test.ts b/packages/stack/src/versions.unit.test.ts index 8f6141035f..a8440db663 100644 --- a/packages/stack/src/versions.unit.test.ts +++ b/packages/stack/src/versions.unit.test.ts @@ -1,4 +1,9 @@ +import { readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; +import { + readVersionManifestFromDockerfile, + syncDefaultVersionsSource, +} from "../scripts/sync-versions-from-dockerfile.ts"; import { DEFAULT_VERSIONS, diffPinnedAndAvailableVersions, @@ -9,6 +14,29 @@ import { type VersionManifest, } from "./versions.ts"; +const dockerfile = readFileSync( + new URL("../../../apps/cli-go/pkg/config/templates/Dockerfile", import.meta.url), + "utf8", +); + +const sampleDockerfile = ` +FROM supabase/postgres:17.0.0.1 AS pg +FROM library/kong:2.8.1 AS kong +FROM axllent/mailpit:v1.2.3 AS mailpit +FROM postgrest/postgrest:v14.0 AS postgrest +FROM supabase/postgres-meta:v0.90.0 AS pgmeta +FROM supabase/studio:2026.01.01-sha-abcdef0 AS studio +FROM darthsim/imgproxy:v3.8.0 AS imgproxy +FROM supabase/edge-runtime:v1.70.0 AS edgeruntime +FROM timberio/vector:0.50.0-alpine AS vector +FROM supabase/supavisor:2.1.0 AS supavisor +FROM supabase/gotrue:v2.100.0 AS gotrue +FROM supabase/realtime:v2.100.0 AS realtime +FROM supabase/storage-api:v1.50.0 AS storage +FROM supabase/logflare:1.40.0 AS logflare +FROM supabase/migra:3.0.1663481299 AS migra +`; + describe("DEFAULT_VERSIONS", () => { it("has all required services", () => { expect(DEFAULT_VERSIONS).toHaveProperty("postgres"); @@ -27,6 +55,65 @@ describe("DEFAULT_VERSIONS", () => { expect(typeof DEFAULT_VERSIONS["edge-runtime"]).toBe("string"); expect(DEFAULT_VERSIONS["edge-runtime"].length).toBeGreaterThan(0); }); + + it("matches the Dockerfile manifest exposed to Dependabot", () => { + expect(readVersionManifestFromDockerfile(dockerfile)).toEqual(DEFAULT_VERSIONS); + }); +}); + +describe("syncDefaultVersionsSource", () => { + it("rewrites the DEFAULT_VERSIONS block from Dockerfile versions", () => { + const source = `before +export const DEFAULT_VERSIONS: VersionManifest = { + postgres: "old", + postgrest: "old", + auth: "old", + "edge-runtime": "old", + realtime: "old", + storage: "old", + imgproxy: "old", + mailpit: "old", + pgmeta: "old", + studio: "old", + analytics: "old", + vector: "old", + pooler: "old", +} as const; +after`; + + expect(syncDefaultVersionsSource(source, readVersionManifestFromDockerfile(sampleDockerfile))) + .toMatchInlineSnapshot(` + "before + export const DEFAULT_VERSIONS: VersionManifest = { + postgres: "17.0.0.1", + postgrest: "14.0", + auth: "2.100.0", + "edge-runtime": "1.70.0", + realtime: "2.100.0", + storage: "1.50.0", + imgproxy: "v3.8.0", + mailpit: "v1.2.3", + pgmeta: "0.90.0", + studio: "2026.01.01-sha-abcdef0", + analytics: "1.40.0", + vector: "0.50.0-alpine", + pooler: "2.1.0", + } as const; + after" + `); + }); + + it("fails when a required Dockerfile image alias is missing", () => { + expect(() => + readVersionManifestFromDockerfile("FROM supabase/postgres:17.6.1.139 AS pg\n"), + ).toThrow("Missing Dockerfile versions for:"); + }); + + it("fails when the Dockerfile contains an unexpected image alias", () => { + expect(() => + readVersionManifestFromDockerfile(`${dockerfile}\nFROM supabase/example:1.0.0 AS example\n`), + ).toThrow("Unknown Dockerfile image alias 'example'."); + }); }); describe("dockerImageForService", () => {