From d959fab9bf31f25403c9eeff246d34ee651ba22b Mon Sep 17 00:00:00 2001 From: Doug Lance <4741454+douglance@users.noreply.github.com> Date: Fri, 21 Aug 2026 10:34:59 -0400 Subject: [PATCH] fix(mirror): read the tag list from the source registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mirror derived its tags from resolvePublishMatrix, which describes what a release publishes *now*. #38 narrows that function to the default contracts version, so `resolvePublishMatrix('all', 'all')` drops from 11 rows to 7 and `(…, 'v2.1')` returns none at all. Mirroring an older release would then copy 7 of its 11 tags and report success, and asking for v2.1 would fail as an empty matrix. `crane ls` on the source repository answers what actually exists, so the mirror copies what a release really published rather than what current rules would publish. Verified against the live package: v0.2.10 resolves all 11 tags including the four nc2.1 ones, a single variant+contracts filter resolves exactly one, and an unknown version fails. Both steps move into scripts/ci, matching the other CI helpers, so the loop is readable and testable outside a workflow run. Behavior otherwise unchanged: skip-if-current, refuse-on-divergence unless overwrite, digest asserted after every copy. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/mirror-to-dockerhub.yml | 133 ++++------------------ scripts/ci/mirror-tags.mjs | 116 +++++++++++++++++++ scripts/ci/resolve-mirror-tags.mjs | 64 +++++++++++ 3 files changed, 204 insertions(+), 109 deletions(-) create mode 100644 scripts/ci/mirror-tags.mjs create mode 100644 scripts/ci/resolve-mirror-tags.mjs diff --git a/.github/workflows/mirror-to-dockerhub.yml b/.github/workflows/mirror-to-dockerhub.yml index 3e5c2a0..ea5c6ad 100644 --- a/.github/workflows/mirror-to-dockerhub.yml +++ b/.github/workflows/mirror-to-dockerhub.yml @@ -4,13 +4,10 @@ name: Mirror Images to Docker Hub # equivalent: the Dockerfile fetches token-bridge-contracts and apt packages at # build time, so it lands a different digest than the tag it replaces. # -# The copy uses crane rather than `docker buildx imagetools create`, which wraps a -# single-arch source in a new manifest index and so gives the destination a -# different digest than the source. crane copies the manifest byte-for-byte, which -# keeps `repo@sha256:...` valid against either registry and is asserted below. -# -# The tag set comes from resolvePublishMatrix, the same source of truth the publish -# workflow uses, so a mirror cannot drift from what a release produces. +# The tag list is read from the source registry, not derived from +# resolvePublishMatrix: that function describes what a release publishes now, so +# using it would silently omit tags an older release published under rules since +# changed. on: workflow_dispatch: inputs: @@ -73,24 +70,6 @@ jobs: - name: Checkout uses: actions/checkout@v4 - # Emits one tag suffix per supported (variant x contracts version) combo, - # matching buildTestnodeImageRef in packages/testnode/src/runtime.mjs. - - name: Resolve tags - id: tags - env: - VARIANT_FILTER: ${{ inputs.variant }} - VERSION: ${{ inputs.version }} - VERSION_FILTER: ${{ inputs.nitro-contracts-version }} - run: >- - node --input-type=module -e - "import { resolvePublishMatrix, NITRO_CONTRACTS_VERSIONS } from './packages/testnode/src/runtime.mjs'; - const rows = resolvePublishMatrix(process.env.VARIANT_FILTER, process.env.VERSION_FILTER); - if (!rows.length) throw new Error('empty mirror matrix'); - const tags = rows.map((r) => process.env.VERSION + '-' + NITRO_CONTRACTS_VERSIONS[r.contractsVersion].tagComponent + '-' + r.variant); - console.error('mirroring ' + tags.length + ' tags:', tags.join(' ')); - const { appendFileSync } = await import('node:fs'); - appendFileSync(process.env.GITHUB_OUTPUT, 'list<- + node scripts/ci/resolve-mirror-tags.mjs + --repository "$SRC_REPOSITORY" + --version "$VERSION" + --variant "$VARIANT" + --contracts-version "$CONTRACTS_VERSION" + - name: Mirror tags env: DST_REPOSITORY: ${{ inputs.dockerhub-repository }} - OVERWRITE: ${{ inputs.overwrite }} SRC_REPOSITORY: ${{ inputs.source-repository }} TAGS: ${{ steps.tags.outputs.list }} - run: | - set -uo pipefail - src_repo="$SRC_REPOSITORY" - failed=0 - copied=0 - skipped=0 - - digest_of() { - crane digest "$1" 2>/dev/null - } - - while IFS= read -r tag; do - [ -n "$tag" ] || continue - echo "::group::$tag" - - if ! src_digest="$(digest_of "$src_repo:$tag")" || [ -z "$src_digest" ]; then - echo "source missing: $src_repo:$tag" >&2 - failed=1 - echo "::endgroup::" - continue - fi - - # Destination existence comes from the Hub API, not the registry: - # Docker Hub answers 401 for an unknown repository, which is - # indistinguishable from bad credentials. - status="$(curl -sS -o /dev/null -w '%{http_code}' \ - "https://hub.docker.com/v2/repositories/${DST_REPOSITORY}/tags/${tag}")" - - case "$status" in - 404) ;; - 200) - dst_digest="$(digest_of "index.docker.io/$DST_REPOSITORY:$tag" || true)" - if [ "$dst_digest" = "$src_digest" ]; then - echo "already mirrored at $src_digest; skipping" - skipped=$((skipped + 1)) - echo "::endgroup::" - continue - fi - if [ "$OVERWRITE" != "true" ]; then - echo "exists with a different digest (dst=$dst_digest src=$src_digest); re-run with overwrite=true" >&2 - failed=1 - echo "::endgroup::" - continue - fi - echo "replacing $dst_digest with $src_digest" - ;; - *) - echo "could not determine whether $DST_REPOSITORY:$tag exists (HTTP $status)" >&2 - failed=1 - echo "::endgroup::" - continue - ;; - esac - - if ! crane copy "$src_repo:$tag" "index.docker.io/$DST_REPOSITORY:$tag"; then - echo "copy failed" >&2 - failed=1 - echo "::endgroup::" - continue - fi - - # crane preserves the manifest, so the digests must match. If they do - # not, the destination is not what was released. - dst_digest="$(digest_of "index.docker.io/$DST_REPOSITORY:$tag" || true)" - if [ "$dst_digest" != "$src_digest" ]; then - echo "digest mismatch after copy (src=$src_digest dst=$dst_digest)" >&2 - failed=1 - echo "::endgroup::" - continue - fi - - echo "copied $src_digest" - copied=$((copied + 1)) - { - echo "- \`$DST_REPOSITORY:$tag\` <- \`$src_digest\`" - } >> "$GITHUB_STEP_SUMMARY" - echo "::endgroup::" - done <<< "$TAGS" - - echo "copied=$copied skipped=$skipped" - echo "copied $copied, skipped $skipped (already current)" >> "$GITHUB_STEP_SUMMARY" - exit "$failed" + run: >- + node scripts/ci/mirror-tags.mjs + --source "$SRC_REPOSITORY" + --destination "$DST_REPOSITORY" + ${{ inputs.overwrite && '--overwrite' || '' }} diff --git a/scripts/ci/mirror-tags.mjs b/scripts/ci/mirror-tags.mjs new file mode 100644 index 0000000..ca492e7 --- /dev/null +++ b/scripts/ci/mirror-tags.mjs @@ -0,0 +1,116 @@ +import { execFileSync } from "node:child_process"; +import { appendFileSync } from "node:fs"; + +/** + * Copies each tag from the source repository to the destination, preserving the + * digest, and refuses to change a destination tag that already differs. + * + * crane rather than `docker buildx imagetools create`, which wraps a single-arch + * source in a new index and so gives the destination a different digest than the + * source. Matching digests keep `repo@sha256:...` valid against either registry. + */ + +function readArg(name) { + const index = process.argv.indexOf(name); + if (index === -1) { + return ""; + } + return process.argv[index + 1] || ""; +} + +const source = readArg("--source"); +const destination = readArg("--destination"); +if (!source || !destination) { + throw new Error("--source and --destination are required"); +} +const overwrite = process.argv.includes("--overwrite"); + +const tags = (process.env.TAGS ?? "") + .split("\n") + .map((line) => line.trim()) + .filter(Boolean); +if (tags.length === 0) { + throw new Error("TAGS is empty"); +} + +const digestOf = (ref) => { + try { + return execFileSync("crane", ["digest", ref], { encoding: "utf-8" }).trim(); + } catch { + return ""; + } +}; + +/** + * Whether the destination tag exists, via the Hub API rather than the registry: + * Docker Hub answers 401 for an unknown repository, which is indistinguishable + * from bad credentials. Assumes a public destination. + */ +async function destinationExists(repository, tag) { + const response = await fetch(`https://hub.docker.com/v2/repositories/${repository}/tags/${tag}`); + if (response.status === 200) { + return true; + } + if (response.status === 404) { + return false; + } + throw new Error(`cannot tell whether ${repository}:${tag} exists (HTTP ${response.status})`); +} + +const summary = []; +const failures = []; +let copied = 0; +let skipped = 0; + +for (const tag of tags) { + const from = `${source}:${tag}`; + const to = `${destination}:${tag}`; + console.log(`::group::${tag}`); + try { + const sourceDigest = digestOf(from); + if (!sourceDigest) { + throw new Error(`source missing: ${from}`); + } + + if (await destinationExists(destination, tag)) { + const current = digestOf(`index.docker.io/${to}`); + if (current === sourceDigest) { + console.log(`already mirrored at ${sourceDigest}`); + skipped += 1; + continue; + } + if (!overwrite) { + throw new Error( + `${to} exists at ${current}, source is ${sourceDigest}; re-run with overwrite to replace it`, + ); + } + console.log(`replacing ${current} with ${sourceDigest}`); + } + + execFileSync("crane", ["copy", from, `index.docker.io/${to}`], { stdio: "inherit" }); + + const copiedDigest = digestOf(`index.docker.io/${to}`); + if (copiedDigest !== sourceDigest) { + throw new Error(`digest mismatch after copy: source ${sourceDigest}, got ${copiedDigest}`); + } + + console.log(`copied ${sourceDigest}`); + summary.push(`- \`${to}\` <- \`${sourceDigest}\``); + copied += 1; + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + failures.push(tag); + } finally { + console.log("::endgroup::"); + } +} + +const outcome = `copied ${copied}, skipped ${skipped} (already current), failed ${failures.length}`; +console.log(outcome); +if (process.env.GITHUB_STEP_SUMMARY) { + appendFileSync(process.env.GITHUB_STEP_SUMMARY, `${[...summary, outcome].join("\n")}\n`); +} + +if (failures.length > 0) { + throw new Error(`failed tags: ${failures.join(" ")}`); +} diff --git a/scripts/ci/resolve-mirror-tags.mjs b/scripts/ci/resolve-mirror-tags.mjs new file mode 100644 index 0000000..fbe652d --- /dev/null +++ b/scripts/ci/resolve-mirror-tags.mjs @@ -0,0 +1,64 @@ +import { execFileSync } from "node:child_process"; +import { appendFileSync } from "node:fs"; + +/** + * Lists the tags a version actually has in the source repository. + * + * Enumerating the registry rather than re-deriving from resolvePublishMatrix: + * that function describes what a release publishes *now*, so it silently omits + * tags an older release published under rules since changed. A mirror that + * copies fewer tags than exist looks like a success. + */ + +function readArg(name) { + const index = process.argv.indexOf(name); + if (index === -1) { + return ""; + } + return process.argv[index + 1] || ""; +} + +const repository = readArg("--repository"); +if (!repository) { + throw new Error("--repository is required"); +} + +const version = readArg("--version"); +if (!version) { + throw new Error("--version is required"); +} + +const variant = readArg("--variant") || "all"; +const contractsVersion = readArg("--contracts-version") || "all"; + +const listed = execFileSync("crane", ["ls", repository], { encoding: "utf-8" }) + .split("\n") + .map((line) => line.trim()) + .filter(Boolean); + +// `-nc-`, matching buildTestnodeImageRef. +const pattern = /^(?.+)-nc(?[^-]+)-(?.+)$/; + +const tags = listed.filter((tag) => { + const parts = pattern.exec(tag)?.groups; + if (!parts || parts.version !== version) { + return false; + } + if (variant !== "all" && parts.variant !== variant) { + return false; + } + return contractsVersion === "all" || `v${parts.contracts}` === contractsVersion; +}); + +if (tags.length === 0) { + throw new Error( + `no tags in ${repository} match version ${version} (variant ${variant}, contracts ${contractsVersion})`, + ); +} + +tags.sort(); +console.error(`mirroring ${tags.length} tags: ${tags.join(" ")}`); + +if (process.env.GITHUB_OUTPUT) { + appendFileSync(process.env.GITHUB_OUTPUT, `list<