diff --git a/.github/workflows/mirror-to-dockerhub.yml b/.github/workflows/mirror-to-dockerhub.yml index ea5c6ad..87f2487 100644 --- a/.github/workflows/mirror-to-dockerhub.yml +++ b/.github/workflows/mirror-to-dockerhub.yml @@ -1,8 +1,10 @@ name: Mirror Images to Docker Hub -# Copies published GHCR tags to Docker Hub without rebuilding. A rebuild is not -# equivalent: the Dockerfile fetches token-bridge-contracts and apt packages at -# build time, so it lands a different digest than the tag it replaces. +# Promotes published GHCR tags to public Docker Hub without rebuilding. A rebuild +# is not equivalent: the Dockerfile fetches token-bridge-contracts and apt +# packages at build time, so it lands a different digest than the tag it +# replaces. Releases publish privately to GHCR; this is the deliberate step that +# makes a verified version public. # # The tag list is read from the source registry, not derived from # resolvePublishMatrix: that function describes what a release publishes now, so @@ -41,7 +43,7 @@ on: source-repository: description: "GHCR repository to copy from" required: false - default: "ghcr.io/offchainlabs/arbitrum-testnode-ci" + default: "ghcr.io/offchainlabs/arbitrum-litro" type: string dockerhub-repository: description: "Docker Hub repository to mirror into" @@ -125,6 +127,7 @@ jobs: - name: Mirror tags env: + ALIASES: ${{ steps.tags.outputs.aliases }} DST_REPOSITORY: ${{ inputs.dockerhub-repository }} SRC_REPOSITORY: ${{ inputs.source-repository }} TAGS: ${{ steps.tags.outputs.list }} diff --git a/.github/workflows/release-testnode-image.yml b/.github/workflows/release-testnode-image.yml index 4966a6a..08ec716 100644 --- a/.github/workflows/release-testnode-image.yml +++ b/.github/workflows/release-testnode-image.yml @@ -5,6 +5,11 @@ name: Publish Testnode # GENERATED inline in CI by running `init` (no pre-built snapshot release # bundles, nothing to publish out of band). The intended (variant x version) # matrix and the init flags per combo come from runtime.mjs resolvePublishMatrix. +# +# Publishing lands in GHCR only by default, where the package is private. Docker +# Hub is public and its tags are permanent in practice, so getting there is a +# separate promotion: verify the GHCR images, then dispatch Mirror Images to +# Docker Hub, which copies the digests rather than rebuilding. on: push: tags: @@ -30,6 +35,14 @@ on: - l3-custom-16 - l3-custom-18 - l3-custom-20 + registries: + description: "Where to publish. Docker Hub is public and permanent; promote to it with Mirror Images to Docker Hub instead unless a direct publish is intended." + required: true + default: "ghcr" + type: choice + options: + - ghcr + - ghcr,dockerhub dockerhub-repository: description: "Docker Hub repository to publish to (retarget for a dry run)" required: false @@ -46,11 +59,24 @@ jobs: runs-on: ubuntu-latest outputs: matrix: ${{ steps.matrix.outputs.matrix }} + registries: ${{ steps.registries.outputs.selected }} version: ${{ steps.ver.outputs.version }} steps: - name: Checkout uses: actions/checkout@v4 + # Resolved once so the build and alias jobs cannot disagree about where a + # release went. Dispatch inputs do not exist on a tag push, so a tag falls + # through to the private registry. + - name: Resolve target registries + id: registries + env: + REGISTRIES: ${{ inputs.registries }} + run: | + set -euo pipefail + echo "selected=${REGISTRIES:-ghcr}" >> "$GITHUB_OUTPUT" + echo "publishing to ${REGISTRIES:-ghcr}" + - name: Resolve image version id: ver run: | @@ -129,6 +155,23 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile + # Before the snapshot below, which takes tens of minutes: a taken tag or a + # missing credential should fail in a minute, not after the build. + - name: Resolve image refs + id: refs + env: + DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} + DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + run: >- + node scripts/ci/resolve-publish-refs.mjs + --registries "${{ needs.resolve-publish-matrix.outputs.registries }}" + --owner "${{ github.repository_owner }}" + --dockerhub-repository "${{ inputs.dockerhub-repository || 'offchainlabs/arbitrum-litro' }}" + --version "${{ needs.resolve-publish-matrix.outputs.version }}" + --variant "${{ matrix.variant }}" + --contracts-version "${{ matrix.contractsVersion }}" + ${{ inputs.overwrite && '--overwrite' || '' }} + - name: Install Foundry (forge for token-bridge build) uses: foundry-rs/foundry-toolchain@v1 @@ -268,85 +311,21 @@ jobs: --variant "${{ matrix.variant }}" --snapshot-id "${{ matrix.snapshotId }}" - # Both refs are derived from one suffix so the registries cannot disagree - # about what a version means. The suffix shape must match - # buildTestnodeImageRef in packages/testnode/src/runtime.mjs -- that is - # what consumers resolve, so a drift here publishes tags nothing pulls. - - name: Resolve image refs - id: refs - env: - CONTRACTS_VERSION: ${{ matrix.contractsVersion }} - # Empty on tag pushes (dispatch inputs do not exist there), so a - # release always lands in the real repository. - DOCKERHUB_REPOSITORY: ${{ inputs.dockerhub-repository || 'offchainlabs/arbitrum-litro' }} - OWNER: ${{ github.repository_owner }} - VARIANT: ${{ matrix.variant }} - VERSION: ${{ needs.resolve-publish-matrix.outputs.version }} - run: | - set -euo pipefail - suffix="${VERSION}-nc${CONTRACTS_VERSION#v}-${VARIANT}" - owner="$(echo "$OWNER" | tr '[:upper:]' '[:lower:]')" - { - echo "ghcr=ghcr.io/${owner}/arbitrum-litro:${suffix}" - echo "dockerhub=${DOCKERHUB_REPOSITORY}:${suffix}" - } >> "$GITHUB_OUTPUT" - echo "publishing ${suffix} to ghcr.io/${owner}/arbitrum-litro and ${DOCKERHUB_REPOSITORY}" - - # Docker Hub tags are mutable and public: without this, a re-run silently - # replaces a shipped image. Queried through the Hub API rather than the - # registry because Docker Hub answers 401 for an unknown repository, which - # is indistinguishable from bad credentials. Assumes a public repository - # (a private one 404s whether or not the tag exists). - - name: Refuse to overwrite a published Docker Hub tag - if: ${{ !inputs.overwrite }} - env: - REF: ${{ steps.refs.outputs.dockerhub }} - run: | - set -euo pipefail - repository="${REF%:*}" - tag="${REF##*:}" - status="$(curl -sS -o /dev/null -w '%{http_code}' \ - "https://hub.docker.com/v2/repositories/${repository}/tags/${tag}")" - case "$status" in - 404) - echo "$REF is free" - ;; - 200) - echo "$REF already exists; re-run with overwrite=true to replace it" >&2 - exit 1 - ;; - *) - echo "could not determine whether $REF exists (HTTP $status)" >&2 - exit 1 - ;; - esac - # Supplies the container driver the gha cache below needs; the default # docker driver cannot export one. - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Log in to GHCR + if: ${{ contains(needs.resolve-publish-matrix.outputs.registries, 'ghcr') }} uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - # A release must not degrade to GHCR-only when the credential is missing: - # half-published versions are worse than a failed run. - - name: Check Docker Hub credentials - env: - DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} - DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} - run: | - set -euo pipefail - if [ -z "${DOCKERHUB_USERNAME}" ] || [ -z "${DOCKERHUB_TOKEN}" ]; then - echo "DOCKERHUB_USERNAME and DOCKERHUB_TOKEN are required to publish" >&2 - exit 1 - fi - - name: Log in to Docker Hub + if: ${{ contains(needs.resolve-publish-matrix.outputs.registries, 'dockerhub') }} uses: docker/login-action@v3 with: username: ${{ secrets.DOCKERHUB_USERNAME }} @@ -362,23 +341,24 @@ jobs: build-args: | BUNDLE_VERSION=${{ needs.resolve-publish-matrix.outputs.version }} BUNDLE_VARIANT=${{ matrix.variant }} + IMAGE_SOURCE=${{ github.server_url }}/${{ github.repository }} NITRO_CONTRACTS_REF=${{ env.NITRO_CONTRACTS_REF }} NITRO_CONTRACTS_COMMIT=${{ env.NITRO_CONTRACTS_COMMIT }} TOKENBRIDGE_REF=${{ env.TOKEN_BRIDGE_REF }} TOKENBRIDGE_COMMIT=${{ env.TOKEN_BRIDGE_COMMIT }} push: true - # One build, two pushes -- a second build could diverge from the first. - tags: | - ${{ steps.refs.outputs.ghcr }} - ${{ steps.refs.outputs.dockerhub }} + # One build, one push per registry -- a second build could diverge from + # the first, giving the same tag a different digest per registry. + tags: ${{ steps.refs.outputs.tags }} # Unscoped on purpose: the token-bridge-contracts stage is identical # across matrix rows (same pinned ref), so sharing one scope is where # the saving is. Rows overwrite each other's entry; reads still hit. cache-from: type=gha cache-to: type=gha,mode=max - # Moves `latest-` onto this release, in both registries so the alias - # means the same thing wherever it is pulled from. + # Moves `latest-` onto this release, in every registry the release was + # pushed to. Registries this run skipped pick the alias up when the version is + # mirrored to them, so the alias never names a version that registry lacks. publish-latest-bundle: if: ${{ github.ref_type == 'tag' }} needs: [resolve-publish-matrix, publish-testnode-image] @@ -400,6 +380,7 @@ jobs: tar -xzf /tmp/crane.tar.gz -C /usr/local/bin crane - name: Log in to GHCR + if: ${{ contains(needs.resolve-publish-matrix.outputs.registries, 'ghcr') }} uses: docker/login-action@v3 with: registry: ghcr.io @@ -407,20 +388,18 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Log in to Docker Hub + if: ${{ contains(needs.resolve-publish-matrix.outputs.registries, 'dockerhub') }} uses: docker/login-action@v3 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - - name: Lowercase owner - id: owner - run: echo "name=$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_OUTPUT" - - name: Publish latest bundle aliases env: MATRIX: ${{ needs.resolve-publish-matrix.outputs.matrix }} VERSION: ${{ needs.resolve-publish-matrix.outputs.version }} run: >- node scripts/ci/publish-latest-aliases.mjs - --repository "ghcr.io/${{ steps.owner.outputs.name }}/arbitrum-litro" - --repository "offchainlabs/arbitrum-litro" + --registries "${{ needs.resolve-publish-matrix.outputs.registries }}" + --owner "${{ github.repository_owner }}" + --dockerhub-repository "${{ inputs.dockerhub-repository || 'offchainlabs/arbitrum-litro' }}" diff --git a/.github/workflows/verify-published-image.yml b/.github/workflows/verify-published-image.yml new file mode 100644 index 0000000..a917d2d --- /dev/null +++ b/.github/workflows/verify-published-image.yml @@ -0,0 +1,101 @@ +name: Verify Published Image + +# Boots a published image through the action, so a version can be checked in the +# private registry before it is promoted to public Docker Hub. GHCR needs a +# credential, which is what makes this distinct from test-action.yml's +# credential-free check of the public default. +on: + workflow_dispatch: + inputs: + version: + description: "Published version to verify (e.g. v0.3.0)" + required: true + type: string + image-repository: + description: "Repository holding the published image" + required: false + default: "ghcr.io/offchainlabs/arbitrum-litro" + type: string + nitro-contracts-version: + description: "Nitro contracts version the tag was published for" + required: true + default: "v3.2" + type: choice + options: + - v3.2 + - v2.1 + +jobs: + # One row per shape a consumer boots: an L2-only chain and a full L1/L2/L3 + # stack. Both resolve their tag through the action, so a tag the action cannot + # name fails here rather than in a consumer's repository. + verify: + strategy: + fail-fast: false + matrix: + include: + - variant: l2 + l3-enabled: "false" + - variant: l3-eth + l3-enabled: "true" + name: verify (${{ matrix.variant }}) + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + packages: read + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Run action against the published image + id: action + uses: ./ + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + image-repository: ${{ inputs.image-repository }} + l3-enabled: ${{ matrix.l3-enabled }} + nitro-contracts-version: ${{ inputs.nitro-contracts-version }} + version: ${{ inputs.version }} + + - name: Validate chains respond + env: + L1_RPC_URL: ${{ steps.action.outputs.l1-rpc-url }} + L2_RPC_URL: ${{ steps.action.outputs.l2-rpc-url }} + L3_RPC_URL: ${{ steps.action.outputs.l3-rpc-url }} + run: | + set -euo pipefail + for url in "$L1_RPC_URL" "$L2_RPC_URL" "${L3_RPC_URL:-}"; do + if [ -z "$url" ]; then + continue + fi + echo "checking $url" + curl -sfS -X POST -H 'Content-Type: application/json' \ + -d '{"jsonrpc":"2.0","id":1,"method":"eth_chainId","params":[]}' "$url" + done + + - name: Validate network config is served + env: + LOCAL_NETWORK_PATH: ${{ steps.action.outputs.local-network-path }} + run: | + set -euo pipefail + test -f "$LOCAL_NETWORK_PATH" + curl -sfS http://127.0.0.1:8080/health + + - name: Validate bundle provenance + run: >- + node scripts/ci/assert-bundle-labels.mjs + --image-ref "${{ steps.action.outputs.image-ref }}" + --variant "${{ matrix.variant }}" + --version "${{ inputs.version }}" diff --git a/README.md b/README.md index 6933aa9..1b8a0d8 100644 --- a/README.md +++ b/README.md @@ -368,29 +368,44 @@ pnpm release 0.2.11 --push # ...and push, starting the publish The `Publish Testnode` workflow publishes automatically when a `v*` tag is pushed. The Git tag becomes the image version, and every current v3.2 variant is published. -The workflow can also be run manually to publish one variant or `all`. Each build is -pushed to two registries under the same tag suffix: +The workflow can also be run manually to publish one variant or `all`. + +Publishing goes to the private GHCR package, and reaching the public Docker Hub +repository is a separate step. Both use the same tag suffix, so a version means the +same thing in either: ```text ghcr.io//arbitrum-litro:-nc- offchainlabs/arbitrum-litro:-nc- ``` -Both carry identical images: the workflow builds once and pushes the same digest to -each. The Docker Hub repository is public, so pulling it needs no credentials; the -GHCR package is private and requires a token. Publishing requires the -`DOCKERHUB_USERNAME` and `DOCKERHUB_TOKEN` repository secrets, and refuses to -replace a Docker Hub tag that already exists unless the manual run sets -`overwrite`. +Getting from one to the other is a three-step promotion: + +1. **Publish.** A tag push, or a manual run with `registries: ghcr`, builds and pushes + to GHCR only. A manual run can select `ghcr,dockerhub` to publish to both at once, + which skips the gate below; that needs the `DOCKERHUB_USERNAME` and + `DOCKERHUB_TOKEN` repository secrets, and refuses to replace a Docker Hub tag that + already exists unless the run sets `overwrite`. +2. **Verify.** `Verify Published Image` boots the published `l2` and `l3-eth` images + through the action, authenticating against GHCR, and checks that each image's + bundle labels match the tag it was pulled as. +3. **Promote.** `Mirror Images to Docker Hub` copies the version's tags across with + `crane`, preserving digests rather than rebuilding, so `repo@sha256:...` stays valid + against either registry. It refuses to change a Docker Hub tag whose digest already + differs unless the run sets `overwrite`. After every variant succeeds, a tag-triggered release also updates the corresponding -`latest-` aliases, in both registries. These canonical aliases deliberately -omit a contracts version: consumers follow the composed bundle, while its exact Nitro -and Token Bridge refs and commits remain recorded as OCI labels. +`latest-` aliases in the registries it published to. Promotion carries an +alias across only when the source alias still names a version being mirrored, so the +public alias never points at a version Docker Hub does not have. These canonical +aliases deliberately omit a contracts version: consumers follow the composed bundle, +while its exact Nitro and Token Bridge refs and commits remain recorded as OCI labels. Releases up to `v0.2.10` live in a separate GHCR package, `ghcr.io//arbitrum-testnode-ci`, which still serves those tags. Resolving one -needs `image-repository` plus a token, since that package is private. +needs `image-repository` plus a token, since that package is private. That package is +also the last one to carry `nc2.1` tags: `nitro-contracts-version: v2.1` resolves a +tag that exists only for versions up to `v0.2.10`. Publish the default testnode image automatically: diff --git a/docker/testnode.Dockerfile b/docker/testnode.Dockerfile index 03e6354..03914e6 100644 --- a/docker/testnode.Dockerfile +++ b/docker/testnode.Dockerfile @@ -39,8 +39,13 @@ ARG NITRO_CONTRACTS_REF ARG NITRO_CONTRACTS_COMMIT ARG TOKENBRIDGE_REF ARG TOKENBRIDGE_COMMIT +ARG IMAGE_SOURCE="https://github.com/OffchainLabs/arbitrum-litro" -LABEL io.arbitrum.testnode.bundle.version="${BUNDLE_VERSION}" \ +# GHCR links a package to a repository through image.source, which is what grants +# the repository's own workflows access to a private package. Without it a +# published package starts orphaned and has to be linked by hand. +LABEL org.opencontainers.image.source="${IMAGE_SOURCE}" \ + io.arbitrum.testnode.bundle.version="${BUNDLE_VERSION}" \ io.arbitrum.testnode.bundle.variant="${BUNDLE_VARIANT}" \ io.arbitrum.testnode.nitro-contracts.ref="${NITRO_CONTRACTS_REF}" \ io.arbitrum.testnode.nitro-contracts.commit="${NITRO_CONTRACTS_COMMIT}" \ diff --git a/packages/action/test/action.test.ts b/packages/action/test/action.test.ts index 317d59e..476383d 100644 --- a/packages/action/test/action.test.ts +++ b/packages/action/test/action.test.ts @@ -147,15 +147,44 @@ describe("published bundle metadata", () => { expect(workflow).toContain("node scripts/ci/publish-latest-aliases.mjs"); }); - it("aliases every repository the release was pushed to", () => { - // An alias present in one registry but not the other means the same tag - // name resolves to different images depending on where it is pulled from. + it("aliases exactly the registries the release published to", () => { + // One resolved selection feeds the build and the aliases, so an alias + // cannot name a version the registry it lives in never received. const aliases = readFileSync("scripts/ci/publish-latest-aliases.mjs", "utf-8"); - expect(workflow).toContain('--repository "offchainlabs/arbitrum-litro"'); - expect(workflow).toContain('/arbitrum-litro"'); + expect(workflow).toContain( + '--registries "${{ needs.resolve-publish-matrix.outputs.registries }}"', + ); + expect(aliases).toContain("resolveRepositories({"); // crane preserves the digest, so an alias and its version tag match. expect(aliases).toContain('execFileSync("crane", ["copy"'); }); + + it("publishes privately by default and promotes by copying digests", () => { + // Docker Hub is public and its tags are permanent in practice, so getting + // there is a separate dispatch rather than a side effect of a tag push. + const mirror = readFileSync(".github/workflows/mirror-to-dockerhub.yml", "utf-8"); + expect(workflow).toContain('default: "ghcr"'); + expect(mirror).toContain('default: "ghcr.io/offchainlabs/arbitrum-litro"'); + expect(mirror).toContain("node scripts/ci/mirror-tags.mjs"); + }); + + it("carries latest aliases through promotion", () => { + // Without this the public registry has version tags but no `latest-*`, + // which is what the action and bake actions resolve by default. + const mirror = readFileSync("scripts/ci/mirror-tags.mjs", "utf-8"); + const resolve = readFileSync("scripts/ci/resolve-mirror-tags.mjs", "utf-8"); + expect(resolve).toContain("latest-"); + expect(mirror).toContain('readList("ALIASES")'); + // An alias only moves when the source alias is one of the mirrored digests. + expect(mirror).toContain("mirroredDigests.has(sourceDigest)"); + }); + + it("links the published package to this repository", () => { + // GHCR grants a repository's workflows access to a private package through + // image.source; without it the package starts orphaned. + expect(dockerfile).toContain("org.opencontainers.image.source"); + expect(workflow).toContain("IMAGE_SOURCE="); + }); }); describe("resolveVariant", () => { diff --git a/packages/core/package.json b/packages/core/package.json index 3ecfb94..b19f354 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -19,6 +19,7 @@ }, "dependencies": { "@arbitrum/chain-sdk": "0.26.0", + "@arbitrum/testnode": "workspace:*", "viem": "^2.47.5" } } diff --git a/packages/core/src/snapshot-image.ts b/packages/core/src/snapshot-image.ts index ed8bbab..dbe3cf6 100644 --- a/packages/core/src/snapshot-image.ts +++ b/packages/core/src/snapshot-image.ts @@ -9,6 +9,7 @@ import { writeFileSync, } from "node:fs"; import { join, resolve } from "node:path"; +import { buildTestnodeImageRef } from "@arbitrum/testnode"; import { execOrThrow } from "./exec.js"; import { getSnapshotAnvilStateDir, @@ -195,8 +196,7 @@ export interface BakeSnapshotImageResult { } function defaultBaseImageRef(l3Enabled: boolean): string { - const variant = l3Enabled ? "l3-eth" : "l2"; - return `ghcr.io/offchainlabs/arbitrum-testnode-ci:latest-${variant}`; + return buildTestnodeImageRef({ variant: l3Enabled ? "l3-eth" : "l2", version: "latest" }); } /** diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0822a12..1f13461 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -49,6 +49,9 @@ importers: '@arbitrum/chain-sdk': specifier: 0.26.0 version: 0.26.0(typescript@5.9.3)(viem@2.47.5(typescript@5.9.3)(zod@3.25.76))(zod@3.25.76) + '@arbitrum/testnode': + specifier: workspace:* + version: link:../testnode viem: specifier: ^2.47.5 version: 2.47.5(typescript@5.9.3)(zod@3.25.76) diff --git a/scripts/ci/assert-bundle-labels.mjs b/scripts/ci/assert-bundle-labels.mjs new file mode 100644 index 0000000..16fe721 --- /dev/null +++ b/scripts/ci/assert-bundle-labels.mjs @@ -0,0 +1,44 @@ +import { execFileSync } from "node:child_process"; + +/** + * Asserts an image's bundle labels agree with the tag it was pulled as. + * + * Promotion copies digests rather than rebuilding, so a mislabelled image stays + * mislabelled all the way to the public registry. The labels are the only record + * of which version and variant an image actually is once its tag is out of sight. + */ + +function readArg(name) { + const index = process.argv.indexOf(name); + if (index === -1) { + return ""; + } + return process.argv[index + 1] || ""; +} + +const imageRef = readArg("--image-ref"); +if (!imageRef) { + throw new Error("--image-ref is required"); +} + +const expected = { + "io.arbitrum.testnode.bundle.variant": readArg("--variant"), + "io.arbitrum.testnode.bundle.version": readArg("--version"), +}; + +const labels = + JSON.parse( + execFileSync("docker", ["image", "inspect", "--format", "{{json .Config.Labels}}", imageRef], { + encoding: "utf-8", + }), + ) ?? {}; + +const mismatches = Object.entries(expected) + .filter(([label, value]) => value && labels[label] !== value) + .map(([label, value]) => `${label} is ${labels[label] ?? "unset"}, expected ${value}`); + +if (mismatches.length > 0) { + throw new Error(`${imageRef}: ${mismatches.join("; ")}`); +} + +console.log(`${imageRef} labels match: ${JSON.stringify(expected)}`); diff --git a/scripts/ci/mirror-tags.mjs b/scripts/ci/mirror-tags.mjs index ca492e7..783eb35 100644 --- a/scripts/ci/mirror-tags.mjs +++ b/scripts/ci/mirror-tags.mjs @@ -25,13 +25,17 @@ if (!source || !destination) { } const overwrite = process.argv.includes("--overwrite"); -const tags = (process.env.TAGS ?? "") - .split("\n") - .map((line) => line.trim()) - .filter(Boolean); +const readList = (name) => + (process.env[name] ?? "") + .split("\n") + .map((line) => line.trim()) + .filter(Boolean); + +const tags = readList("TAGS"); if (tags.length === 0) { throw new Error("TAGS is empty"); } +const aliases = readList("ALIASES"); const digestOf = (ref) => { try { @@ -57,25 +61,37 @@ async function destinationExists(repository, tag) { throw new Error(`cannot tell whether ${repository}:${tag} exists (HTTP ${response.status})`); } +function copyTag(tag, sourceDigest) { + execFileSync("crane", ["copy", `${source}:${tag}`, `index.docker.io/${destination}:${tag}`], { + stdio: "inherit", + }); + const copiedDigest = digestOf(`index.docker.io/${destination}:${tag}`); + if (copiedDigest !== sourceDigest) { + throw new Error(`digest mismatch after copy: source ${sourceDigest}, got ${copiedDigest}`); + } +} + const summary = []; const failures = []; +const mirroredDigests = new Set(); let copied = 0; let skipped = 0; +let held = 0; for (const tag of tags) { - const from = `${source}:${tag}`; const to = `${destination}:${tag}`; console.log(`::group::${tag}`); try { - const sourceDigest = digestOf(from); + const sourceDigest = digestOf(`${source}:${tag}`); if (!sourceDigest) { - throw new Error(`source missing: ${from}`); + throw new Error(`source missing: ${source}:${tag}`); } if (await destinationExists(destination, tag)) { const current = digestOf(`index.docker.io/${to}`); if (current === sourceDigest) { console.log(`already mirrored at ${sourceDigest}`); + mirroredDigests.add(sourceDigest); skipped += 1; continue; } @@ -87,13 +103,8 @@ for (const tag of tags) { 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}`); - } - + copyTag(tag, sourceDigest); + mirroredDigests.add(sourceDigest); console.log(`copied ${sourceDigest}`); summary.push(`- \`${to}\` <- \`${sourceDigest}\``); copied += 1; @@ -105,7 +116,42 @@ for (const tag of tags) { } } -const outcome = `copied ${copied}, skipped ${skipped} (already current), failed ${failures.length}`; +// Aliases move only when the source alias names a version this run mirrored. +// Overwriting is the point of an alias, so the guard above does not apply; the +// digest match is what keeps the destination from claiming a version is latest +// when the source does not, or when this run mirrored an older version. +for (const alias of aliases) { + console.log(`::group::${alias}`); + try { + const sourceDigest = digestOf(`${source}:${alias}`); + if (!sourceDigest) { + console.log(`source has no ${alias}`); + held += 1; + continue; + } + if (!mirroredDigests.has(sourceDigest)) { + console.log(`${source}:${alias} is ${sourceDigest}, which is not a version mirrored here`); + held += 1; + continue; + } + if (digestOf(`index.docker.io/${destination}:${alias}`) === sourceDigest) { + console.log(`already mirrored at ${sourceDigest}`); + skipped += 1; + continue; + } + copyTag(alias, sourceDigest); + console.log(`copied ${sourceDigest}`); + summary.push(`- \`${destination}:${alias}\` <- \`${sourceDigest}\``); + copied += 1; + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + failures.push(alias); + } finally { + console.log("::endgroup::"); + } +} + +const outcome = `copied ${copied}, skipped ${skipped} (already current), held ${held} (alias names another version), failed ${failures.length}`; console.log(outcome); if (process.env.GITHUB_STEP_SUMMARY) { appendFileSync(process.env.GITHUB_STEP_SUMMARY, `${[...summary, outcome].join("\n")}\n`); diff --git a/scripts/ci/publish-latest-aliases.mjs b/scripts/ci/publish-latest-aliases.mjs index 8664669..552e000 100644 --- a/scripts/ci/publish-latest-aliases.mjs +++ b/scripts/ci/publish-latest-aliases.mjs @@ -1,4 +1,5 @@ import { execFileSync } from "node:child_process"; +import { resolveRepositories } from "./registries.mjs"; /** * Points `latest-` at the just-published version of that variant, in @@ -10,20 +11,19 @@ import { execFileSync } from "node:child_process"; * tell which release `latest-` currently is. */ -function readArgs(name) { - const values = []; - for (let index = 0; index < process.argv.length; index += 1) { - if (process.argv[index] === name && process.argv[index + 1]) { - values.push(process.argv[index + 1]); - } +function readArg(name) { + const index = process.argv.indexOf(name); + if (index === -1) { + return ""; } - return values; + return process.argv[index + 1] || ""; } -const repositories = readArgs("--repository"); -if (repositories.length === 0) { - throw new Error("at least one --repository is required"); -} +const repositories = resolveRepositories({ + dockerhubRepository: readArg("--dockerhub-repository"), + owner: readArg("--owner"), + registries: readArg("--registries"), +}).map((entry) => entry.repository); const version = process.env.VERSION; if (!version) { diff --git a/scripts/ci/registries.mjs b/scripts/ci/registries.mjs new file mode 100644 index 0000000..9ef7be3 --- /dev/null +++ b/scripts/ci/registries.mjs @@ -0,0 +1,44 @@ +import { DEFAULT_TESTNODE_IMAGE_REPOSITORY } from "../../packages/testnode/src/runtime.mjs"; + +/** + * Maps a registry selection onto the repositories a release writes to. + * + * Both registries carry the same image name, taken from the one constant + * consumers resolve, so a rename cannot leave the two registries disagreeing + * about what the image is called. + */ + +export const REGISTRIES = ["ghcr", "dockerhub"]; + +const IMAGE_NAME = DEFAULT_TESTNODE_IMAGE_REPOSITORY.split("/").pop(); + +/** + * @param {{ registries: string; owner?: string; dockerhubRepository?: string }} options + * @returns {{ registry: string; repository: string }[]} + */ +export function resolveRepositories({ registries, owner, dockerhubRepository }) { + const selected = [ + ...new Set( + String(registries || "") + .split(",") + .map((entry) => entry.trim()) + .filter(Boolean), + ), + ]; + if (selected.length === 0) { + throw new Error("registries is required"); + } + const unknown = selected.filter((entry) => !REGISTRIES.includes(entry)); + if (unknown.length > 0) { + throw new Error(`unknown registries: ${unknown.join(" ")} (expected ${REGISTRIES.join(", ")})`); + } + return selected.map((registry) => { + if (registry === "ghcr") { + if (!owner) { + throw new Error("owner is required to resolve a ghcr repository"); + } + return { registry, repository: `ghcr.io/${owner.toLowerCase()}/${IMAGE_NAME}` }; + } + return { registry, repository: dockerhubRepository || DEFAULT_TESTNODE_IMAGE_REPOSITORY }; + }); +} diff --git a/scripts/ci/resolve-mirror-tags.mjs b/scripts/ci/resolve-mirror-tags.mjs index fbe652d..aa077cd 100644 --- a/scripts/ci/resolve-mirror-tags.mjs +++ b/scripts/ci/resolve-mirror-tags.mjs @@ -59,6 +59,18 @@ if (tags.length === 0) { tags.sort(); console.error(`mirroring ${tags.length} tags: ${tags.join(" ")}`); +// The `latest-` alias for every variant in the set. Whether an alias +// actually moves is decided at copy time, from whether the source alias points +// at one of these tags: the destination must not claim a version is latest when +// the source does not. +const aliases = [ + ...new Set(tags.map((tag) => `latest-${pattern.exec(tag)?.groups?.variant}`)), +].sort(); +console.error(`candidate aliases: ${aliases.join(" ")}`); + if (process.env.GITHUB_OUTPUT) { - appendFileSync(process.env.GITHUB_OUTPUT, `list< ({ + registry, + ref: buildTestnodeImageRef({ contractsVersion, imageRepository: repository, variant, version }), + }), +); + +/** + * Docker Hub tags are mutable and public: without this, a re-run silently + * replaces a shipped image. Queried through the Hub API rather than the registry + * because Docker Hub answers 401 for an unknown repository, which is + * indistinguishable from bad credentials. Assumes a public repository (a private + * one 404s whether or not the tag exists). + */ +async function assertTagIsFree(ref) { + const repository = ref.slice(0, ref.lastIndexOf(":")); + const tag = ref.slice(ref.lastIndexOf(":") + 1); + const response = await fetch(`https://hub.docker.com/v2/repositories/${repository}/tags/${tag}`); + if (response.status === 404) { + console.log(`${ref} is free`); + return; + } + if (response.status === 200) { + throw new Error(`${ref} already exists; re-run with overwrite to replace it`); + } + throw new Error(`could not determine whether ${ref} exists (HTTP ${response.status})`); +} + +const dockerhub = refs.find((entry) => entry.registry === "dockerhub"); +if (dockerhub) { + // A publish must not degrade to a subset of the selected registries when a + // credential is missing: half-published versions are worse than a failed run. + if (!process.env.DOCKERHUB_USERNAME || !process.env.DOCKERHUB_TOKEN) { + throw new Error("DOCKERHUB_USERNAME and DOCKERHUB_TOKEN are required to publish to Docker Hub"); + } + if (!overwrite) { + await assertTagIsFree(dockerhub.ref); + } +} + +const tags = refs.map((entry) => entry.ref); +console.log(`publishing ${tags.join(" ")}`); + +if (process.env.GITHUB_OUTPUT) { + appendFileSync(process.env.GITHUB_OUTPUT, `tags<