From b3a518f79e0f6f28758d199c1996a1e8ecfc0eb9 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 30 Jun 2026 11:38:27 +0000 Subject: [PATCH 1/6] ci: auto-mirror template images and stop pinning ghcr.io in Start MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Start check pinned SUPABASE_INTERNAL_IMAGE_REGISTRY=ghcr.io, so a dependabot image bump failed with "manifest unknown" until the new tag was manually mirrored to ghcr.io — yet the PR still merged (Start is not a required check), breaking Start on develop for every subsequent PR. Two changes: - Drop the ghcr.io override in the Start job so `start` uses the default registry fallback (public.ecr.aws -> ghcr.io -> upstream Docker Hub). A freshly bumped tag exists upstream immediately, so Start stays green while the mirror catches up. - Add mirror-template-images.yml: whenever the templates Dockerfile changes, detect any tag missing from the ghcr.io mirror and backfill it via the existing cli-go-mirror-image reusable workflow. Runs on pull_request_target (guarded to same-repo branches) so it works on dependabot PRs, parses the Dockerfile statically without executing checked-out code, and only mirrors missing tags. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DEeGQ3JAwQD1PGEENE13dc --- .github/workflows/cli-go-ci.yml | 8 +- .github/workflows/cli-go-mirror.yml | 13 +- .github/workflows/mirror-template-images.yml | 123 +++++++++++++++++++ 3 files changed, 135 insertions(+), 9 deletions(-) create mode 100644 .github/workflows/mirror-template-images.yml diff --git a/.github/workflows/cli-go-ci.yml b/.github/workflows/cli-go-ci.yml index 0fc58f8c37..597bf4479d 100644 --- a/.github/workflows/cli-go-ci.yml +++ b/.github/workflows/cli-go-ci.yml @@ -105,9 +105,13 @@ jobs: - run: go build main.go - run: ./main init - run: sed -i '/\[db.pooler\]/{n;s/.*/enabled = true/}' supabase/config.toml + # No SUPABASE_INTERNAL_IMAGE_REGISTRY override: let `start` use the default + # registry fallback chain (public.ecr.aws -> ghcr.io -> upstream Docker Hub). + # A freshly bumped dependency (e.g. a dependabot version bump) may not be + # mirrored to ghcr.io/ECR yet, but the upstream image already exists, so the + # fallback keeps this check green instead of failing on `manifest unknown`. + # The mirror-template-images workflow backfills ghcr.io/ECR in parallel. - run: ./main start --workdir tests - env: - SUPABASE_INTERNAL_IMAGE_REGISTRY: ghcr.io - name: Install websocat run: | sudo wget -qO /usr/local/bin/websocat https://github.com/vi/websocat/releases/latest/download/websocat.x86_64-unknown-linux-musl diff --git a/.github/workflows/cli-go-mirror.yml b/.github/workflows/cli-go-mirror.yml index 8999547a2e..c5e250e0f7 100644 --- a/.github/workflows/cli-go-mirror.yml +++ b/.github/workflows/cli-go-mirror.yml @@ -7,13 +7,12 @@ 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. + # Per-PR mirroring of template image bumps is handled automatically by + # mirror-template-images.yml, which backfills any unmirrored tag whenever the + # templates Dockerfile changes. The `Start` check no longer pins ghcr.io, so a + # freshly bumped image stays green via the upstream Docker Hub fallback while + # the mirror catches up — resolving the old mirror-vs-test catch-22. 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..78fb989fea --- /dev/null +++ b/.github/workflows/mirror-template-images.yml @@ -0,0 +1,123 @@ +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`). Whenever the Dockerfile changes — most often via a +# 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, so the +# image is available on the registries the CLI prefers before the PR merges. +# +# We use `pull_request_target` so the privileged mirror step has access to the +# AWS role and packages:write token even on dependabot PRs (dependabot-triggered +# `pull_request` runs get a read-only token and no secrets). To stay safe under +# `pull_request_target`: +# - the detect job only runs for branches on this repo (not external forks), +# - it parses the Dockerfile statically and never executes checked-out code, +# - the privileged mirror job never checks out PR content; it only consumes +# the list of image references produced by detect, and +# - we only mirror tags that are missing, never overwriting existing ones. + +on: + pull_request_target: + paths: + - apps/cli-go/pkg/config/templates/Dockerfile + push: + branches: + - develop + paths: + - apps/cli-go/pkg/config/templates/Dockerfile + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: mirror-template-images-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: false + +jobs: + detect: + name: Detect unmirrored images + # Only same-repo branches (maintainers and dependabot) reach the privileged + # mirror job. External fork PRs are skipped — a maintainer mirrors those. + if: >- + github.event_name != 'pull_request_target' || + github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + permissions: + contents: read + packages: read + outputs: + missing: ${{ steps.detect.outputs.missing }} + steps: + - name: Checkout Dockerfile + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: ${{ github.event.pull_request.head.sha || github.ref }} + persist-credentials: false + # Fetch only the Dockerfile; we read it as data and never run it. + sparse-checkout: | + apps/cli-go/pkg/config/templates/Dockerfile + sparse-checkout-cone-mode: false + + - 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 }} + + - name: Detect images missing from the mirror + id: detect + env: + DOCKERFILE: apps/cli-go/pkg/config/templates/Dockerfile + run: | + set -euo pipefail + missing=() + # Mirror destinations live under the supabase/ namespace, matching + # utils.GetRegistryImageUrl (registry + "/supabase/" + basename). + while read -r _from image _as _name; do + [ "${_as,,}" = "as" ] || continue + org="${image%%/*}" + # supabase/* images are already published to ghcr.io/ECR by the + # release pipeline, so they need no mirroring — except logflare, + # which is only published to Docker Hub. This mirrors the filter in + # apps/cli-go/tools/listdep/main.go. + if [ "$org" = "supabase" ] && [[ "$image" != supabase/logflare:* ]]; then + continue + fi + dest="ghcr.io/supabase/${image##*/}" + if docker buildx imagetools inspect "$dest" >/dev/null 2>&1; then + echo "already mirrored: $dest" + else + echo "needs mirror: $image -> $dest" + missing+=("$image") + fi + done < <(grep -iE '^[[:space:]]*FROM[[:space:]]' "$DOCKERFILE") + + if [ "${#missing[@]}" -eq 0 ]; then + echo "All template images are already mirrored." + echo 'missing=[]' >> "$GITHUB_OUTPUT" + else + json="$(printf '%s\n' "${missing[@]}" | jq -Rc . | jq -cs .)" + echo "missing=$json" >> "$GITHUB_OUTPUT" + fi + + 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 }} From 5dd2eaff87b9468e9833197a6369ea62f7771403 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 30 Jun 2026 13:26:38 +0000 Subject: [PATCH 2/6] ci: mirror template images on push to develop instead of pull_request_target MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Avoid pull_request_target (privileged token + cloud creds exposed to PR context). Dependabot pull_request runs can't be granted the AWS role / packages:write needed to mirror anyway, and Start no longer needs the mirror to be done before merge (it falls back to the upstream image), so the backfill runs post-merge on push to develop — a trusted context — and develop self-heals within a minute. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DEeGQ3JAwQD1PGEENE13dc --- .github/workflows/cli-go-mirror.yml | 10 +++--- .github/workflows/mirror-template-images.yml | 34 ++++++-------------- 2 files changed, 15 insertions(+), 29 deletions(-) diff --git a/.github/workflows/cli-go-mirror.yml b/.github/workflows/cli-go-mirror.yml index c5e250e0f7..ac55189c95 100644 --- a/.github/workflows/cli-go-mirror.yml +++ b/.github/workflows/cli-go-mirror.yml @@ -8,11 +8,11 @@ name: Mirror Dependencies on: # This workflow is the manual/bulk entry point for re-mirroring everything. - # Per-PR mirroring of template image bumps is handled automatically by - # mirror-template-images.yml, which backfills any unmirrored tag whenever the - # templates Dockerfile changes. The `Start` check no longer pins ghcr.io, so a - # freshly bumped image stays green via the upstream Docker Hub fallback while - # the mirror catches up — resolving the old mirror-vs-test catch-22. + # 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. The `Start` check no longer pins ghcr.io, so a freshly + # bumped image stays green via the upstream Docker Hub fallback while the mirror + # catches up — resolving the old mirror-vs-test catch-22. workflow_dispatch: permissions: contents: read diff --git a/.github/workflows/mirror-template-images.yml b/.github/workflows/mirror-template-images.yml index 78fb989fea..566188c18d 100644 --- a/.github/workflows/mirror-template-images.yml +++ b/.github/workflows/mirror-template-images.yml @@ -2,25 +2,18 @@ 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`). Whenever the Dockerfile changes — most often via a -# 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, so the -# image is available on the registries the CLI prefers before the PR merges. +# `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. # -# We use `pull_request_target` so the privileged mirror step has access to the -# AWS role and packages:write token even on dependabot PRs (dependabot-triggered -# `pull_request` runs get a read-only token and no secrets). To stay safe under -# `pull_request_target`: -# - the detect job only runs for branches on this repo (not external forks), -# - it parses the Dockerfile statically and never executes checked-out code, -# - the privileged mirror job never checks out PR content; it only consumes -# the list of image references produced by detect, and -# - we only mirror tags that are missing, never overwriting existing ones. +# 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 PR +# itself stays green without the mirror because the `Start` check no longer +# pins ghcr.io and falls back to the upstream Docker Hub image, so develop is +# never broken in the short window before this backfill runs. on: - pull_request_target: - paths: - - apps/cli-go/pkg/config/templates/Dockerfile push: branches: - develop @@ -32,17 +25,12 @@ permissions: contents: read concurrency: - group: mirror-template-images-${{ github.event.pull_request.number || github.ref }} + group: mirror-template-images-${{ github.ref }} cancel-in-progress: false jobs: detect: name: Detect unmirrored images - # Only same-repo branches (maintainers and dependabot) reach the privileged - # mirror job. External fork PRs are skipped — a maintainer mirrors those. - if: >- - github.event_name != 'pull_request_target' || - github.event.pull_request.head.repo.full_name == github.repository runs-on: ubuntu-latest permissions: contents: read @@ -53,9 +41,7 @@ jobs: - name: Checkout Dockerfile uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: - ref: ${{ github.event.pull_request.head.sha || github.ref }} persist-credentials: false - # Fetch only the Dockerfile; we read it as data and never run it. sparse-checkout: | apps/cli-go/pkg/config/templates/Dockerfile sparse-checkout-cone-mode: false From 41c762a717bc9eb3f283bb4105381fe071363936 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 30 Jun 2026 13:37:50 +0000 Subject: [PATCH 3/6] ci: keep ghcr.io pin in Start; mirror docs reflect backfill behavior Revert the cli-go-ci.yml change so the Start job keeps validating against ghcr.io. The post-merge mirror-template-images workflow repopulates ghcr.io/ECR as soon as a bump lands on develop, so develop and PRs rebased on it stop inheriting the manifest-unknown failure. Update the related comments to match. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DEeGQ3JAwQD1PGEENE13dc --- .github/workflows/cli-go-ci.yml | 8 ++------ .github/workflows/cli-go-mirror.yml | 5 ++--- .github/workflows/mirror-template-images.yml | 9 +++++---- 3 files changed, 9 insertions(+), 13 deletions(-) diff --git a/.github/workflows/cli-go-ci.yml b/.github/workflows/cli-go-ci.yml index 597bf4479d..0fc58f8c37 100644 --- a/.github/workflows/cli-go-ci.yml +++ b/.github/workflows/cli-go-ci.yml @@ -105,13 +105,9 @@ jobs: - run: go build main.go - run: ./main init - run: sed -i '/\[db.pooler\]/{n;s/.*/enabled = true/}' supabase/config.toml - # No SUPABASE_INTERNAL_IMAGE_REGISTRY override: let `start` use the default - # registry fallback chain (public.ecr.aws -> ghcr.io -> upstream Docker Hub). - # A freshly bumped dependency (e.g. a dependabot version bump) may not be - # mirrored to ghcr.io/ECR yet, but the upstream image already exists, so the - # fallback keeps this check green instead of failing on `manifest unknown`. - # The mirror-template-images workflow backfills ghcr.io/ECR in parallel. - run: ./main start --workdir tests + env: + SUPABASE_INTERNAL_IMAGE_REGISTRY: ghcr.io - name: Install websocat run: | sudo wget -qO /usr/local/bin/websocat https://github.com/vi/websocat/releases/latest/download/websocat.x86_64-unknown-linux-musl diff --git a/.github/workflows/cli-go-mirror.yml b/.github/workflows/cli-go-mirror.yml index ac55189c95..1e5d3224e9 100644 --- a/.github/workflows/cli-go-mirror.yml +++ b/.github/workflows/cli-go-mirror.yml @@ -10,9 +10,8 @@ on: # 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. The `Start` check no longer pins ghcr.io, so a freshly - # bumped image stays green via the upstream Docker Hub fallback while the mirror - # catches up — resolving the old mirror-vs-test catch-22. + # 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 index 566188c18d..157dff2b04 100644 --- a/.github/workflows/mirror-template-images.yml +++ b/.github/workflows/mirror-template-images.yml @@ -8,10 +8,11 @@ name: Mirror template images # # 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 PR -# itself stays green without the mirror because the `Start` check no longer -# pins ghcr.io and falls back to the upstream Docker Hub image, so develop is -# never broken in the short window before this backfill runs. +# 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: From 33e1d2c96ece7c95fa44f4878accc9181b5fc03a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 30 Jun 2026 13:49:54 +0000 Subject: [PATCH 4/6] ci: detect unmirrored template images via tested bun script MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the inline bash in mirror-template-images.yml with a Bun script backed by a unit-tested module: - src/shared/services/mirror-images.ts — mirrorImageTarget (mirrors Go's utils.GetRegistryImageUrl) and partitionUnmirroredImages, the idempotent core that splits images by mirror presence. Reuses the existing parseDockerfileServiceImages parser. - scripts/detect-unmirrored-images.ts — thin CI entry that runs the docker inspect check and writes missing= to GITHUB_OUTPUT. Check every image, not just third-party ones: any image already on the mirror is skipped, so a supabase/* image that is somehow absent is still backfilled and a re-run is a no-op. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DEeGQ3JAwQD1PGEENE13dc --- .github/workflows/mirror-template-images.yml | 47 +++---------- apps/cli/scripts/detect-unmirrored-images.ts | 41 +++++++++++ apps/cli/src/shared/services/mirror-images.ts | 56 +++++++++++++++ .../services/mirror-images.unit.test.ts | 68 +++++++++++++++++++ 4 files changed, 175 insertions(+), 37 deletions(-) create mode 100644 apps/cli/scripts/detect-unmirrored-images.ts create mode 100644 apps/cli/src/shared/services/mirror-images.ts create mode 100644 apps/cli/src/shared/services/mirror-images.unit.test.ts diff --git a/.github/workflows/mirror-template-images.yml b/.github/workflows/mirror-template-images.yml index 157dff2b04..40fa3be500 100644 --- a/.github/workflows/mirror-template-images.yml +++ b/.github/workflows/mirror-template-images.yml @@ -39,13 +39,15 @@ jobs: outputs: missing: ${{ steps.detect.outputs.missing }} steps: - - name: Checkout Dockerfile + - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - sparse-checkout: | - apps/cli-go/pkg/config/templates/Dockerfile - sparse-checkout-cone-mode: 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 @@ -54,41 +56,12 @@ jobs: 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 - env: - DOCKERFILE: apps/cli-go/pkg/config/templates/Dockerfile - run: | - set -euo pipefail - missing=() - # Mirror destinations live under the supabase/ namespace, matching - # utils.GetRegistryImageUrl (registry + "/supabase/" + basename). - while read -r _from image _as _name; do - [ "${_as,,}" = "as" ] || continue - org="${image%%/*}" - # supabase/* images are already published to ghcr.io/ECR by the - # release pipeline, so they need no mirroring — except logflare, - # which is only published to Docker Hub. This mirrors the filter in - # apps/cli-go/tools/listdep/main.go. - if [ "$org" = "supabase" ] && [[ "$image" != supabase/logflare:* ]]; then - continue - fi - dest="ghcr.io/supabase/${image##*/}" - if docker buildx imagetools inspect "$dest" >/dev/null 2>&1; then - echo "already mirrored: $dest" - else - echo "needs mirror: $image -> $dest" - missing+=("$image") - fi - done < <(grep -iE '^[[:space:]]*FROM[[:space:]]' "$DOCKERFILE") - - if [ "${#missing[@]}" -eq 0 ]; then - echo "All template images are already mirrored." - echo 'missing=[]' >> "$GITHUB_OUTPUT" - else - json="$(printf '%s\n' "${missing[@]}" | jq -Rc . | jq -cs .)" - echo "missing=$json" >> "$GITHUB_OUTPUT" - fi + run: pnpm exec bun apps/cli/scripts/detect-unmirrored-images.ts mirror: name: Mirror image diff --git a/apps/cli/scripts/detect-unmirrored-images.ts b/apps/cli/scripts/detect-unmirrored-images.ts new file mode 100644 index 0000000000..e8941dad69 --- /dev/null +++ b/apps/cli/scripts/detect-unmirrored-images.ts @@ -0,0 +1,41 @@ +// Detects which images pinned in apps/cli-go/pkg/config/templates/Dockerfile are +// not yet present on the ghcr.io mirror and emits the list as JSON. Used by the +// mirror-template-images workflow to drive the backfill matrix. +// +// It checks every image and skips the ones already mirrored, so re-running after +// a successful mirror is a no-op. The check itself (`docker buildx imagetools +// inspect`) is the only side effect; the partitioning logic lives in +// src/shared/services/mirror-images.ts and is unit-tested there. +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"; +import { + mirrorImageTarget, + partitionUnmirroredImages, +} from "../src/shared/services/mirror-images.ts"; + +function imageExistsOnMirror(target: string): Promise { + const result = spawnSync("docker", ["buildx", "imagetools", "inspect", target], { + stdio: "ignore", + }); + return Promise.resolve(result.status === 0); +} + +const images = dockerfileServiceImages.map((spec) => spec.image); +const { mirrored, missing } = await partitionUnmirroredImages(images, imageExistsOnMirror); + +for (const image of mirrored) { + console.error(`already mirrored: ${mirrorImageTarget(image)}`); +} +for (const image of missing) { + console.error(`needs mirror: ${image} -> ${mirrorImageTarget(image)}`); +} + +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/src/shared/services/mirror-images.ts b/apps/cli/src/shared/services/mirror-images.ts new file mode 100644 index 0000000000..1ef5b684ea --- /dev/null +++ b/apps/cli/src/shared/services/mirror-images.ts @@ -0,0 +1,56 @@ +import { parseDockerfileServiceImages } from "./dockerfile-images.ts"; + +/** + * Default registry the CLI prefers and that the mirror backfills. Image + * availability here is what the `Start` CI job validates. + */ +const MIRROR_REGISTRY = "ghcr.io"; + +/** + * Mirror destination for an upstream image reference, 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 = MIRROR_REGISTRY): string { + const basename = image.slice(image.lastIndexOf("/") + 1); + return `${registry}/supabase/${basename}`; +} + +/** Every image pinned in the Dockerfile (each `FROM AS ` line). */ +export function dockerfileImages(dockerfile: string): ReadonlyArray { + return parseDockerfileServiceImages(dockerfile).map((spec) => spec.image); +} + +export interface MirrorPartition { + /** Images whose mirror target already exists — nothing to do. */ + readonly mirrored: ReadonlyArray; + /** Images missing from the mirror — these need to be backfilled. */ + readonly missing: ReadonlyArray; +} + +/** + * Split images by whether their mirror target already exists, querying every + * distinct image once. This is the idempotent core of the backfill: re-running + * after a successful mirror returns an empty `missing` list, so mirroring is a + * no-op. No image is skipped up front — a `supabase/*` image that is somehow + * absent from the mirror is reported just like a third-party one. + */ +export async function partitionUnmirroredImages( + images: Iterable, + isMirrored: (target: string) => Promise, + registry: string = MIRROR_REGISTRY, +): Promise { + const unique = [...new Set(images)]; + const results = await Promise.all( + unique.map(async (image) => ({ + image, + mirrored: await isMirrored(mirrorImageTarget(image, registry)), + })), + ); + + return { + mirrored: results.filter((result) => result.mirrored).map((result) => result.image), + missing: results.filter((result) => !result.mirrored).map((result) => result.image), + }; +} diff --git a/apps/cli/src/shared/services/mirror-images.unit.test.ts b/apps/cli/src/shared/services/mirror-images.unit.test.ts new file mode 100644 index 0000000000..0abbd1c36e --- /dev/null +++ b/apps/cli/src/shared/services/mirror-images.unit.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, test } from "vitest"; +import serviceImagesDockerfile from "../../../../cli-go/pkg/config/templates/Dockerfile" with { type: "text" }; +import { dockerfileImages, mirrorImageTarget, partitionUnmirroredImages } from "./mirror-images.ts"; + +describe("mirror images", () => { + test("mirrors every upstream image under the supabase namespace", () => { + // Third-party orgs are dropped; only the basename is kept, matching Go's + // utils.GetRegistryImageUrl. + expect(mirrorImageTarget("postgrest/postgrest:v14.14")).toBe( + "ghcr.io/supabase/postgrest:v14.14", + ); + expect(mirrorImageTarget("library/kong:2.8.1")).toBe("ghcr.io/supabase/kong:2.8.1"); + expect(mirrorImageTarget("supabase/logflare:1.45.6")).toBe("ghcr.io/supabase/logflare:1.45.6"); + }); + + test("supports an alternate mirror registry", () => { + expect(mirrorImageTarget("postgrest/postgrest:v14.14", "public.ecr.aws")).toBe( + "public.ecr.aws/supabase/postgrest:v14.14", + ); + }); + + test("lists every FROM image pinned in the Dockerfile", () => { + expect( + dockerfileImages(` + FROM postgrest/postgrest:v14.14 AS postgrest + RUN echo ignored + FROM supabase/logflare:1.45.6 AS logflare + `), + ).toEqual(["postgrest/postgrest:v14.14", "supabase/logflare:1.45.6"]); + }); + + test("includes every image in the real Dockerfile, supabase/* ones too", () => { + const images = dockerfileImages(serviceImagesDockerfile); + expect(images).toContain("postgrest/postgrest:v14.14"); + // No image is filtered out by org — supabase/* images are checked as well. + expect(images.some((image) => image.startsWith("supabase/"))).toBe(true); + }); + + test("partitions images by mirror presence and queries each only once", async () => { + const onMirror = new Set(["ghcr.io/supabase/kong:2.8.1"]); + const queried: string[] = []; + const isMirrored = (target: string) => { + queried.push(target); + return Promise.resolve(onMirror.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"]); + expect(queried).toHaveLength(2); + }); + + test("is a no-op once everything is mirrored (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"]); + }); +}); From cc78a1da4204e577e48b5c6b675e04544aabca3f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 30 Jun 2026 13:56:24 +0000 Subject: [PATCH 5/6] ci: require template images on every mirror registry (ECR + ghcr.io) An image counts as mirrored only when present on ALL mirror registries (public.ecr.aws and ghcr.io), not just one. The mirror pushes to both at once, so a tag present on one but missing from the other is a partial mirror that must be re-pushed. partitionUnmirroredImages now checks every (image, registry) pair via mirrorImageTargets and flags an image as missing if any registry lacks it. Anonymous imagetools inspect works for public ECR, so the detect job needs no AWS credentials. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DEeGQ3JAwQD1PGEENE13dc --- apps/cli/scripts/detect-unmirrored-images.ts | 8 ++-- apps/cli/src/shared/services/mirror-images.ts | 48 ++++++++++++------- .../services/mirror-images.unit.test.ts | 42 +++++++++++----- 3 files changed, 65 insertions(+), 33 deletions(-) diff --git a/apps/cli/scripts/detect-unmirrored-images.ts b/apps/cli/scripts/detect-unmirrored-images.ts index e8941dad69..bf48afa047 100644 --- a/apps/cli/scripts/detect-unmirrored-images.ts +++ b/apps/cli/scripts/detect-unmirrored-images.ts @@ -11,10 +11,12 @@ import { appendFileSync } from "node:fs"; import process from "node:process"; import { dockerfileServiceImages } from "../src/shared/services/dockerfile-images.ts"; import { - mirrorImageTarget, + mirrorImageTargets, partitionUnmirroredImages, } from "../src/shared/services/mirror-images.ts"; +// An image counts as mirrored only when this returns true for every registry +// target (public ECR and ghcr.io); both are queried per image by the partition. function imageExistsOnMirror(target: string): Promise { const result = spawnSync("docker", ["buildx", "imagetools", "inspect", target], { stdio: "ignore", @@ -26,10 +28,10 @@ const images = dockerfileServiceImages.map((spec) => spec.image); const { mirrored, missing } = await partitionUnmirroredImages(images, imageExistsOnMirror); for (const image of mirrored) { - console.error(`already mirrored: ${mirrorImageTarget(image)}`); + console.error(`already mirrored: ${image}`); } for (const image of missing) { - console.error(`needs mirror: ${image} -> ${mirrorImageTarget(image)}`); + console.error(`needs mirror: ${image} -> ${mirrorImageTargets(image).join(", ")}`); } const json = JSON.stringify(missing); diff --git a/apps/cli/src/shared/services/mirror-images.ts b/apps/cli/src/shared/services/mirror-images.ts index 1ef5b684ea..16fde42265 100644 --- a/apps/cli/src/shared/services/mirror-images.ts +++ b/apps/cli/src/shared/services/mirror-images.ts @@ -1,52 +1,66 @@ import { parseDockerfileServiceImages } from "./dockerfile-images.ts"; /** - * Default registry the CLI prefers and that the mirror backfills. Image - * availability here is what the `Start` CI job validates. + * 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. */ -const MIRROR_REGISTRY = "ghcr.io"; +export const MIRROR_REGISTRIES = ["public.ecr.aws", "ghcr.io"] as const; /** - * Mirror destination for an upstream image reference, mirroring Go's + * 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 = MIRROR_REGISTRY): string { +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)); +} + /** Every image pinned in the Dockerfile (each `FROM AS ` line). */ export function dockerfileImages(dockerfile: string): ReadonlyArray { return parseDockerfileServiceImages(dockerfile).map((spec) => spec.image); } export interface MirrorPartition { - /** Images whose mirror target already exists — nothing to do. */ + /** Images present on every mirror registry — nothing to do. */ readonly mirrored: ReadonlyArray; - /** Images missing from the mirror — these need to be backfilled. */ + /** Images missing from at least one mirror registry — these need backfilling. */ readonly missing: ReadonlyArray; } /** - * Split images by whether their mirror target already exists, querying every - * distinct image once. This is the idempotent core of the backfill: re-running - * after a successful mirror returns an empty `missing` list, so mirroring is a - * no-op. No image is skipped up front — a `supabase/*` image that is somehow - * absent from the mirror is reported just like a third-party one. + * 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, - registry: string = MIRROR_REGISTRY, + registries: ReadonlyArray = MIRROR_REGISTRIES, ): Promise { const unique = [...new Set(images)]; const results = await Promise.all( - unique.map(async (image) => ({ - image, - mirrored: await isMirrored(mirrorImageTarget(image, registry)), - })), + unique.map(async (image) => { + const presence = await Promise.all( + mirrorImageTargets(image, registries).map((target) => isMirrored(target)), + ); + return { image, mirrored: presence.every(Boolean) }; + }), ); return { diff --git a/apps/cli/src/shared/services/mirror-images.unit.test.ts b/apps/cli/src/shared/services/mirror-images.unit.test.ts index 0abbd1c36e..fa110ffcfe 100644 --- a/apps/cli/src/shared/services/mirror-images.unit.test.ts +++ b/apps/cli/src/shared/services/mirror-images.unit.test.ts @@ -1,22 +1,31 @@ import { describe, expect, test } from "vitest"; import serviceImagesDockerfile from "../../../../cli-go/pkg/config/templates/Dockerfile" with { type: "text" }; -import { dockerfileImages, mirrorImageTarget, partitionUnmirroredImages } from "./mirror-images.ts"; +import { + dockerfileImages, + MIRROR_REGISTRIES, + mirrorImageTarget, + mirrorImageTargets, + partitionUnmirroredImages, +} from "./mirror-images.ts"; describe("mirror images", () => { - test("mirrors every upstream image under the supabase namespace", () => { + 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")).toBe( + expect(mirrorImageTarget("postgrest/postgrest:v14.14", "ghcr.io")).toBe( "ghcr.io/supabase/postgrest:v14.14", ); - expect(mirrorImageTarget("library/kong:2.8.1")).toBe("ghcr.io/supabase/kong:2.8.1"); - expect(mirrorImageTarget("supabase/logflare:1.45.6")).toBe("ghcr.io/supabase/logflare:1.45.6"); + expect(mirrorImageTarget("library/kong:2.8.1", "public.ecr.aws")).toBe( + "public.ecr.aws/supabase/kong:2.8.1", + ); }); - test("supports an alternate mirror registry", () => { - expect(mirrorImageTarget("postgrest/postgrest:v14.14", "public.ecr.aws")).toBe( + 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("lists every FROM image pinned in the Dockerfile", () => { @@ -36,12 +45,18 @@ describe("mirror images", () => { expect(images.some((image) => image.startsWith("supabase/"))).toBe(true); }); - test("partitions images by mirror presence and queries each only once", async () => { - const onMirror = new Set(["ghcr.io/supabase/kong:2.8.1"]); + 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(onMirror.has(target)); + return Promise.resolve(present.has(target)); }; const { mirrored, missing } = await partitionUnmirroredImages( @@ -52,10 +67,11 @@ describe("mirror images", () => { expect(mirrored).toEqual(["library/kong:2.8.1"]); expect(missing).toEqual(["postgrest/postgrest:v14.14"]); - expect(queried).toHaveLength(2); + // Two unique images x two registries = four checks. + expect(queried).toHaveLength(4); }); - test("is a no-op once everything is mirrored (idempotent re-run)", async () => { + 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"], From 70aa7ed62ce45347d1fedf16853da8afb95487ef Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 30 Jun 2026 14:08:42 +0000 Subject: [PATCH 6/6] refactor(ci): inline mirror-detection logic into the script The mirror helpers were used only by the detect script and its test, so keep them in scripts/detect-unmirrored-images.ts instead of a separate src module. The executable entry is guarded by import.meta.main so the exported helpers can be imported by the unit test without side effects. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DEeGQ3JAwQD1PGEENE13dc --- apps/cli/scripts/detect-unmirrored-images.ts | 110 ++++++++++++++---- .../detect-unmirrored-images.unit.test.ts} | 23 +--- apps/cli/src/shared/services/mirror-images.ts | 70 ----------- 3 files changed, 88 insertions(+), 115 deletions(-) rename apps/cli/{src/shared/services/mirror-images.unit.test.ts => scripts/detect-unmirrored-images.unit.test.ts} (72%) delete mode 100644 apps/cli/src/shared/services/mirror-images.ts diff --git a/apps/cli/scripts/detect-unmirrored-images.ts b/apps/cli/scripts/detect-unmirrored-images.ts index bf48afa047..44136f8caa 100644 --- a/apps/cli/scripts/detect-unmirrored-images.ts +++ b/apps/cli/scripts/detect-unmirrored-images.ts @@ -1,22 +1,82 @@ // Detects which images pinned in apps/cli-go/pkg/config/templates/Dockerfile are -// not yet present on the ghcr.io mirror and emits the list as JSON. Used by the -// mirror-template-images workflow to drive the backfill matrix. +// 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, so re-running after -// a successful mirror is a no-op. The check itself (`docker buildx imagetools -// inspect`) is the only side effect; the partitioning logic lives in -// src/shared/services/mirror-images.ts and is unit-tested there. +// 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"; -import { - mirrorImageTargets, - partitionUnmirroredImages, -} from "../src/shared/services/mirror-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 (public ECR and ghcr.io); both are queried per image by the partition. +// 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", @@ -24,20 +84,22 @@ function imageExistsOnMirror(target: string): Promise { return Promise.resolve(result.status === 0); } -const images = dockerfileServiceImages.map((spec) => spec.image); -const { mirrored, missing } = await partitionUnmirroredImages(images, imageExistsOnMirror); +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(", ")}`); -} + 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); + 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`); + // 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/src/shared/services/mirror-images.unit.test.ts b/apps/cli/scripts/detect-unmirrored-images.unit.test.ts similarity index 72% rename from apps/cli/src/shared/services/mirror-images.unit.test.ts rename to apps/cli/scripts/detect-unmirrored-images.unit.test.ts index fa110ffcfe..f51420cf04 100644 --- a/apps/cli/src/shared/services/mirror-images.unit.test.ts +++ b/apps/cli/scripts/detect-unmirrored-images.unit.test.ts @@ -1,14 +1,12 @@ import { describe, expect, test } from "vitest"; -import serviceImagesDockerfile from "../../../../cli-go/pkg/config/templates/Dockerfile" with { type: "text" }; import { - dockerfileImages, MIRROR_REGISTRIES, mirrorImageTarget, mirrorImageTargets, partitionUnmirroredImages, -} from "./mirror-images.ts"; +} from "./detect-unmirrored-images.ts"; -describe("mirror images", () => { +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. @@ -28,23 +26,6 @@ describe("mirror images", () => { ]); }); - test("lists every FROM image pinned in the Dockerfile", () => { - expect( - dockerfileImages(` - FROM postgrest/postgrest:v14.14 AS postgrest - RUN echo ignored - FROM supabase/logflare:1.45.6 AS logflare - `), - ).toEqual(["postgrest/postgrest:v14.14", "supabase/logflare:1.45.6"]); - }); - - test("includes every image in the real Dockerfile, supabase/* ones too", () => { - const images = dockerfileImages(serviceImagesDockerfile); - expect(images).toContain("postgrest/postgrest:v14.14"); - // No image is filtered out by org — supabase/* images are checked as well. - expect(images.some((image) => image.startsWith("supabase/"))).toBe(true); - }); - test("an image is mirrored only when present on ALL registries", async () => { const present = new Set([ // kong is on both registries -> mirrored. diff --git a/apps/cli/src/shared/services/mirror-images.ts b/apps/cli/src/shared/services/mirror-images.ts deleted file mode 100644 index 16fde42265..0000000000 --- a/apps/cli/src/shared/services/mirror-images.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { parseDockerfileServiceImages } from "./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)); -} - -/** Every image pinned in the Dockerfile (each `FROM AS ` line). */ -export function dockerfileImages(dockerfile: string): ReadonlyArray { - return parseDockerfileServiceImages(dockerfile).map((spec) => spec.image); -} - -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), - }; -}