From acd3469b2d0eb17adfacb3acc669d0ffb5a5b0f4 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 12:44:06 +0000 Subject: [PATCH 1/3] ci: add mirror-slim-image dispatch handler Handle the mirror-slim-image repository_dispatch sent by the supabase/slim-services release pipeline: validate the untrusted payload (pattern-check service/version/digest, derive source and destination instead of trusting the payload strings), then mirror ghcr.io/supabase/cli/: to public.ecr.aws/supabase/cli/: with a digest-preserving regctl image copy that moves the whole OCI index. The sender polls the ECR destination anonymously with a 15-minute timeout and fails its release unless the index digest matches, so the copy must not rewrite the index (no docker buildx imagetools create). The workflow is idempotent (exits early when the destination already resolves to the expected digest), ensures the ECR Public repository exists with a clear error naming the one-time manual creation step when the role cannot create it, and reuses the existing mirror conventions: PROD_AWS_ROLE via configure-aws-credentials, us-east-1, docker login-action for ECR Public and ghcr.io, SHA-pinned actions. A workflow_dispatch trigger with equivalent inputs supports manual runs and post-merge testing, since repository_dispatch only runs the version on the default branch. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_019vxDP5STtHeohcy727UrrR --- .github/workflows/mirror-slim-image.yml | 201 ++++++++++++++++++++++++ 1 file changed, 201 insertions(+) create mode 100644 .github/workflows/mirror-slim-image.yml diff --git a/.github/workflows/mirror-slim-image.yml b/.github/workflows/mirror-slim-image.yml new file mode 100644 index 0000000000..b1e00cee03 --- /dev/null +++ b/.github/workflows/mirror-slim-image.yml @@ -0,0 +1,201 @@ +name: Mirror Slim Image + +# Mirrors slim service images published by supabase/slim-services from +# ghcr.io/supabase/cli/: to +# public.ecr.aws/supabase/cli/:. +# +# The slim-services release pipeline sends a `mirror-slim-image` +# repository_dispatch to this repo, then anonymously polls the ECR Public +# destination (15-minute timeout) and fails its release unless the destination +# resolves to the exact index digest it published. The copy must therefore be +# digest-preserving: we use `regctl image copy`, which moves the whole OCI +# index (all platform manifests and referrers) byte-for-byte. Do NOT switch +# this to `docker buildx imagetools create` — it can rewrite the index and +# change its digest, breaking the sender's verification. +# +# The payload arrives with whatever authority holds the dispatch token, so it +# is validated as untrusted input: names are pattern-checked, source and +# destination are derived here rather than trusted from the payload, and the +# source must resolve to the digest claimed by the sender before anything is +# copied. +# +# Full contract: docs/design/ecr-mirror-dispatch.md in supabase/slim-services. + +on: + repository_dispatch: + types: + - mirror-slim-image + workflow_dispatch: + inputs: + service: + description: "Service name (e.g. postgrest)" + required: true + type: string + version: + description: "Image tag (e.g. v16.2)" + required: true + type: string + digest: + description: "Expected index digest (sha256:<64 hex chars>)" + required: true + type: string + +permissions: + contents: read + +concurrency: + group: mirror-slim-image-${{ github.event.client_payload.service || inputs.service }}-${{ github.event.client_payload.version || inputs.version }} + cancel-in-progress: false + +jobs: + mirror: + runs-on: ubuntu-latest + # The sender's poll times out after 15 minutes; fail fast instead of + # hanging past that window. + timeout-minutes: 10 + permissions: + contents: read + packages: read + id-token: write + steps: + - name: Validate payload + id: validate + env: + EVENT_NAME: ${{ github.event_name }} + SERVICE: ${{ github.event.client_payload.service || inputs.service }} + VERSION: ${{ github.event.client_payload.version || inputs.version }} + DIGEST: ${{ github.event.client_payload.digest || inputs.digest }} + PAYLOAD_SOURCE: ${{ github.event.client_payload.source }} + PAYLOAD_DESTINATION: ${{ github.event.client_payload.destination }} + run: | + set -euo pipefail + if [[ ! "$SERVICE" =~ ^[a-z][a-z0-9-]*$ ]]; then + echo "::error::invalid service name: '$SERVICE'" + exit 1 + fi + if [[ ! "$VERSION" =~ ^[A-Za-z0-9._-]+$ ]]; then + echo "::error::invalid version: '$VERSION'" + exit 1 + fi + if [[ ! "$DIGEST" =~ ^sha256:[0-9a-f]{64}$ ]]; then + echo "::error::invalid digest: '$DIGEST'" + exit 1 + fi + SOURCE="ghcr.io/supabase/cli/${SERVICE}:${VERSION}" + DESTINATION="public.ecr.aws/supabase/cli/${SERVICE}:${VERSION}" + # Never trust the payload's source/destination strings; require them + # to match the values derived from service + version. + if [ "$EVENT_NAME" = "repository_dispatch" ]; then + if [ "$PAYLOAD_SOURCE" != "$SOURCE" ]; then + echo "::error::payload source '$PAYLOAD_SOURCE' does not match derived '$SOURCE'" + exit 1 + fi + if [ "$PAYLOAD_DESTINATION" != "$DESTINATION" ]; then + echo "::error::payload destination '$PAYLOAD_DESTINATION' does not match derived '$DESTINATION'" + exit 1 + fi + fi + { + echo "service=$SERVICE" + echo "source=$SOURCE" + echo "destination=$DESTINATION" + echo "digest=$DIGEST" + } >> "$GITHUB_OUTPUT" + + - name: Install regctl + run: | + set -euo pipefail + curl -fsSLo /usr/local/bin/regctl \ + https://github.com/regclient/regclient/releases/download/v0.11.5/regctl-linux-amd64 + echo "c93aa7638749f5aaac1a8e01787321889c78f0101809bb2880343478d0ba0467 /usr/local/bin/regctl" | sha256sum -c - + chmod +x /usr/local/bin/regctl + regctl version + + - name: Log in to ghcr.io + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Verify source digest + env: + SOURCE: ${{ steps.validate.outputs.source }} + DIGEST: ${{ steps.validate.outputs.digest }} + run: | + set -euo pipefail + SOURCE_DIGEST="$(regctl manifest head "$SOURCE")" + if [ "$SOURCE_DIGEST" != "$DIGEST" ]; then + echo "::error::source $SOURCE resolves to $SOURCE_DIGEST, expected $DIGEST" + exit 1 + fi + + - name: Configure aws credentials + uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c # v6.2.3 + with: + role-to-assume: ${{ secrets.PROD_AWS_ROLE }} + aws-region: us-east-1 + + - name: Log in to ECR Public + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + registry: public.ecr.aws + + - name: Check if destination is already mirrored + id: check + env: + DESTINATION: ${{ steps.validate.outputs.destination }} + DIGEST: ${{ steps.validate.outputs.digest }} + run: | + set -euo pipefail + if DEST_DIGEST="$(regctl manifest head "$DESTINATION" 2>/dev/null)" \ + && [ "$DEST_DIGEST" = "$DIGEST" ]; then + echo "$DESTINATION already resolves to $DIGEST; nothing to do" + echo "skip=true" >> "$GITHUB_OUTPUT" + else + echo "skip=false" >> "$GITHUB_OUTPUT" + fi + + - name: Ensure ECR Public repository exists + if: steps.check.outputs.skip != 'true' + env: + SERVICE: ${{ steps.validate.outputs.service }} + run: | + set -euo pipefail + REPO_NAME="cli/${SERVICE}" + if aws ecr-public describe-repositories \ + --repository-names "$REPO_NAME" --region us-east-1 >/dev/null 2>&1; then + echo "ECR Public repository $REPO_NAME exists" + elif aws ecr-public create-repository \ + --repository-name "$REPO_NAME" --region us-east-1 >/dev/null; then + echo "created ECR Public repository $REPO_NAME" + else + echo "::error::ECR Public repository '$REPO_NAME' does not exist and this role cannot create it (missing ecr-public:CreateRepository). Create it once manually — aws ecr-public create-repository --repository-name '$REPO_NAME' --region us-east-1 — then re-run this workflow." + exit 1 + fi + + - name: Mirror image + if: steps.check.outputs.skip != 'true' + env: + SOURCE: ${{ steps.validate.outputs.source }} + DESTINATION: ${{ steps.validate.outputs.destination }} + DIGEST: ${{ steps.validate.outputs.digest }} + # Copy by digest so the copy cannot race a tag move on the source; the + # whole index, all child manifests, and any referrers move as-is. + run: | + set -euo pipefail + regctl image copy --referrers --digest-tags \ + "${SOURCE%:*}@${DIGEST}" "$DESTINATION" + + - name: Verify destination digest + env: + DESTINATION: ${{ steps.validate.outputs.destination }} + DIGEST: ${{ steps.validate.outputs.digest }} + run: | + set -euo pipefail + DEST_DIGEST="$(regctl manifest head "$DESTINATION")" + if [ "$DEST_DIGEST" != "$DIGEST" ]; then + echo "::error::destination $DESTINATION resolves to $DEST_DIGEST, expected $DIGEST" + exit 1 + fi + echo "$DESTINATION resolves to $DIGEST" From f710f580007c9894acde8400be2036a3db9db326 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 12:54:39 +0000 Subject: [PATCH 2/3] ci: harden regctl install and ECR repo creation race Install regctl into $RUNNER_TEMP and expose it via $GITHUB_PATH instead of writing to /usr/local/bin, which may not be writable by the job user. Treat RepositoryAlreadyExistsException from create-repository as success: the concurrency group is keyed on service+version, so two backfill runs for different versions of a brand-new service can race repository creation, and the loser must not misreport the race as a missing ecr-public:CreateRepository permission. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_019vxDP5STtHeohcy727UrrR --- .github/workflows/mirror-slim-image.yml | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/.github/workflows/mirror-slim-image.yml b/.github/workflows/mirror-slim-image.yml index b1e00cee03..0c7e4a3c6d 100644 --- a/.github/workflows/mirror-slim-image.yml +++ b/.github/workflows/mirror-slim-image.yml @@ -103,13 +103,17 @@ jobs: } >> "$GITHUB_OUTPUT" - name: Install regctl + # Installed under $RUNNER_TEMP (always writable by the job user) and + # exposed to later steps via $GITHUB_PATH. run: | set -euo pipefail - curl -fsSLo /usr/local/bin/regctl \ + install -d "${RUNNER_TEMP}/regctl-bin" + curl -fsSLo "${RUNNER_TEMP}/regctl-bin/regctl" \ https://github.com/regclient/regclient/releases/download/v0.11.5/regctl-linux-amd64 - echo "c93aa7638749f5aaac1a8e01787321889c78f0101809bb2880343478d0ba0467 /usr/local/bin/regctl" | sha256sum -c - - chmod +x /usr/local/bin/regctl - regctl version + echo "c93aa7638749f5aaac1a8e01787321889c78f0101809bb2880343478d0ba0467 ${RUNNER_TEMP}/regctl-bin/regctl" | sha256sum -c - + chmod +x "${RUNNER_TEMP}/regctl-bin/regctl" + echo "${RUNNER_TEMP}/regctl-bin" >> "$GITHUB_PATH" + "${RUNNER_TEMP}/regctl-bin/regctl" version - name: Log in to ghcr.io uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 @@ -166,10 +170,17 @@ jobs: if aws ecr-public describe-repositories \ --repository-names "$REPO_NAME" --region us-east-1 >/dev/null 2>&1; then echo "ECR Public repository $REPO_NAME exists" - elif aws ecr-public create-repository \ - --repository-name "$REPO_NAME" --region us-east-1 >/dev/null; then + exit 0 + fi + if CREATE_ERR="$(aws ecr-public create-repository \ + --repository-name "$REPO_NAME" --region us-east-1 2>&1 >/dev/null)"; then echo "created ECR Public repository $REPO_NAME" + elif grep -q RepositoryAlreadyExistsException <<< "$CREATE_ERR"; then + # Concurrent run for another version of the same new service won + # the creation race; the repository exists, which is all we need. + echo "ECR Public repository $REPO_NAME was created concurrently" else + echo "$CREATE_ERR" echo "::error::ECR Public repository '$REPO_NAME' does not exist and this role cannot create it (missing ecr-public:CreateRepository). Create it once manually — aws ecr-public create-repository --repository-name '$REPO_NAME' --region us-east-1 — then re-run this workflow." exit 1 fi From a444c83fc08505533b481a0b30881294119d4e7a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 16:17:03 +0000 Subject: [PATCH 3/3] ci: always run the mirror copy to heal partial copies A copy that pushed the root index but died before finishing referrers or digest-tags would previously be skipped forever by the idempotency early-exit, because the destination tag already resolves to the expected digest. Run regctl image copy unconditionally instead: it is incremental, so a re-dispatch after a complete copy stays a cheap verification while a re-run after a partial failure completes the missing ancillary artifacts. Re-dispatches still exit successfully with the destination digest unchanged. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_019vxDP5STtHeohcy727UrrR --- .github/workflows/mirror-slim-image.yml | 25 ++++++++----------------- 1 file changed, 8 insertions(+), 17 deletions(-) diff --git a/.github/workflows/mirror-slim-image.yml b/.github/workflows/mirror-slim-image.yml index 0c7e4a3c6d..eeb3d3f6fe 100644 --- a/.github/workflows/mirror-slim-image.yml +++ b/.github/workflows/mirror-slim-image.yml @@ -145,23 +145,7 @@ jobs: with: registry: public.ecr.aws - - name: Check if destination is already mirrored - id: check - env: - DESTINATION: ${{ steps.validate.outputs.destination }} - DIGEST: ${{ steps.validate.outputs.digest }} - run: | - set -euo pipefail - if DEST_DIGEST="$(regctl manifest head "$DESTINATION" 2>/dev/null)" \ - && [ "$DEST_DIGEST" = "$DIGEST" ]; then - echo "$DESTINATION already resolves to $DIGEST; nothing to do" - echo "skip=true" >> "$GITHUB_OUTPUT" - else - echo "skip=false" >> "$GITHUB_OUTPUT" - fi - - name: Ensure ECR Public repository exists - if: steps.check.outputs.skip != 'true' env: SERVICE: ${{ steps.validate.outputs.service }} run: | @@ -186,13 +170,20 @@ jobs: fi - name: Mirror image - if: steps.check.outputs.skip != 'true' env: SOURCE: ${{ steps.validate.outputs.source }} DESTINATION: ${{ steps.validate.outputs.destination }} DIGEST: ${{ steps.validate.outputs.digest }} # Copy by digest so the copy cannot race a tag move on the source; the # whole index, all child manifests, and any referrers move as-is. + # + # The copy runs unconditionally, with no early exit when the + # destination tag already resolves to the digest: regctl's copy is + # incremental, so a re-dispatch after a complete copy is a cheap + # verification pass, while re-running after a partial failure (root + # index pushed but referrers or digest-tags missing) completes the + # copy instead of skipping it. Re-dispatches therefore still exit + # successfully with the destination digest unchanged. run: | set -euo pipefail regctl image copy --referrers --digest-tags \