From 2f4546b76e7f7079a571cd9ccd2857e6e2f1b27e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 12:34:19 +0000 Subject: [PATCH 1/2] Mirror released images to AWS ECR Public via supabase/cli dispatch Slim images are published to ghcr.io/supabase/cli/ only, so the CLI cannot pull them through public.ecr.aws like its other images, and mirror drift keeps surfacing as manifest-not-found failures in dependent CI. Reuse the mirror machinery and AWS credentials that already live in supabase/cli instead of provisioning AWS access for this repository. - scripts/ecr-mirror.sh sends a mirror-slim-image repository_dispatch to supabase/cli and polls public.ecr.aws/supabase/cli/: anonymously until its index digest matches the published GHCR digest. Its sync mode audits every published release and can re-request out-of-sync tags, which doubles as the backfill path. - service-release.yml gains a mirror-ecr job between publish-image and publish-release. It skips with a notice until the CLI_MIRROR_DISPATCH_TOKEN secret exists; once configured, an unverified mirror fails the release, and verified releases list the ECR references in their notes. - ecr-mirror-check.yml runs the sync audit daily. - docs/design/ecr-mirror-dispatch.md records the dispatch contract the supabase/cli handler must implement (digest-preserving copy, payload validation, repository creation) and the setup checklist. - scripts/test-ecr-mirror.sh covers the payload contract, input validation, and the token guard, and is wired into repository checks. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01YaJgsq9xTCpMnEhqesr87S --- .github/workflows/ecr-mirror-check.yml | 79 ++++++++ .github/workflows/repository-checks.yml | 1 + .github/workflows/service-release.yml | 88 +++++++++ docs/design/ecr-mirror-dispatch.md | 86 +++++++++ scripts/ecr-mirror.sh | 244 ++++++++++++++++++++++++ scripts/test-ecr-mirror.sh | 109 +++++++++++ scripts/test-external-workflows.sh | 1 + 7 files changed, 608 insertions(+) create mode 100644 .github/workflows/ecr-mirror-check.yml create mode 100644 docs/design/ecr-mirror-dispatch.md create mode 100755 scripts/ecr-mirror.sh create mode 100755 scripts/test-ecr-mirror.sh diff --git a/.github/workflows/ecr-mirror-check.yml b/.github/workflows/ecr-mirror-check.yml new file mode 100644 index 0000000..fca3510 --- /dev/null +++ b/.github/workflows/ecr-mirror-check.yml @@ -0,0 +1,79 @@ +name: ECR mirror check + +"on": + schedule: + - cron: "17 6 * * *" + workflow_dispatch: + inputs: + request: + description: Dispatch mirror requests for out-of-sync releases. + required: false + type: boolean + default: false + +permissions: + contents: read + +concurrency: + group: ecr-mirror-check + cancel-in-progress: false + +jobs: + check: + name: check ECR Public mirrors + runs-on: ubuntu-24.04 + timeout-minutes: 120 + steps: + - name: Checkout mirror scripts + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 1 + persist-credentials: false + + - name: Check mirror dispatch configuration + id: config + shell: bash + env: + MIRROR_DISPATCH_TOKEN: ${{ secrets.CLI_MIRROR_DISPATCH_TOKEN }} + run: | + set -euo pipefail + if [[ -z "$MIRROR_DISPATCH_TOKEN" ]]; then + echo "::notice::CLI_MIRROR_DISPATCH_TOKEN is not configured; skipping the ECR mirror check" + printf 'configured=false\n' >> "$GITHUB_OUTPUT" + else + printf 'configured=true\n' >> "$GITHUB_OUTPUT" + fi + + - name: Install regctl + if: steps.config.outputs.configured == 'true' + shell: bash + run: | + set -euo pipefail + regctl_version="v0.11.5" + expected_sha256="c93aa7638749f5aaac1a8e01787321889c78f0101809bb2880343478d0ba0467" + curl -fsSL \ + "https://github.com/regclient/regclient/releases/download/${regctl_version}/regctl-linux-amd64" \ + -o "$RUNNER_TEMP/regctl" + actual_sha256="$(sha256sum "$RUNNER_TEMP/regctl" | cut -d' ' -f1)" + [[ "$actual_sha256" == "$expected_sha256" ]] || { + printf 'regctl checksum mismatch: expected %s, got %s\n' \ + "$expected_sha256" "$actual_sha256" >&2 + exit 1 + } + chmod +x "$RUNNER_TEMP/regctl" + echo "$RUNNER_TEMP" >> "$GITHUB_PATH" + + - name: Compare published releases against ECR Public + if: steps.config.outputs.configured == 'true' + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + MIRROR_DISPATCH_TOKEN: ${{ secrets.CLI_MIRROR_DISPATCH_TOKEN }} + REQUEST: ${{ inputs.request }} + run: | + set -euo pipefail + args=() + if [[ "$REQUEST" == "true" ]]; then + args+=(--request) + fi + scripts/ecr-mirror.sh sync "${args[@]}" diff --git a/.github/workflows/repository-checks.yml b/.github/workflows/repository-checks.yml index 3f33898..9245752 100644 --- a/.github/workflows/repository-checks.yml +++ b/.github/workflows/repository-checks.yml @@ -57,5 +57,6 @@ jobs: scripts/test-extract-upstream-archive.sh scripts/test-upstream-artifact.sh scripts/test-oci-mirror.sh + scripts/test-ecr-mirror.sh scripts/test-upstream-runtime.sh services/vector/test-smoke.sh diff --git a/.github/workflows/service-release.yml b/.github/workflows/service-release.yml index 0f9225e..d4141e7 100644 --- a/.github/workflows/service-release.yml +++ b/.github/workflows/service-release.yml @@ -738,12 +738,83 @@ jobs: retention-days: 7 path: mirror-provenance.json + mirror-ecr: + name: mirror ${{ inputs.service }} image to ECR Public + needs: + - plan + - publish-image + if: needs.plan.outputs.publish == 'true' + runs-on: ubuntu-24.04 + timeout-minutes: 45 + outputs: + mirrored: ${{ steps.mirror.outputs.mirrored || 'false' }} + steps: + - name: Checkout mirror scripts + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 1 + persist-credentials: false + + - name: Check mirror dispatch configuration + id: config + shell: bash + env: + MIRROR_DISPATCH_TOKEN: ${{ secrets.CLI_MIRROR_DISPATCH_TOKEN }} + run: | + set -euo pipefail + if [[ -z "$MIRROR_DISPATCH_TOKEN" ]]; then + echo "::notice::CLI_MIRROR_DISPATCH_TOKEN is not configured; skipping the ECR Public mirror" + printf 'configured=false\n' >> "$GITHUB_OUTPUT" + else + printf 'configured=true\n' >> "$GITHUB_OUTPUT" + fi + + - name: Download published image metadata + if: steps.config.outputs.configured == 'true' + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: published-image-${{ inputs.service }}-${{ inputs.version }} + + - name: Install regctl + if: steps.config.outputs.configured == 'true' + shell: bash + run: | + set -euo pipefail + regctl_version="v0.11.5" + expected_sha256="c93aa7638749f5aaac1a8e01787321889c78f0101809bb2880343478d0ba0467" + curl -fsSL \ + "https://github.com/regclient/regclient/releases/download/${regctl_version}/regctl-linux-amd64" \ + -o "$RUNNER_TEMP/regctl" + actual_sha256="$(sha256sum "$RUNNER_TEMP/regctl" | cut -d' ' -f1)" + [[ "$actual_sha256" == "$expected_sha256" ]] || { + printf 'regctl checksum mismatch: expected %s, got %s\n' \ + "$expected_sha256" "$actual_sha256" >&2 + exit 1 + } + chmod +x "$RUNNER_TEMP/regctl" + echo "$RUNNER_TEMP" >> "$GITHUB_PATH" + + - name: Request and verify ECR Public mirror + id: mirror + if: steps.config.outputs.configured == 'true' + shell: bash + env: + MIRROR_DISPATCH_TOKEN: ${{ secrets.CLI_MIRROR_DISPATCH_TOKEN }} + SERVICE: ${{ inputs.service }} + VERSION: ${{ inputs.version }} + run: | + set -euo pipefail + digest="$(python3 -c 'import json; print(json.load(open("published-image.json"))["digest"])')" + scripts/ecr-mirror.sh request "$SERVICE" "$VERSION" "$digest" + printf 'mirrored=true\n' >> "$GITHUB_OUTPUT" + publish-release: name: publish ${{ inputs.service }} release needs: - plan - build - publish-image + - mirror-ecr if: needs.plan.outputs.publish == 'true' runs-on: ubuntu-24.04 timeout-minutes: 20 @@ -799,6 +870,7 @@ jobs: shell: bash env: IMAGE_RELEASE: ${{ needs.plan.outputs.image_release }} + MIRRORED: ${{ needs.mirror-ecr.outputs.mirrored }} SERVICE: ${{ inputs.service }} VERSION: ${{ inputs.version }} run: | @@ -856,6 +928,22 @@ jobs: Verify a downloaded archive with the attached \`SHA256SUMS\` file. EOF + if [[ "${MIRRORED:-}" == "true" ]]; then + ecr_image="$( + scripts/ecr-mirror.sh payload "$SERVICE" "$VERSION" "$digest" \ + | python3 -c 'import json,sys; print(json.load(sys.stdin)["client_payload"]["destination"])' + )" + ecr_image="${ecr_image%%:*}" + cat >> release-notes.md <> release-notes.md <<'PY' import json diff --git a/docs/design/ecr-mirror-dispatch.md b/docs/design/ecr-mirror-dispatch.md new file mode 100644 index 0000000..ffca459 --- /dev/null +++ b/docs/design/ecr-mirror-dispatch.md @@ -0,0 +1,86 @@ +# ECR Public mirroring via supabase/cli dispatch + +Slim images are published to `ghcr.io/supabase/cli/:` by +`.github/workflows/service-release.yml`. This document describes how those +images are also mirrored to AWS ECR Public, reusing the mirror machinery and +AWS credentials that already live in `supabase/cli`, so this repository needs +no AWS access of its own. + +## Flow + +1. The `publish-image` job publishes the multi-platform image to GHCR and + records its index digest in `published-image.json`. +2. The `mirror-ecr` job sends a `repository_dispatch` event to `supabase/cli` + (`scripts/ecr-mirror.sh request`), then polls the destination anonymously + until its index digest equals the GHCR index digest, or fails after a + timeout (15 minutes by default). +3. The `publish-release` job appends the ECR references to the release notes + when the mirror was verified. +4. `.github/workflows/ecr-mirror-check.yml` runs daily and fails when any + published release is missing from ECR Public or resolves to a different + digest. Run it manually with `request: true` to re-request out-of-sync + tags (this is also the backfill path for releases that predate mirroring). + +The mirror is skipped, with a workflow notice, until the +`CLI_MIRROR_DISPATCH_TOKEN` secret exists. Once the secret is set, a failed +or unverified mirror fails the release: a release is only done when both +registries serve the same digest. + +## Dispatch contract + +The event sent to `POST /repos/supabase/cli/dispatches`: + +```json +{ + "event_type": "mirror-slim-image", + "client_payload": { + "service": "postgrest", + "version": "v16.2", + "source": "ghcr.io/supabase/cli/postgrest:v16.2", + "digest": "sha256:…", + "destination": "public.ecr.aws/supabase/cli/postgrest:v16.2" + } +} +``` + +The handling workflow in `supabase/cli` must: + +- Trigger on `repository_dispatch` with `types: [mirror-slim-image]`. +- Copy `source` to `destination` with a digest-preserving tool + (`regctl image copy`, `crane cp`, or `akhilerm/tag-push-action`). + `docker buildx imagetools create` may rewrite the index and change its + digest; verification here would then fail the release. +- Reject a `source` outside `ghcr.io/supabase/cli/` and a `destination` + outside `public.ecr.aws/supabase/cli/`, and verify that `source` resolves + to `digest` before copying. The payload arrives with whatever authority + holds the dispatch token, so the handler validates it independently. +- Create the ECR Public repository when it does not exist, or the + `cli/` repositories must be created up front. ECR does not create + repositories on push. + +This repository treats the dispatch as fire-and-forget: success is defined +purely by the destination digest matching, which `scripts/ecr-mirror.sh` +verifies with anonymous pulls. + +## Setup checklist + +1. Land the `mirror-slim-image` handler in `supabase/cli` (see contract + above). +2. Create the `cli/` ECR Public repositories for the services in + `.github/service-release-sources.json`, or grant the handler's role + `ecr-public:CreateRepository`. +3. Create a token that can send `repository_dispatch` to `supabase/cli` + (fine-grained PAT with contents read/write on `supabase/cli`, or a GitHub + App installation token) and store it in this repository as the + `CLI_MIRROR_DISPATCH_TOKEN` actions secret. +4. Backfill existing releases: run the `ECR mirror check` workflow with + `request: true`. + +## Naming + +The destination keeps the `cli/` namespace (`public.ecr.aws/supabase/cli/…`) +instead of joining the existing upstream mirrors at +`public.ecr.aws/supabase/` because slim tags reuse upstream version +strings; `supabase/postgrest:v16.2` is already the upstream image. Keeping +the path identical to GHCR also lets consumers switch registries by swapping +only the registry host prefix. diff --git a/scripts/ecr-mirror.sh b/scripts/ecr-mirror.sh new file mode 100755 index 0000000..ae108e4 --- /dev/null +++ b/scripts/ecr-mirror.sh @@ -0,0 +1,244 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +# shellcheck source=scripts/lib.sh +source "$ROOT_DIR/scripts/lib.sh" + +usage() { + cat <<'EOF' +Usage: + scripts/ecr-mirror.sh payload SERVICE VERSION DIGEST + scripts/ecr-mirror.sh request SERVICE VERSION DIGEST + scripts/ecr-mirror.sh verify SERVICE VERSION DIGEST + scripts/ecr-mirror.sh sync [--request] + +Mirror published slim images to AWS ECR Public through the mirror workflow +hosted in the dispatch repository (supabase/cli by default). + +Subcommands: + payload Print the repository_dispatch request body for one release. + request Send the repository_dispatch event, then poll the destination + until its index digest matches DIGEST. No-op when the + destination already matches. + verify Poll the destination until its index digest matches DIGEST. + sync Compare every published release against the destination + registry and report drift. With --request, also dispatch a + mirror request for each missing or mismatched tag and verify + the result. Exits non-zero while any tag is out of sync. + +Environment: + MIRROR_DISPATCH_TOKEN Token used to send repository_dispatch + (required by request, and by sync --request). + MIRROR_DISPATCH_REPO Dispatch repository (default: supabase/cli). + MIRROR_EVENT_TYPE Dispatch event type (default: mirror-slim-image). + SOURCE_IMAGE_PREFIX Source repository prefix + (default: ghcr.io/supabase/cli). + ECR_MIRROR_PREFIX Destination repository prefix + (default: public.ecr.aws/supabase/cli). + ECR_MIRROR_TIMEOUT Verify timeout in seconds (default: 900). + ECR_MIRROR_POLL_INTERVAL Verify poll interval in seconds (default: 30). +EOF +} + +[[ "${1:-}" == "-h" || "${1:-}" == "--help" ]] && { usage; exit 0; } +[[ $# -ge 1 ]] || { usage >&2; exit 2; } + +require_cmd python3 + +CONFIG_FILE="${SERVICE_RELEASE_CONFIG:-$ROOT_DIR/.github/service-release-sources.json}" +MIRROR_DISPATCH_REPO="${MIRROR_DISPATCH_REPO:-supabase/cli}" +MIRROR_EVENT_TYPE="${MIRROR_EVENT_TYPE:-mirror-slim-image}" +SOURCE_IMAGE_PREFIX="${SOURCE_IMAGE_PREFIX:-ghcr.io/supabase/cli}" +ECR_MIRROR_PREFIX="${ECR_MIRROR_PREFIX:-public.ecr.aws/supabase/cli}" +ECR_MIRROR_TIMEOUT="${ECR_MIRROR_TIMEOUT:-900}" +ECR_MIRROR_POLL_INTERVAL="${ECR_MIRROR_POLL_INTERVAL:-30}" + +[[ -f "$CONFIG_FILE" ]] || fail "service release config not found: $CONFIG_FILE" + +validate_release() { + local service="$1" version="$2" + python3 - "$CONFIG_FILE" "$service" "$version" <<'PY' || exit 1 +import json +import re +import sys + +config_path, service, version = sys.argv[1:] +with open(config_path, encoding="utf-8") as fh: + services = json.load(fh)["services"] +config = services.get(service) +if config is None: + raise SystemExit(f"unknown release service: {service}") +if not re.fullmatch(config["tag_pattern"], version): + raise SystemExit(f"version is not an allowed release tag for {service}: {version}") +PY +} + +validate_digest() { + [[ "$1" =~ ^sha256:[0-9a-f]{64}$ ]] || fail "not a sha256 image digest: $1" +} + +render_payload() { + local service="$1" version="$2" digest="$3" + python3 - "$MIRROR_EVENT_TYPE" "$service" "$version" \ + "$SOURCE_IMAGE_PREFIX/$service:$version" "$digest" \ + "$ECR_MIRROR_PREFIX/$service:$version" <<'PY' +import json +import sys + +event_type, service, version, source, digest, destination = sys.argv[1:] +print(json.dumps({ + "event_type": event_type, + "client_payload": { + "service": service, + "version": version, + "source": source, + "digest": digest, + "destination": destination, + }, +}, indent=2, sort_keys=True)) +PY +} + +destination_digest() { + local reference="$1" + regctl manifest head "$reference" 2>/dev/null | tr -d '[:space:]' || true +} + +verify_release() { + local service="$1" version="$2" digest="$3" + local destination_ref="$ECR_MIRROR_PREFIX/$service:$version" + local deadline=$((SECONDS + ECR_MIRROR_TIMEOUT)) + local live="" + while true; do + live="$(destination_digest "$destination_ref")" + if [[ "$live" == "$digest" ]]; then + log "verified $destination_ref@$digest" + return 0 + fi + if ((SECONDS >= deadline)); then + fail "destination did not match within ${ECR_MIRROR_TIMEOUT}s: $destination_ref (expected $digest, got ${live:-none})" + fi + log "waiting for $destination_ref (expected $digest, got ${live:-none})" + sleep "$ECR_MIRROR_POLL_INTERVAL" + done +} + +request_release() { + local service="$1" version="$2" digest="$3" + local destination_ref="$ECR_MIRROR_PREFIX/$service:$version" + local live + live="$(destination_digest "$destination_ref")" + if [[ "$live" == "$digest" ]]; then + log "destination already matches: $destination_ref@$digest" + return 0 + fi + [[ -n "${MIRROR_DISPATCH_TOKEN:-}" ]] || fail "MIRROR_DISPATCH_TOKEN is required to send repository_dispatch" + log "requesting mirror of $SOURCE_IMAGE_PREFIX/$service:$version@$digest via $MIRROR_DISPATCH_REPO" + render_payload "$service" "$version" "$digest" \ + | GH_TOKEN="$MIRROR_DISPATCH_TOKEN" gh api "repos/$MIRROR_DISPATCH_REPO/dispatches" --input - \ + || fail "repository_dispatch to $MIRROR_DISPATCH_REPO failed" + verify_release "$service" "$version" "$digest" +} + +list_releases() { + local releases_json="$1" + gh api --paginate --slurp \ + "repos/${GITHUB_REPOSITORY:-supabase/slim-services}/releases?per_page=100" \ + > "$releases_json" + python3 - "$CONFIG_FILE" "$releases_json" <<'PY' +import json +import re +import sys + +config_path, releases_path = sys.argv[1:] +with open(config_path, encoding="utf-8") as fh: + services = json.load(fh)["services"] +with open(releases_path, encoding="utf-8") as fh: + release_pages = json.load(fh) + +for page in release_pages: + for release in page: + tag = release.get("tag_name", "") + if release.get("draft") or release.get("prerelease"): + continue + for service, config in services.items(): + prefix = f"{service}-" + if not tag.startswith(prefix): + continue + version = tag[len(prefix):] + if re.fullmatch(config["tag_pattern"], version): + print(f"{service}\t{version}") + break +PY +} + +sync_releases() { + local request="$1" + require_cmd gh + require_cmd regctl + local temp_dir + temp_dir="$(mktemp -d "${TMPDIR:-/tmp}/slim-ecr-sync.XXXXXX")" + trap 'rm -rf "$temp_dir"' EXIT + list_releases "$temp_dir/releases.json" > "$temp_dir/releases.tsv" + [[ -s "$temp_dir/releases.tsv" ]] || fail "no published releases found" + + local drift=0 service version source_digest live + while IFS=$'\t' read -r service version; do + source_digest="$(regctl manifest head "$SOURCE_IMAGE_PREFIX/$service:$version" | tr -d '[:space:]')" \ + || fail "could not resolve source digest for $service $version" + live="$(destination_digest "$ECR_MIRROR_PREFIX/$service:$version")" + if [[ "$live" == "$source_digest" ]]; then + log "in sync: $service $version ($source_digest)" + continue + fi + log "out of sync: $service $version (expected $source_digest, got ${live:-none})" + if [[ "$request" == "true" ]]; then + request_release "$service" "$version" "$source_digest" + else + drift=1 + fi + done < "$temp_dir/releases.tsv" + + ((drift == 0)) || fail "one or more releases are missing from $ECR_MIRROR_PREFIX" + log "all published releases are mirrored to $ECR_MIRROR_PREFIX" +} + +command="$1" +shift +case "$command" in + payload) + [[ $# -eq 3 ]] || { usage >&2; exit 2; } + validate_release "$1" "$2" + validate_digest "$3" + render_payload "$1" "$2" "$3" + ;; + request) + [[ $# -eq 3 ]] || { usage >&2; exit 2; } + require_cmd gh + require_cmd regctl + validate_release "$1" "$2" + validate_digest "$3" + request_release "$1" "$2" "$3" + ;; + verify) + [[ $# -eq 3 ]] || { usage >&2; exit 2; } + require_cmd regctl + validate_release "$1" "$2" + validate_digest "$3" + verify_release "$1" "$2" "$3" + ;; + sync) + request=false + if [[ "${1:-}" == "--request" ]]; then + request=true + shift + fi + [[ $# -eq 0 ]] || { usage >&2; exit 2; } + sync_releases "$request" + ;; + *) + usage >&2 + exit 2 + ;; +esac diff --git a/scripts/test-ecr-mirror.sh b/scripts/test-ecr-mirror.sh new file mode 100755 index 0000000..63ebfcc --- /dev/null +++ b/scripts/test-ecr-mirror.sh @@ -0,0 +1,109 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +python3 - "$ROOT_DIR" <<'PY' +import json +import pathlib +import subprocess +import sys +import tempfile +import unittest + + +ROOT_DIR = pathlib.Path(sys.argv[1]) +sys.argv[1:] = [] +SCRIPT = ROOT_DIR / "scripts" / "ecr-mirror.sh" +DIGEST = "sha256:" + "a" * 64 + + +def run(*args, env=None): + return subprocess.run( + [str(SCRIPT), *args], + capture_output=True, + text=True, + cwd=ROOT_DIR, + env={"PATH": "/usr/bin:/bin", **(env or {})}, + ) + + +class EcrMirrorContract(unittest.TestCase): + def test_payload_renders_dispatch_request(self): + result = run("payload", "postgrest", "v16.2", DIGEST) + self.assertEqual(result.returncode, 0, result.stderr) + payload = json.loads(result.stdout) + self.assertEqual(payload["event_type"], "mirror-slim-image") + self.assertEqual( + payload["client_payload"], + { + "service": "postgrest", + "version": "v16.2", + "source": "ghcr.io/supabase/cli/postgrest:v16.2", + "digest": DIGEST, + "destination": "public.ecr.aws/supabase/cli/postgrest:v16.2", + }, + ) + + def test_payload_honors_prefix_overrides(self): + result = run( + "payload", + "auth", + "v2.196.0", + DIGEST, + env={ + "MIRROR_EVENT_TYPE": "mirror-test", + "SOURCE_IMAGE_PREFIX": "ghcr.io/example/src", + "ECR_MIRROR_PREFIX": "public.ecr.aws/example/dst", + }, + ) + self.assertEqual(result.returncode, 0, result.stderr) + payload = json.loads(result.stdout) + self.assertEqual(payload["event_type"], "mirror-test") + self.assertEqual( + payload["client_payload"]["source"], "ghcr.io/example/src/auth:v2.196.0" + ) + self.assertEqual( + payload["client_payload"]["destination"], + "public.ecr.aws/example/dst/auth:v2.196.0", + ) + + def test_payload_rejects_unknown_service(self): + result = run("payload", "kong", "v1.0.0", DIGEST) + self.assertNotEqual(result.returncode, 0) + self.assertIn("unknown release service", result.stderr) + + def test_payload_rejects_disallowed_version(self): + result = run("payload", "postgrest", "latest", DIGEST) + self.assertNotEqual(result.returncode, 0) + self.assertIn("not an allowed release tag", result.stderr) + + def test_payload_rejects_malformed_digest(self): + result = run("payload", "postgrest", "v16.2", "sha256:nope") + self.assertNotEqual(result.returncode, 0) + self.assertIn("not a sha256 image digest", result.stderr) + + def test_request_requires_token_when_destination_is_stale(self): + with tempfile.TemporaryDirectory() as stub_dir: + for stub in ("gh", "regctl"): + stub_path = pathlib.Path(stub_dir) / stub + stub_path.write_text("#!/usr/bin/env bash\nexit 1\n", encoding="utf-8") + stub_path.chmod(0o755) + result = run( + "request", + "postgrest", + "v16.2", + DIGEST, + env={"PATH": f"{stub_dir}:/usr/bin:/bin"}, + ) + self.assertNotEqual(result.returncode, 0) + self.assertIn("MIRROR_DISPATCH_TOKEN is required", result.stderr) + + def test_unknown_subcommand_prints_usage(self): + result = run("mirror-all") + self.assertEqual(result.returncode, 2) + self.assertIn("Usage:", result.stderr) + + +unittest.main(verbosity=2) +PY diff --git a/scripts/test-external-workflows.sh b/scripts/test-external-workflows.sh index c2a5a76..b05699f 100755 --- a/scripts/test-external-workflows.sh +++ b/scripts/test-external-workflows.sh @@ -352,6 +352,7 @@ def test_repository_checks_runs_dynamic_and_external_contracts(): "scripts/test-extract-upstream-archive.sh", "scripts/test-upstream-artifact.sh", "scripts/test-oci-mirror.sh", + "scripts/test-ecr-mirror.sh", "scripts/test-upstream-runtime.sh", "scripts/test-external-source-build.sh", "scripts/test-dockerhub-release.sh", From 4985705f48cf59ca0e0cca3eb892181a3f76119f Mon Sep 17 00:00:00 2001 From: avallete Date: Tue, 1 Sep 2026 12:05:16 +0200 Subject: [PATCH 2/2] Fix ECR mirror audit scope, anonymous dest proof, and check gating list_releases dropped tags that fail today's tag_pattern (live postgres-15 releases), dest digest inherited ambient registry creds, and the daily check skipped entirely without the write token. Audit by service prefix, isolate dest lookups, and always run compare-only sync. Co-authored-by: Cursor --- .github/workflows/ecr-mirror-check.yml | 18 +---- docs/design/ecr-mirror-dispatch.md | 19 +++-- scripts/ecr-mirror.sh | 36 ++++++--- scripts/test-ecr-mirror.sh | 108 ++++++++++++++++++++++++- scripts/test-external-workflows.sh | 19 +++++ 5 files changed, 164 insertions(+), 36 deletions(-) diff --git a/.github/workflows/ecr-mirror-check.yml b/.github/workflows/ecr-mirror-check.yml index fca3510..b061dcb 100644 --- a/.github/workflows/ecr-mirror-check.yml +++ b/.github/workflows/ecr-mirror-check.yml @@ -30,22 +30,7 @@ jobs: fetch-depth: 1 persist-credentials: false - - name: Check mirror dispatch configuration - id: config - shell: bash - env: - MIRROR_DISPATCH_TOKEN: ${{ secrets.CLI_MIRROR_DISPATCH_TOKEN }} - run: | - set -euo pipefail - if [[ -z "$MIRROR_DISPATCH_TOKEN" ]]; then - echo "::notice::CLI_MIRROR_DISPATCH_TOKEN is not configured; skipping the ECR mirror check" - printf 'configured=false\n' >> "$GITHUB_OUTPUT" - else - printf 'configured=true\n' >> "$GITHUB_OUTPUT" - fi - - name: Install regctl - if: steps.config.outputs.configured == 'true' shell: bash run: | set -euo pipefail @@ -64,7 +49,6 @@ jobs: echo "$RUNNER_TEMP" >> "$GITHUB_PATH" - name: Compare published releases against ECR Public - if: steps.config.outputs.configured == 'true' shell: bash env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -75,5 +59,7 @@ jobs: args=() if [[ "$REQUEST" == "true" ]]; then args+=(--request) + else + unset MIRROR_DISPATCH_TOKEN fi scripts/ecr-mirror.sh sync "${args[@]}" diff --git a/docs/design/ecr-mirror-dispatch.md b/docs/design/ecr-mirror-dispatch.md index ffca459..157fa30 100644 --- a/docs/design/ecr-mirror-dispatch.md +++ b/docs/design/ecr-mirror-dispatch.md @@ -17,14 +17,19 @@ no AWS access of its own. 3. The `publish-release` job appends the ECR references to the release notes when the mirror was verified. 4. `.github/workflows/ecr-mirror-check.yml` runs daily and fails when any - published release is missing from ECR Public or resolves to a different - digest. Run it manually with `request: true` to re-request out-of-sync - tags (this is also the backfill path for releases that predate mirroring). + published non-draft/non-prerelease tag that maps to a configured service + (by `-` prefix) is missing from ECR Public or resolves to a + different digest. The daily audit does not apply `tag_pattern`, so older + tags that no longer match the current pattern stay in the compare set. + Run it manually with `request: true` to re-request out-of-sync tags + (this is also the backfill path for releases that predate mirroring). -The mirror is skipped, with a workflow notice, until the -`CLI_MIRROR_DISPATCH_TOKEN` secret exists. Once the secret is set, a failed -or unverified mirror fails the release: a release is only done when both -registries serve the same digest. +Release-time mirroring (`service-release.yml` `mirror-ecr`) is skipped, +with a workflow notice, until the `CLI_MIRROR_DISPATCH_TOKEN` secret +exists. Once the secret is set, a failed or unverified mirror fails the +release: a release is only done when both registries serve the same +digest. The daily audit always runs; it needs only `gh` and `regctl`. +Dispatch (`request: true`) still requires the token. ## Dispatch contract diff --git a/scripts/ecr-mirror.sh b/scripts/ecr-mirror.sh index ae108e4..1848e55 100755 --- a/scripts/ecr-mirror.sh +++ b/scripts/ecr-mirror.sh @@ -53,6 +53,9 @@ SOURCE_IMAGE_PREFIX="${SOURCE_IMAGE_PREFIX:-ghcr.io/supabase/cli}" ECR_MIRROR_PREFIX="${ECR_MIRROR_PREFIX:-public.ecr.aws/supabase/cli}" ECR_MIRROR_TIMEOUT="${ECR_MIRROR_TIMEOUT:-900}" ECR_MIRROR_POLL_INTERVAL="${ECR_MIRROR_POLL_INTERVAL:-30}" +# Dest lookups must succeed without the caller's registry credentials. +anonymous_regctl_config="" +anonymous_docker_config="" [[ -f "$CONFIG_FILE" ]] || fail "service release config not found: $CONFIG_FILE" @@ -100,13 +103,23 @@ print(json.dumps({ PY } +init_anonymous_configs() { + if [[ -z "$anonymous_regctl_config" ]]; then + anonymous_regctl_config="$(mktemp -d "${TMPDIR:-/tmp}/slim-ecr-anon-regctl.XXXXXX")" + anonymous_docker_config="$(mktemp -d "${TMPDIR:-/tmp}/slim-ecr-anon-docker.XXXXXX")" + fi +} + destination_digest() { local reference="$1" - regctl manifest head "$reference" 2>/dev/null | tr -d '[:space:]' || true + REGCTL_CONFIG="$anonymous_regctl_config" \ + DOCKER_CONFIG="$anonymous_docker_config" \ + regctl image digest "$reference" 2>/dev/null | tr -d '[:space:]' || true } verify_release() { local service="$1" version="$2" digest="$3" + init_anonymous_configs local destination_ref="$ECR_MIRROR_PREFIX/$service:$version" local deadline=$((SECONDS + ECR_MIRROR_TIMEOUT)) local live="" @@ -126,6 +139,7 @@ verify_release() { request_release() { local service="$1" version="$2" digest="$3" + init_anonymous_configs local destination_ref="$ECR_MIRROR_PREFIX/$service:$version" local live live="$(destination_digest "$destination_ref")" @@ -148,7 +162,6 @@ list_releases() { > "$releases_json" python3 - "$CONFIG_FILE" "$releases_json" <<'PY' import json -import re import sys config_path, releases_path = sys.argv[1:] @@ -157,18 +170,22 @@ with open(config_path, encoding="utf-8") as fh: with open(releases_path, encoding="utf-8") as fh: release_pages = json.load(fh) +# Prefix only: tag_pattern is for payload/request/verify, not the audit set. +# Longest prefix wins so a shorter name cannot steal another service's tags. +prefixes = sorted( + ((f"{name}-", name) for name in services), + key=lambda item: len(item[0]), + reverse=True, +) + for page in release_pages: for release in page: tag = release.get("tag_name", "") if release.get("draft") or release.get("prerelease"): continue - for service, config in services.items(): - prefix = f"{service}-" - if not tag.startswith(prefix): - continue - version = tag[len(prefix):] - if re.fullmatch(config["tag_pattern"], version): - print(f"{service}\t{version}") + for prefix, service in prefixes: + if tag.startswith(prefix): + print(f"{service}\t{tag[len(prefix):]}") break PY } @@ -177,6 +194,7 @@ sync_releases() { local request="$1" require_cmd gh require_cmd regctl + init_anonymous_configs local temp_dir temp_dir="$(mktemp -d "${TMPDIR:-/tmp}/slim-ecr-sync.XXXXXX")" trap 'rm -rf "$temp_dir"' EXIT diff --git a/scripts/test-ecr-mirror.sh b/scripts/test-ecr-mirror.sh index 63ebfcc..3f2cbc6 100755 --- a/scripts/test-ecr-mirror.sh +++ b/scripts/test-ecr-mirror.sh @@ -99,10 +99,110 @@ class EcrMirrorContract(unittest.TestCase): self.assertNotEqual(result.returncode, 0) self.assertIn("MIRROR_DISPATCH_TOKEN is required", result.stderr) - def test_unknown_subcommand_prints_usage(self): - result = run("mirror-all") - self.assertEqual(result.returncode, 2) - self.assertIn("Usage:", result.stderr) + def test_request_is_noop_when_destination_matches(self): + with tempfile.TemporaryDirectory() as stub_dir: + stub_dir = pathlib.Path(stub_dir) + fake_regctl = stub_dir / "regctl" + fake_regctl.write_text( + "#!/bin/sh\n" + f'if [ "$1" = image ] && [ "$2" = digest ]; then printf "%s\\n" "{DIGEST}"; exit 0; fi\n' + "exit 1\n", + encoding="utf-8", + ) + fake_regctl.chmod(0o755) + fake_gh = stub_dir / "gh" + fake_gh.write_text("#!/usr/bin/env bash\nexit 1\n", encoding="utf-8") + fake_gh.chmod(0o755) + result = run( + "request", + "postgrest", + "v16.2", + DIGEST, + env={"PATH": f"{stub_dir}:/usr/bin:/bin"}, + ) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("destination already matches", result.stdout) + + def test_destination_digest_uses_empty_task_local_configs(self): + with tempfile.TemporaryDirectory() as stub_dir: + stub_dir = pathlib.Path(stub_dir) + trace = stub_dir / "regctl-trace" + fake_regctl = stub_dir / "regctl" + fake_regctl.write_text( + "#!/bin/sh\n" + "regctl_empty=no; docker_empty=no\n" + '[ -d "${REGCTL_CONFIG-}" ] && [ -z "$(ls -A "$REGCTL_CONFIG")" ] && regctl_empty=yes\n' + '[ -d "${DOCKER_CONFIG-}" ] && [ -z "$(ls -A "$DOCKER_CONFIG")" ] && docker_empty=yes\n' + 'printf "%s\\t%s\\t%s\\t%s\\t%s\\n" "$*" "${REGCTL_CONFIG-}" "${DOCKER_CONFIG-}" "$regctl_empty" "$docker_empty" >> "$FAKE_TRACE"\n' + 'n=0; [ -f "$FAKE_TRACE.count" ] && n=$(cat "$FAKE_TRACE.count")\n' + 'n=$((n + 1)); printf "%s\\n" "$n" > "$FAKE_TRACE.count"\n' + f'if [ "$1" = image ] && [ "$2" = digest ] && [ "$n" -ge 2 ]; then printf "%s\\n" "{DIGEST}"; exit 0; fi\n' + f'if [ "$1" = image ] && [ "$2" = digest ]; then printf "%s\\n" "sha256:{"b" * 64}"; exit 0; fi\n' + "exit 1\n", + encoding="utf-8", + ) + fake_regctl.chmod(0o755) + caller_regctl = stub_dir / "caller-regctl" + caller_docker = stub_dir / "caller-docker" + caller_regctl.mkdir() + caller_docker.mkdir() + result = run( + "verify", + "postgrest", + "v16.2", + DIGEST, + env={ + "PATH": f"{stub_dir}:/usr/bin:/bin", + "FAKE_TRACE": str(trace), + "REGCTL_CONFIG": str(caller_regctl), + "DOCKER_CONFIG": str(caller_docker), + "ECR_MIRROR_POLL_INTERVAL": "0", + "ECR_MIRROR_TIMEOUT": "30", + }, + ) + self.assertEqual(result.returncode, 0, result.stderr) + dest_lines = [ + line.split("\t") + for line in trace.read_text(encoding="utf-8").splitlines() + if "image digest public.ecr.aws/supabase/cli/postgrest:v16.2" in line + ] + self.assertGreaterEqual(len(dest_lines), 2) + _, regctl_config, docker_config, regctl_empty, docker_empty = dest_lines[-1] + self.assertEqual(dest_lines[0][1], dest_lines[1][1]) + self.assertNotEqual(regctl_config, str(caller_regctl)) + self.assertNotEqual(docker_config, str(caller_docker)) + self.assertEqual(regctl_empty, "yes") + self.assertEqual(docker_empty, "yes") + + def test_sync_lists_published_tag_outside_current_pattern(self): + stale = "sha256:" + "b" * 64 + with tempfile.TemporaryDirectory() as stub_dir: + stub_dir = pathlib.Path(stub_dir) + fake_gh = stub_dir / "gh" + fake_gh.write_text( + "#!/usr/bin/env bash\n" + 'cat <<\'EOF\'\n' + '[[{"tag_name":"postgres-15.14.1.159","draft":false,"prerelease":false}]]\n' + "EOF\n", + encoding="utf-8", + ) + fake_gh.chmod(0o755) + fake_regctl = stub_dir / "regctl" + fake_regctl.write_text( + "#!/bin/sh\n" + f'if [ "$1" = manifest ] && [ "$2" = head ]; then printf "%s\\n" "{DIGEST}"; exit 0; fi\n' + f'if [ "$1" = image ] && [ "$2" = digest ]; then printf "%s\\n" "{stale}"; exit 0; fi\n' + "exit 1\n", + encoding="utf-8", + ) + fake_regctl.chmod(0o755) + result = run( + "sync", + env={"PATH": f"{stub_dir}:/usr/bin:/bin"}, + ) + self.assertNotEqual(result.returncode, 0) + self.assertIn("out of sync: postgres 15.14.1.159", result.stdout) + self.assertNotIn("no published releases found", result.stderr) unittest.main(verbosity=2) diff --git a/scripts/test-external-workflows.sh b/scripts/test-external-workflows.sh index b05699f..d93329f 100755 --- a/scripts/test-external-workflows.sh +++ b/scripts/test-external-workflows.sh @@ -335,6 +335,25 @@ def test_workflow_downloads_and_verifies_snapshot_before_recipe_build_consumers( assert_true("matrix.external != true" not in artifact_nix_cache.get("if", ""), "external artifact source incorrectly skips Nix cache") +def test_service_release_mirror_ecr_gates_publish_release(): + ruby = ( + "require 'yaml'; require 'json'; " + "data=YAML.safe_load(File.read(ARGV[0]), aliases: true); " + "jobs=data.fetch('jobs'); " + "notes=jobs.fetch('publish-release').fetch('steps').find { |s| s['name'] == 'Prepare checksums and release notes' }; " + "puts JSON.generate({mirror: jobs.key?('mirror-ecr'), needs: jobs.fetch('publish-release').fetch('needs'), notes_env: notes.fetch('env')})" + ) + result = run(["ruby", "-e", ruby, str(ROOT / ".github" / "workflows" / "service-release.yml")]) + assert_true(result.returncode == 0, result.stderr) + parsed = json.loads(result.stdout) + assert_true(parsed["mirror"] is True, "jobs.mirror-ecr is missing") + assert_true("mirror-ecr" in parsed["needs"], "publish-release.needs omits mirror-ecr") + assert_true( + "needs.mirror-ecr.outputs.mirrored" in str(parsed["notes_env"].get("MIRRORED", "")), + "notes step env omits needs.mirror-ecr.outputs.mirrored", + ) + + def test_repository_checks_runs_dynamic_and_external_contracts(): ruby = ( "require 'yaml'; require 'json'; "