diff --git a/.github/workflows/build-cli-artifacts.yml b/.github/workflows/build-cli-artifacts.yml index ff97ce69ec..7300d26b7a 100644 --- a/.github/workflows/build-cli-artifacts.yml +++ b/.github/workflows/build-cli-artifacts.yml @@ -21,8 +21,8 @@ on: required: false type: string default: blacksmith-32vcpu-ubuntu-2404 - cache_key_suffix: - description: Suffix to distinguish build artifact cache producers + artifact_name_suffix: + description: Suffix to distinguish build artifact producers (e.g. -github) required: false type: string default: "" @@ -124,23 +124,25 @@ jobs: ls -la dist/ - - name: Check existing build artifacts cache - id: build-artifacts-cache - uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + # Hand the build off to the smoke/publish/brew/scoop jobs via a run-scoped + # artifact rather than a cache. Caches share a 10 GB per-repo budget and + # are evicted LRU, so a large build cache could vanish mid-run between the + # producer and a later consumer (e.g. publish), failing the restore. + # Artifacts have their own deterministic retention and survive job re-runs + # within the run, which is exactly what this handoff needs. + - name: Upload build artifacts + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: + name: cli-build-${{ inputs.shell }}-${{ inputs.version }}${{ inputs.artifact_name_suffix }} path: | packages/cli-*/bin/ dist/ - key: cli-build-${{ github.run_id }}-${{ inputs.shell }}-${{ inputs.version }}${{ inputs.cache_key_suffix }}-v1 - enableCrossOsArchive: true - lookup-only: true - - - name: Save build artifacts cache - if: steps.build-artifacts-cache.outputs.cache-hit != 'true' - uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: | - packages/cli-*/bin/ - dist/ - key: cli-build-${{ github.run_id }}-${{ inputs.shell }}-${{ inputs.version }}${{ inputs.cache_key_suffix }}-v1 - enableCrossOsArchive: true + # Intra-run handoff, not a kept deliverable — expire it the next day. + retention-days: 1 + # A full re-run of this job replaces its own artifact instead of + # failing on the duplicate name from the previous attempt. + overwrite: true + # dist/* is already compressed (tar.gz/zip/deb/rpm/apk); a light level + # trims the raw bin/ binaries without burning CPU re-packing the rest. + compression-level: 1 + if-no-files-found: error diff --git a/.github/workflows/cli-go-mirror.yml b/.github/workflows/cli-go-mirror.yml index 8999547a2e..1e5d3224e9 100644 --- a/.github/workflows/cli-go-mirror.yml +++ b/.github/workflows/cli-go-mirror.yml @@ -7,13 +7,11 @@ name: Mirror Dependencies # ghcr.io, and AWS ECR. on: - # We can't trigger the mirror job on PR merge because certain tests would fail - # until we mirror some images. E.g. a PR to update the imgproxy image version - # would fail, because there is a test that creates a container from the - # updated image version, which would fail because the image hasn't been - # mirrored yet. It's a catch-22! - # - # TODO: Make the cli start test run *after* we mirror images (if needed). + # This workflow is the manual/bulk entry point for re-mirroring everything. + # Template image bumps are mirrored automatically by mirror-template-images.yml + # on push to develop, which backfills any unmirrored tag when the templates + # Dockerfile changes — so develop and PRs rebased on it stop inheriting the + # `manifest unknown` failure in the ghcr.io-pinned `Start` check. workflow_dispatch: permissions: contents: read diff --git a/.github/workflows/mirror-template-images.yml b/.github/workflows/mirror-template-images.yml new file mode 100644 index 0000000000..40fa3be500 --- /dev/null +++ b/.github/workflows/mirror-template-images.yml @@ -0,0 +1,83 @@ +name: Mirror template images + +# Keeps the ghcr.io/ECR mirror in sync with the image versions pinned in +# apps/cli-go/pkg/config/templates/Dockerfile (the single source of truth for +# `config.Images`). When the Dockerfile changes on develop — most often via a +# merged dependabot `docker` bump — this workflow detects any tag that is not +# yet mirrored and backfills it the same way `cli-go-mirror-image.yml` does. +# +# It runs on `push` to develop (not on the PR) on purpose: mirroring needs the +# AWS role + packages:write, which a dependabot-triggered `pull_request` run +# cannot be granted, and we deliberately avoid `pull_request_target`. The CI +# `Start` job pins SUPABASE_INTERNAL_IMAGE_REGISTRY=ghcr.io, so it only goes +# green once a bumped tag is mirrored here; this backfill runs as soon as the +# bump lands on develop, repopulating ghcr.io/ECR so develop and any PR rebased +# on it pass `Start` instead of inheriting a `manifest unknown` failure. + +on: + push: + branches: + - develop + paths: + - apps/cli-go/pkg/config/templates/Dockerfile + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: mirror-template-images-${{ github.ref }} + cancel-in-progress: false + +jobs: + detect: + name: Detect unmirrored images + runs-on: ubuntu-latest + permissions: + contents: read + packages: read + outputs: + missing: ${{ steps.detect.outputs.missing }} + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - name: Setup + uses: ./.github/actions/setup + with: + dependency-firewall-token: ${{ secrets.DF_FIREWALL_TOKEN }} + + - name: Log in to ghcr.io + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + # Parses the Dockerfile, checks each image against the mirror, and writes + # `missing=` to $GITHUB_OUTPUT. Idempotent: already-mirrored images + # are skipped, so a re-run produces an empty list. + - name: Detect images missing from the mirror + id: detect + run: pnpm exec bun apps/cli/scripts/detect-unmirrored-images.ts + + mirror: + name: Mirror image + needs: detect + if: needs.detect.outputs.missing != '' && needs.detect.outputs.missing != '[]' + permissions: + contents: read + packages: write + id-token: write + strategy: + fail-fast: false + matrix: + image: ${{ fromJson(needs.detect.outputs.missing) }} + # Reuse the existing mirror logic (docker.io -> public.ecr.aws + ghcr.io). + uses: ./.github/workflows/cli-go-mirror-image.yml + with: + image: ${{ matrix.image }} + secrets: + PROD_AWS_ROLE: ${{ secrets.PROD_AWS_ROLE }} diff --git a/.github/workflows/publish-preview-cli-packages.yml b/.github/workflows/publish-preview-cli-packages.yml index e7486fc5f7..206a1931a5 100644 --- a/.github/workflows/publish-preview-cli-packages.yml +++ b/.github/workflows/publish-preview-cli-packages.yml @@ -57,15 +57,10 @@ jobs: with: dependency-firewall-token: ${{ secrets.DF_FIREWALL_TOKEN }} - - name: Restore preview build artifacts cache - uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + - name: Download preview build artifacts + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: - path: | - packages/cli-*/bin/ - dist/ - key: cli-build-${{ github.run_id }}-legacy-${{ env.PREVIEW_VERSION }}-v1 - enableCrossOsArchive: true - fail-on-cache-miss: true + name: cli-build-legacy-${{ env.PREVIEW_VERSION }} - name: Prepare package files run: | diff --git a/.github/workflows/release-shared.yml b/.github/workflows/release-shared.yml index 3e917b971e..7d4301c037 100644 --- a/.github/workflows/release-shared.yml +++ b/.github/workflows/release-shared.yml @@ -75,7 +75,7 @@ jobs: version: ${{ inputs.version }} shell: ${{ inputs.shell }} runner: large-linux-x86 - cache_key_suffix: -github + artifact_name_suffix: -github timeout_minutes: 45 build_timeout_minutes: 20 secrets: @@ -109,15 +109,10 @@ jobs: with: dependency-firewall-token: ${{ secrets.DF_FIREWALL_TOKEN }} - - name: Restore build artifacts cache - uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + - name: Download build artifacts + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: - path: | - packages/cli-*/bin/ - dist/ - key: cli-build-${{ github.run_id }}-${{ inputs.shell }}-${{ inputs.version }}-v1 - enableCrossOsArchive: true - fail-on-cache-miss: true + name: cli-build-${{ inputs.shell }}-${{ inputs.version }} # Docker's classic image store keeps a single platform manifest per # tag, so pulling `alpine:3.21` for amd64 and again for arm64 leaves @@ -245,15 +240,10 @@ jobs: with: dependency-firewall-token: ${{ secrets.DF_FIREWALL_TOKEN }} - - name: Restore build artifacts cache - uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + - name: Download build artifacts + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: - path: | - packages/cli-*/bin/ - dist/ - key: cli-build-${{ github.run_id }}-${{ inputs.shell }}-${{ inputs.version }}-github-v1 - enableCrossOsArchive: true - fail-on-cache-miss: true + name: cli-build-${{ inputs.shell }}-${{ inputs.version }}-github - name: Fix binary permissions run: chmod +x packages/cli-*/bin/supabase || true @@ -304,15 +294,17 @@ jobs: with: dependency-firewall-token: ${{ secrets.DF_FIREWALL_TOKEN }} - - name: Restore build artifacts cache - uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + - name: Download build artifacts + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: - path: | - packages/cli-*/bin/ - dist/ - key: cli-build-${{ github.run_id }}-${{ inputs.shell }}-${{ inputs.version }}-github-v1 - enableCrossOsArchive: true - fail-on-cache-miss: true + name: cli-build-${{ inputs.shell }}-${{ inputs.version }}-github + + # Artifacts are zipped and do not carry Unix permissions, so the compiled + # binaries arrive without the executable bit. publish.ts ships + # packages/cli-*/bin/supabase to npm verbatim, so restore +x before + # publishing or the installed CLI would not be runnable. + - name: Fix binary permissions + run: chmod +x packages/cli-*/bin/supabase || true - name: Sync versions run: pnpm exec bun apps/cli/scripts/sync-versions.ts --version "${VERSION}" @@ -450,8 +442,6 @@ jobs: publish-homebrew: needs: publish if: ${{ !inputs.dry_run && inputs.publish_brew_scoop }} - # github-hosted to share a cache store with build-github/publish, whose - # -github-v1 artifacts this job's checksums must match. runs-on: ubuntu-latest timeout-minutes: 30 env: @@ -468,21 +458,16 @@ jobs: with: dependency-firewall-token: ${{ secrets.DF_FIREWALL_TOKEN }} - # Must restore the github-hosted build (-github-v1), the same artifacts - # the publish job uploads to the GitHub Release. The Bun-compiled binaries - # are not byte-for-byte reproducible across the blacksmith and github - # builds, so the blacksmith dist/checksums.txt does not match the released + # Must download the github-hosted build (-github), the same artifacts the + # publish job uploads to the GitHub Release. The Bun-compiled binaries are + # not byte-for-byte reproducible across the blacksmith and github builds, + # so the blacksmith dist/checksums.txt does not match the released # tarballs. Reading it here produced a formula whose sha256 rejected the # downloaded archive ("Formula reports different checksum"). - - name: Restore build artifacts cache - uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + - name: Download build artifacts + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: - path: | - packages/cli-*/bin/ - dist/ - key: cli-build-${{ github.run_id }}-${{ inputs.shell }}-${{ inputs.version }}-github-v1 - enableCrossOsArchive: true - fail-on-cache-miss: true + name: cli-build-${{ inputs.shell }}-${{ inputs.version }}-github - name: Generate Homebrew tap token id: app-token @@ -513,8 +498,6 @@ jobs: publish-scoop: needs: publish if: ${{ !inputs.dry_run && inputs.publish_brew_scoop }} - # github-hosted to share a cache store with build-github/publish, whose - # -github-v1 artifacts this job's checksums must match. runs-on: ubuntu-latest timeout-minutes: 30 env: @@ -531,21 +514,16 @@ jobs: with: dependency-firewall-token: ${{ secrets.DF_FIREWALL_TOKEN }} - # Must restore the github-hosted build (-github-v1), the same artifacts - # the publish job uploads to the GitHub Release. The Bun-compiled binaries - # are not byte-for-byte reproducible across the blacksmith and github - # builds, so the blacksmith dist/checksums.txt does not match the released + # Must download the github-hosted build (-github), the same artifacts the + # publish job uploads to the GitHub Release. The Bun-compiled binaries are + # not byte-for-byte reproducible across the blacksmith and github builds, + # so the blacksmith dist/checksums.txt does not match the released # tarballs. Reading it here would produce a manifest whose hash rejects the # downloaded archive. - - name: Restore build artifacts cache - uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + - name: Download build artifacts + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: - path: | - packages/cli-*/bin/ - dist/ - key: cli-build-${{ github.run_id }}-${{ inputs.shell }}-${{ inputs.version }}-github-v1 - enableCrossOsArchive: true - fail-on-cache-miss: true + name: cli-build-${{ inputs.shell }}-${{ inputs.version }}-github - name: Generate Scoop bucket token id: app-token diff --git a/apps/cli/scripts/detect-unmirrored-images.ts b/apps/cli/scripts/detect-unmirrored-images.ts new file mode 100644 index 0000000000..44136f8caa --- /dev/null +++ b/apps/cli/scripts/detect-unmirrored-images.ts @@ -0,0 +1,105 @@ +// Detects which images pinned in apps/cli-go/pkg/config/templates/Dockerfile are +// not yet present on every mirror registry and emits the missing ones as JSON. +// Used by the mirror-template-images workflow to drive the backfill matrix. +// +// It checks every image and skips the ones already mirrored everywhere, so +// re-running after a successful mirror is a no-op. The exported helpers are +// unit-tested in detect-unmirrored-images.unit.test.ts; the entry block below +// (guarded by import.meta.main) performs the only side effects. +import { spawnSync } from "node:child_process"; +import { appendFileSync } from "node:fs"; +import process from "node:process"; +import { dockerfileServiceImages } from "../src/shared/services/dockerfile-images.ts"; + +/** + * Registries the mirror publishes to and the CLI pulls from, mirroring Go's + * `utils.GetRegistryImageUrls` (`defaultRegistry` + `ghcrRegistry`). An image + * counts as mirrored only when it exists on EVERY one of these — the mirror + * pushes to all of them at once, so a tag present on one but not another is a + * partial mirror that must be re-pushed. + */ +export const MIRROR_REGISTRIES = ["public.ecr.aws", "ghcr.io"] as const; + +/** + * Mirror destination for an upstream image on a single registry, mirroring Go's + * `utils.GetRegistryImageUrl` (`registry + "/supabase/" + basename`). The + * upstream org is dropped — every image is mirrored under the `supabase/` + * namespace — e.g. `postgrest/postgrest:v14.14` -> `ghcr.io/supabase/postgrest:v14.14`. + */ +export function mirrorImageTarget(image: string, registry: string): string { + const basename = image.slice(image.lastIndexOf("/") + 1); + return `${registry}/supabase/${basename}`; +} + +/** Mirror destinations for an upstream image across every mirror registry. */ +export function mirrorImageTargets( + image: string, + registries: ReadonlyArray = MIRROR_REGISTRIES, +): ReadonlyArray { + return registries.map((registry) => mirrorImageTarget(image, registry)); +} + +export interface MirrorPartition { + /** Images present on every mirror registry — nothing to do. */ + readonly mirrored: ReadonlyArray; + /** Images missing from at least one mirror registry — these need backfilling. */ + readonly missing: ReadonlyArray; +} + +/** + * Split images by whether they are fully mirrored — present on EVERY registry in + * `registries`. An image missing from any one registry lands in `missing` so the + * backfill re-pushes it everywhere. Every (image, registry) pair is queried, each + * distinct image once. No image is skipped up front — a `supabase/*` image that is + * somehow absent is reported just like a third-party one. Idempotent: once an + * image is on all registries, a re-run skips it. + */ +export async function partitionUnmirroredImages( + images: Iterable, + isMirrored: (target: string) => Promise, + registries: ReadonlyArray = MIRROR_REGISTRIES, +): Promise { + const unique = [...new Set(images)]; + const results = await Promise.all( + unique.map(async (image) => { + const presence = await Promise.all( + mirrorImageTargets(image, registries).map((target) => isMirrored(target)), + ); + return { image, mirrored: presence.every(Boolean) }; + }), + ); + + return { + mirrored: results.filter((result) => result.mirrored).map((result) => result.image), + missing: results.filter((result) => !result.mirrored).map((result) => result.image), + }; +} + +// An image counts as mirrored only when this returns true for every registry +// target; both are queried per image by the partition above. +function imageExistsOnMirror(target: string): Promise { + const result = spawnSync("docker", ["buildx", "imagetools", "inspect", target], { + stdio: "ignore", + }); + return Promise.resolve(result.status === 0); +} + +if (import.meta.main) { + const images = dockerfileServiceImages.map((spec) => spec.image); + const { mirrored, missing } = await partitionUnmirroredImages(images, imageExistsOnMirror); + + for (const image of mirrored) { + console.error(`already mirrored: ${image}`); + } + for (const image of missing) { + console.error(`needs mirror: ${image} -> ${mirrorImageTargets(image).join(", ")}`); + } + + const json = JSON.stringify(missing); + console.log(json); + + // Expose the list to the workflow as a step output when running in CI. + if (process.env.GITHUB_OUTPUT) { + appendFileSync(process.env.GITHUB_OUTPUT, `missing=${json}\n`); + } +} diff --git a/apps/cli/scripts/detect-unmirrored-images.unit.test.ts b/apps/cli/scripts/detect-unmirrored-images.unit.test.ts new file mode 100644 index 0000000000..f51420cf04 --- /dev/null +++ b/apps/cli/scripts/detect-unmirrored-images.unit.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, test } from "vitest"; +import { + MIRROR_REGISTRIES, + mirrorImageTarget, + mirrorImageTargets, + partitionUnmirroredImages, +} from "./detect-unmirrored-images.ts"; + +describe("detect unmirrored images", () => { + test("mirrors an upstream image under the supabase namespace of a registry", () => { + // Third-party orgs are dropped; only the basename is kept, matching Go's + // utils.GetRegistryImageUrl. + expect(mirrorImageTarget("postgrest/postgrest:v14.14", "ghcr.io")).toBe( + "ghcr.io/supabase/postgrest:v14.14", + ); + expect(mirrorImageTarget("library/kong:2.8.1", "public.ecr.aws")).toBe( + "public.ecr.aws/supabase/kong:2.8.1", + ); + }); + + test("targets cover every mirror registry (ECR and ghcr.io)", () => { + expect(MIRROR_REGISTRIES).toEqual(["public.ecr.aws", "ghcr.io"]); + expect(mirrorImageTargets("postgrest/postgrest:v14.14")).toEqual([ + "public.ecr.aws/supabase/postgrest:v14.14", + "ghcr.io/supabase/postgrest:v14.14", + ]); + }); + + test("an image is mirrored only when present on ALL registries", async () => { + const present = new Set([ + // kong is on both registries -> mirrored. + "public.ecr.aws/supabase/kong:2.8.1", + "ghcr.io/supabase/kong:2.8.1", + // postgrest is only on ghcr.io -> partial mirror, must be re-pushed. + "ghcr.io/supabase/postgrest:v14.14", + ]); + const queried: string[] = []; + const isMirrored = (target: string) => { + queried.push(target); + return Promise.resolve(present.has(target)); + }; + + const { mirrored, missing } = await partitionUnmirroredImages( + // Duplicate kong to prove de-duplication. + ["library/kong:2.8.1", "postgrest/postgrest:v14.14", "library/kong:2.8.1"], + isMirrored, + ); + + expect(mirrored).toEqual(["library/kong:2.8.1"]); + expect(missing).toEqual(["postgrest/postgrest:v14.14"]); + // Two unique images x two registries = four checks. + expect(queried).toHaveLength(4); + }); + + test("is a no-op once everything is on every registry (idempotent re-run)", async () => { + const allMirrored = () => Promise.resolve(true); + const { mirrored, missing } = await partitionUnmirroredImages( + ["postgrest/postgrest:v14.14", "supabase/logflare:1.45.6"], + allMirrored, + ); + + expect(missing).toEqual([]); + expect(mirrored).toEqual(["postgrest/postgrest:v14.14", "supabase/logflare:1.45.6"]); + }); +});