From e379be177652f9bc405d4ab4df0e43a13b6ac421 Mon Sep 17 00:00:00 2001 From: dlance Date: Tue, 4 Aug 2026 10:11:02 -0400 Subject: [PATCH 1/4] refactor(init): unify Token Bridge contract sources --- .github/workflows/release-testnode-image.yml | 8 +- .gitignore | 1 + README.md | 11 +- bake/action.yml | 43 +++-- docker/contract-deployer.Dockerfile | 19 -- docker/docker-compose.yaml | 2 + docker/testnode.Dockerfile | 14 +- docker/tokenbridge.Dockerfile | 11 +- packages/action/test/action.test.ts | 6 + packages/core/src/init/chain-steps.ts | 15 +- packages/core/src/init/runner.ts | 6 + packages/core/src/snapshot-image.ts | 21 ++- packages/core/src/token-bridge-source.ts | 163 ++++++++++++++++++ packages/core/src/token-bridge.ts | 38 ++-- .../core/test/token-bridge-source.test.ts | 129 ++++++++++++++ packages/core/test/token-bridge.test.ts | 12 ++ 16 files changed, 429 insertions(+), 70 deletions(-) create mode 100644 packages/core/src/token-bridge-source.ts create mode 100644 packages/core/test/token-bridge-source.test.ts diff --git a/.github/workflows/release-testnode-image.yml b/.github/workflows/release-testnode-image.yml index 6725ee9..97aebf6 100644 --- a/.github/workflows/release-testnode-image.yml +++ b/.github/workflows/release-testnode-image.yml @@ -105,10 +105,11 @@ jobs: contents: read packages: write env: - # init resolves token-bridge-contracts to /token-bridge-contracts; - # pin the location and the commit the contract-deployer Dockerfiles use. + # Prepare the same pinned Token Bridge checkout for host deployment and + # the baked image's named build context. TOKEN_BRIDGE_LOCAL_DIR: ${{ github.workspace }}/../token-bridge-contracts TOKEN_BRIDGE_COMMIT: 5975d8f7360816341be7f94fd333ef240f4aec23 + TOKEN_BRIDGE_DOCKER_CONTEXT: https://github.com/OffchainLabs/token-bridge-contracts.git#5975d8f7360816341be7f94fd333ef240f4aec23 NITRO_CONTRACTS_LOCAL_DIR: ${{ github.workspace }}/../nitro-contracts NITRO_CONTRACTS_COMMIT: 2695e7b3e3f460531e2b77fed48a60561c54d90e steps: @@ -141,6 +142,7 @@ jobs: git remote add origin https://github.com/OffchainLabs/token-bridge-contracts.git git fetch --depth 1 origin "$TOKEN_BRIDGE_COMMIT" git checkout --detach FETCH_HEAD + git submodule update --init --recursive --depth 1 yarn install --frozen-lockfile yarn build test -f node_modules/ts-node/dist/bin.js @@ -352,6 +354,8 @@ jobs: uses: docker/build-push-action@v6 with: context: . + build-contexts: | + tokenbridge=${{ env.TOKEN_BRIDGE_DOCKER_CONTEXT }} file: docker/testnode.Dockerfile push: true # One build, two pushes -- a second build could diverge from the first. diff --git a/.gitignore b/.gitignore index f532927..6b79ad2 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ config/anvil-state config/runs/ config/snapshots/ .testnode-context/ +.cache/ scratch/ .tmp/ config/l1-l2-admin/ diff --git a/README.md b/README.md index b536c72..e794d2d 100644 --- a/README.md +++ b/README.md @@ -272,6 +272,12 @@ The checkout determines the contracts family; `init` and `bake` no longer expose separate Nitro version or branch selectors. New builds require Nitro 3.x. Existing published v2.1 images remain available through `start` and the run action. +Token Bridge contracts use the same source model: set `TOKEN_BRIDGE_LOCAL_DIR` to +a prepared checkout, or use a prepared sibling `../token-bridge-contracts` checkout. +When neither exists, the CLI materializes the compatible default commit in its +ignored `.cache/contract-sources` directory. Host deployment, Compose builds, +and baked images all use the same selected source identity. + To bake straight from an existing snapshot (no setup step), use the à-la-carte subcommand: @@ -300,8 +306,9 @@ job — log in before invoking it: ``` By default the action installs a base snapshot release (via `github-token`) and -restores it; set `rebuild: true` to run a full init instead. Rebuilds also accept -`nitro-contracts-ref` (default `v3.2.0`) and `fee-token-decimals`. +restores it; set `rebuild: true` to run a full init instead. The action accepts +`nitro-contracts-ref` (default `v3.2.0`), `token-bridge-ref`, and +`fee-token-decimals`. ### Booting a custom image diff --git a/bake/action.yml b/bake/action.yml index 31db16a..62a11c9 100644 --- a/bake/action.yml +++ b/bake/action.yml @@ -42,6 +42,11 @@ inputs: default: "v3.2.0" description: >- Nitro contracts release, branch, or commit used during a rebuild init. + token-bridge-ref: + required: false + default: "5975d8f7360816341be7f94fd333ef240f4aec23" + description: >- + Token Bridge contracts release, branch, or commit used by init and the baked image. fee-token-decimals: required: false default: "" @@ -73,6 +78,29 @@ runs: pnpm install --frozen-lockfile pnpm build + - name: Prepare Token Bridge contracts checkout + shell: bash + env: + TOKEN_BRIDGE_REF_INPUT: ${{ inputs.token-bridge-ref }} + TOKEN_BRIDGE_LOCAL_DIR: ${{ runner.temp }}/token-bridge-contracts + run: | + set -euxo pipefail + npm install -g yarn@1.22.22 + mkdir -p "$TOKEN_BRIDGE_LOCAL_DIR" + cd "$TOKEN_BRIDGE_LOCAL_DIR" + git init . + git remote add origin https://github.com/OffchainLabs/token-bridge-contracts.git + git fetch --depth 1 origin "$TOKEN_BRIDGE_REF_INPUT" + git checkout --detach FETCH_HEAD + git submodule update --init --recursive --depth 1 + TOKEN_BRIDGE_RESOLVED_COMMIT="$(git rev-parse HEAD)" + yarn install --frozen-lockfile + yarn build + test -f node_modules/ts-node/dist/bin.js + test -f scripts/deployment/deployTokenBridgeCreator.ts + echo "TOKEN_BRIDGE_LOCAL_DIR=$TOKEN_BRIDGE_LOCAL_DIR" >> "$GITHUB_ENV" + echo "TOKEN_BRIDGE_DOCKER_CONTEXT=https://github.com/OffchainLabs/token-bridge-contracts.git#$TOKEN_BRIDGE_RESOLVED_COMMIT" >> "$GITHUB_ENV" + - name: Install base snapshot if: ${{ inputs.rebuild != 'true' }} shell: bash @@ -104,11 +132,9 @@ runs: NITRO_CONTRACTS_REF_INPUT: ${{ inputs.nitro-contracts-ref }} NITRO_CONTRACTS_DEFAULT_COMMIT: 2695e7b3e3f460531e2b77fed48a60561c54d90e NITRO_CONTRACTS_LOCAL_DIR: ${{ runner.temp }}/nitro-contracts - TOKEN_BRIDGE_COMMIT: 5975d8f7360816341be7f94fd333ef240f4aec23 TOKEN_BRIDGE_LOCAL_DIR: ${{ runner.temp }}/token-bridge-contracts run: | set -euxo pipefail - npm install -g yarn@1.22.22 if [ ! -f "$NITRO_CONTRACTS_LOCAL_DIR/package.json" ]; then mkdir -p "$NITRO_CONTRACTS_LOCAL_DIR" ( @@ -123,18 +149,6 @@ runs: fi ) fi - if [ ! -f "$TOKEN_BRIDGE_LOCAL_DIR/node_modules/ts-node/dist/bin.js" ]; then - mkdir -p "$TOKEN_BRIDGE_LOCAL_DIR" - ( - cd "$TOKEN_BRIDGE_LOCAL_DIR" - git init . - git remote add origin https://github.com/OffchainLabs/token-bridge-contracts.git - git fetch --depth 1 origin "$TOKEN_BRIDGE_COMMIT" - git checkout --detach FETCH_HEAD - yarn install --frozen-lockfile - yarn build - ) - fi base="$BASE_SNAPSHOT_ID_INPUT" init_args=(--rebuild --capture-id "$base" --skip-post-capture-verify) if [ -n "$FEE_TOKEN_DECIMALS_INPUT" ]; then @@ -161,6 +175,7 @@ runs: # Booting nitro from the restored snapshot is slower on cold CI runners # than the default 120s budget. TESTNODE_RPC_TIMEOUT_MS: "300000" + TOKEN_BRIDGE_LOCAL_DIR: ${{ runner.temp }}/token-bridge-contracts run: | set -euo pipefail node apps/cli/dist/index.js bake \ diff --git a/docker/contract-deployer.Dockerfile b/docker/contract-deployer.Dockerfile index 5ab6b46..485ad3a 100644 --- a/docker/contract-deployer.Dockerfile +++ b/docker/contract-deployer.Dockerfile @@ -18,31 +18,12 @@ RUN cp scripts/config.example.ts scripts/config.ts RUN yarn install --frozen-lockfile RUN yarn build:all -FROM node:20-trixie-slim AS token-bridge-builder - -COPY --from=foundry /usr/local/bin/forge /usr/local/bin/forge - -RUN apt-get update && \ - apt-get install -y git python3 build-essential && \ - rm -rf /var/lib/apt/lists/* - -WORKDIR /workspace/token-bridge-contracts - -RUN git init . \ - && git remote add origin https://github.com/OffchainLabs/token-bridge-contracts.git \ - && git fetch --depth 1 origin 5975d8f7360816341be7f94fd333ef240f4aec23 \ - && git checkout --detach FETCH_HEAD - -RUN yarn install --frozen-lockfile -RUN yarn build - FROM node:20-trixie-slim COPY --from=foundry /usr/local/bin/forge /usr/local/bin/forge WORKDIR /workspace COPY --from=nitro-builder /workspace/nitro-contracts /workspace/nitro-contracts -COPY --from=token-bridge-builder /workspace/token-bridge-contracts /workspace/token-bridge-contracts COPY deploy-rollup-creator.ts /workspace/nitro-contracts/scripts/local-deployment/deployRollupCreatorOnly.ts COPY deploy-timeboost-auction.ts /workspace/nitro-contracts/scripts/local-deployment/deployTimeboostAuction.ts diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index 3fc38e8..e821236 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -133,6 +133,8 @@ services: build: context: . dockerfile: tokenbridge.Dockerfile + additional_contexts: + tokenbridge: ${TOKEN_BRIDGE_DOCKER_CONTEXT:-${TOKEN_BRIDGE_LOCAL_DIR:-../../token-bridge-contracts}} depends_on: - sequencer - l1 diff --git a/docker/testnode.Dockerfile b/docker/testnode.Dockerfile index 879f55f..1b9a7c5 100644 --- a/docker/testnode.Dockerfile +++ b/docker/testnode.Dockerfile @@ -2,6 +2,8 @@ ARG NODE_IMAGE=node:20-bullseye-slim ARG FOUNDRY_IMAGE=ghcr.io/foundry-rs/foundry:v1.3.5 ARG NITRO_IMAGE=offchainlabs/nitro-node:v3.9.5-66e42c4 +FROM scratch AS tokenbridge + FROM ${NODE_IMAGE} AS token-bridge-contracts RUN apt-get update \ @@ -10,14 +12,16 @@ RUN apt-get update \ WORKDIR /workspace -RUN git init . \ - && git remote add origin https://github.com/OffchainLabs/token-bridge-contracts.git \ - && git fetch --depth 1 origin feat/polling-interval-conditional-verification \ - && git checkout --detach FETCH_HEAD +COPY --from=tokenbridge . /workspace + +RUN rm -rf .git && \ + git init && \ + git add . && \ + git -c user.name="user" -c user.email="user@example.com" commit -m "Initial commit" RUN yarn install --frozen-lockfile \ && yarn build \ - && rm -rf .git + && rm -rf .git node_modules/.cache FROM ${FOUNDRY_IMAGE} AS foundry diff --git a/docker/tokenbridge.Dockerfile b/docker/tokenbridge.Dockerfile index ddfad6f..dcafa42 100644 --- a/docker/tokenbridge.Dockerfile +++ b/docker/tokenbridge.Dockerfile @@ -1,15 +1,14 @@ +FROM scratch AS tokenbridge + FROM node:20-trixie-slim RUN apt-get update && apt-get install -y git docker.io python3 make gcc g++ curl jq -ARG TOKEN_BRIDGE_BRANCH=main - WORKDIR /workspace -RUN git clone --no-checkout https://github.com/OffchainLabs/token-bridge-contracts.git ./ && \ - git checkout ${TOKEN_BRIDGE_BRANCH} && \ - git submodule update --init --recursive && \ - rm -rf .git && \ +COPY --from=tokenbridge . /workspace + +RUN rm -rf .git && \ git init && \ git add . && \ git -c user.name="user" -c user.email="user@example.com" commit -m "Initial commit" diff --git a/packages/action/test/action.test.ts b/packages/action/test/action.test.ts index 34b016e..0057c05 100644 --- a/packages/action/test/action.test.ts +++ b/packages/action/test/action.test.ts @@ -115,6 +115,12 @@ describe("bake action metadata", () => { expect(action).not.toContain("--nitro-contracts-branch"); expect(action).toContain('node apps/cli/dist/index.js init "${init_args[@]}"'); }); + + it("uses one selected Token Bridge checkout for init and image baking", () => { + expect(action).toContain("TOKEN_BRIDGE_REF_INPUT: ${{ inputs.token-bridge-ref }}"); + expect(action).toContain("TOKEN_BRIDGE_LOCAL_DIR: ${{ runner.temp }}/token-bridge-contracts"); + expect(action).toContain('git fetch --depth 1 origin "$TOKEN_BRIDGE_REF_INPUT"'); + }); }); describe("resolveVariant", () => { diff --git a/packages/core/src/init/chain-steps.ts b/packages/core/src/init/chain-steps.ts index 53ecb63..5778ed5 100644 --- a/packages/core/src/init/chain-steps.ts +++ b/packages/core/src/init/chain-steps.ts @@ -17,6 +17,7 @@ import { startL1Container } from "../runtime.js"; import { deployRollupViaSdk, prepareNodeConfigFromDeployment } from "../sdk-chain.js"; import type { InitState } from "../state.js"; import { markStepDone } from "../state.js"; +import type { TokenBridgeSource } from "../token-bridge-source.js"; import { deployL1L2TokenBridge, deployL2L3TokenBridge, @@ -404,6 +405,7 @@ function createL2DeploySteps( function createL2RuntimeSteps( runtime: InitRuntime, nitroContractsSource: NitroContractsSource, + tokenBridgeSource: TokenBridgeSource, ): Record { return { "start-l2": async (state) => { @@ -484,6 +486,7 @@ function createL2RuntimeSteps( childRpc: L2_RPC_INTERNAL, parentKey: accounts.l2owner.privateKey, childKey: accounts.l2owner.privateKey, + tokenBridgeDir: tokenBridgeSource.path, }); return markStepDone(state, "deploy-l2-token-bridge"); }, @@ -666,6 +669,7 @@ function createL3Steps( runtime: InitRuntime, feeTokenDecimals: number | undefined, nitroContractsSource: NitroContractsSource, + tokenBridgeSource: TokenBridgeSource, ): Record { return { "deploy-l3-rollup": (state) => @@ -807,6 +811,7 @@ function createL3Steps( parentKey: accounts.l3owner.privateKey, childKey: accounts.l3owner.privateKey, parentWethOverride: getL2ChildWeth(runtime.configDir), + tokenBridgeDir: tokenBridgeSource.path, }); setL3StakerEnabled(runtime, false); @@ -831,12 +836,18 @@ export function makeStepRunners( options: { feeTokenDecimals?: number | undefined; nitroContractsSource: NitroContractsSource; + tokenBridgeSource: TokenBridgeSource; }, ): Record { return { ...createL1Steps(runtime), ...createL2DeploySteps(runtime, options.nitroContractsSource), - ...createL2RuntimeSteps(runtime, options.nitroContractsSource), - ...createL3Steps(runtime, options.feeTokenDecimals, options.nitroContractsSource), + ...createL2RuntimeSteps(runtime, options.nitroContractsSource, options.tokenBridgeSource), + ...createL3Steps( + runtime, + options.feeTokenDecimals, + options.nitroContractsSource, + options.tokenBridgeSource, + ), }; } diff --git a/packages/core/src/init/runner.ts b/packages/core/src/init/runner.ts index 77e6845..4949e20 100644 --- a/packages/core/src/init/runner.ts +++ b/packages/core/src/init/runner.ts @@ -21,6 +21,7 @@ import { verifySnapshotSemanticState, } from "../snapshot.js"; import { createState, getNextPendingStep, loadState, markStepFailed, saveState } from "../state.js"; +import { prepareTokenBridgeSource, resolveTokenBridgeSource } from "../token-bridge-source.js"; import { makeStepRunners } from "./chain-steps.js"; import { type InitContext, type InitRuntime, createInitRuntime } from "./context.js"; import { resolveNitroContractsSource } from "./nitro-contracts-source.js"; @@ -63,6 +64,7 @@ async function runInitLoop( rebuild?: boolean | undefined; timeboostEnabled?: boolean | undefined; nitroContractsSource: ReturnType; + tokenBridgeSource: ReturnType; }, ): Promise<{ success: boolean; @@ -75,6 +77,7 @@ async function runInitLoop( const runners = makeStepRunners(runtime, { feeTokenDecimals: options.feeTokenDecimals, nitroContractsSource: options.nitroContractsSource, + tokenBridgeSource: options.tokenBridgeSource, }); const steps = getInitSteps({ timeboostEnabled: options.timeboostEnabled }); const timings: Record = {}; @@ -217,11 +220,14 @@ async function runInitForeground( startRunLoggingFromEnv(runtime.configDir) ?? startInlineRunLogging(runtime.configDir, logArgs); const nitroContractsSource = resolveNitroContractsSource(runtime.projectRoot); console.log(`[init] Nitro contracts source: ${nitroContractsSource.identity}`); + const tokenBridgeSource = prepareTokenBridgeSource(resolveTokenBridgeSource(runtime.projectRoot)); + console.log(`[init] Token Bridge contracts source: ${tokenBridgeSource.identity}`); const result = await runInitLoop(runtime, { feeTokenDecimals, rebuild: options.rebuild, timeboostEnabled: options.timeboostEnabled, nitroContractsSource, + tokenBridgeSource, }); const totalElapsed = Date.now() - totalStart; logInitTimeline(result.timings, totalElapsed); diff --git a/packages/core/src/snapshot-image.ts b/packages/core/src/snapshot-image.ts index 7f09d53..115e5a8 100644 --- a/packages/core/src/snapshot-image.ts +++ b/packages/core/src/snapshot-image.ts @@ -16,6 +16,7 @@ import { getSnapshotVolumesDir, verifySnapshotManifest, } from "./snapshot.js"; +import { prepareTokenBridgeSource, resolveTokenBridgeSource } from "./token-bridge-source.js"; /** * Turn a captured snapshot into a runnable testnode docker image. @@ -224,10 +225,24 @@ export function bakeSnapshotImage(options: BakeSnapshotImageOptions): BakeSnapsh : {}), ...(options.variant !== undefined ? { variant: options.variant } : {}), }); + const tokenBridgeSource = prepareTokenBridgeSource(resolveTokenBridgeSource(projectRoot)); - execOrThrow("docker", ["build", "-f", dockerfile, "-t", options.imageRef, projectRoot], { - timeout: 900_000, - }); + execOrThrow( + "docker", + [ + "build", + "--build-context", + `tokenbridge=${tokenBridgeSource.dockerContext}`, + "-f", + dockerfile, + "-t", + options.imageRef, + projectRoot, + ], + { + timeout: 900_000, + }, + ); let pushed = false; if (options.push) { diff --git a/packages/core/src/token-bridge-source.ts b/packages/core/src/token-bridge-source.ts new file mode 100644 index 0000000..2b6d9f0 --- /dev/null +++ b/packages/core/src/token-bridge-source.ts @@ -0,0 +1,163 @@ +import { existsSync, mkdirSync, readFileSync, renameSync, rmSync } from "node:fs"; +import { resolve } from "node:path"; +import { execOrThrow } from "./exec.js"; + +export const DEFAULT_TOKEN_BRIDGE_COMMIT = "5975d8f7360816341be7f94fd333ef240f4aec23"; + +const TOKEN_BRIDGE_REPOSITORY = "https://github.com/OffchainLabs/token-bridge-contracts.git"; +const TOKEN_BRIDGE_PACKAGE = "@arbitrum/token-bridge-contracts"; +const TOKEN_BRIDGE_CREATOR_SCRIPT = "scripts/deployment/deployTokenBridgeCreator.ts"; +const TOKEN_BRIDGE_TS_NODE = "node_modules/ts-node/dist/bin.js"; +const TOKEN_BRIDGE_SUBMODULE_PATHS = ["lib/forge-std/src", "lib/nitro-contracts/src"] as const; + +export interface TokenBridgeSource { + kind: "managed" | "workspace"; + path: string; + dockerContext: string; + identity: string; + packageVersion?: string | undefined; +} + +interface TokenBridgePackageJson { + name?: unknown; + version?: unknown; + scripts?: Record | undefined; +} + +function readPackageJson(workspace: string): TokenBridgePackageJson { + const path = resolve(workspace, "package.json"); + if (!existsSync(path)) { + throw new Error(`Token Bridge contracts workspace is missing package.json: ${path}`); + } + try { + return JSON.parse(readFileSync(path, "utf8")) as TokenBridgePackageJson; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Unable to parse Token Bridge contracts package.json at ${path}: ${message}`); + } +} + +function assertPath(workspace: string, relativePath: string): void { + const path = resolve(workspace, relativePath); + if (!existsSync(path)) { + throw new Error(`Token Bridge contracts workspace is missing ${relativePath}: ${path}`); + } +} + +export function validateTokenBridgeWorkspace( + workspace: string, + options: { requireDependencies?: boolean } = {}, +): { path: string; packageVersion: string } { + const path = resolve(workspace); + const packageJson = readPackageJson(path); + if (packageJson.name !== TOKEN_BRIDGE_PACKAGE) { + throw new Error( + `Expected ${TOKEN_BRIDGE_PACKAGE} in ${resolve(path, "package.json")}, found ${JSON.stringify(packageJson.name)}`, + ); + } + if ( + typeof packageJson.version !== "string" || + !/^1\.\d+\.\d+(?:[-+].+)?$/.test(packageJson.version) + ) { + throw new Error( + `Unsupported Token Bridge contracts package version ${String(packageJson.version)}`, + ); + } + if (typeof packageJson.scripts?.["build"] !== "string") { + throw new Error( + `Token Bridge contracts workspace does not define the required build script: ${path}`, + ); + } + assertPath(path, "yarn.lock"); + assertPath(path, TOKEN_BRIDGE_CREATOR_SCRIPT); + for (const submodulePath of TOKEN_BRIDGE_SUBMODULE_PATHS) { + assertPath(path, submodulePath); + } + if (options.requireDependencies) { + assertPath(path, TOKEN_BRIDGE_TS_NODE); + } + return { path, packageVersion: packageJson.version }; +} + +function workspaceSource(workspace: string, dockerContext?: string): TokenBridgeSource { + const validated = validateTokenBridgeWorkspace(workspace, { requireDependencies: true }); + return { + kind: "workspace", + path: validated.path, + dockerContext: dockerContext?.trim() || validated.path, + packageVersion: validated.packageVersion, + identity: `workspace:${validated.path}`, + }; +} + +export function resolveTokenBridgeSource( + projectRoot: string, + env: NodeJS.ProcessEnv = process.env, +): TokenBridgeSource { + const configured = env["TOKEN_BRIDGE_LOCAL_DIR"]?.trim(); + if (configured) { + return workspaceSource(configured, env["TOKEN_BRIDGE_DOCKER_CONTEXT"]); + } + + const sibling = resolve(projectRoot, "..", "token-bridge-contracts"); + if (existsSync(sibling)) { + return workspaceSource(sibling, env["TOKEN_BRIDGE_DOCKER_CONTEXT"]); + } + + return { + kind: "managed", + path: resolve( + projectRoot, + ".cache", + "contract-sources", + "token-bridge-contracts", + DEFAULT_TOKEN_BRIDGE_COMMIT, + ), + dockerContext: `${TOKEN_BRIDGE_REPOSITORY}#${DEFAULT_TOKEN_BRIDGE_COMMIT}`, + identity: `commit:${DEFAULT_TOKEN_BRIDGE_COMMIT}`, + }; +} + +function cloneManagedSource(destination: string): void { + const temp = `${destination}.tmp-${process.pid}`; + rmSync(temp, { recursive: true, force: true }); + mkdirSync(temp, { recursive: true }); + try { + execOrThrow("git", ["init", "."], { cwd: temp }); + execOrThrow("git", ["remote", "add", "origin", TOKEN_BRIDGE_REPOSITORY], { cwd: temp }); + execOrThrow("git", ["fetch", "--depth", "1", "origin", DEFAULT_TOKEN_BRIDGE_COMMIT], { + cwd: temp, + timeout: 300_000, + }); + execOrThrow("git", ["checkout", "--detach", "FETCH_HEAD"], { cwd: temp }); + execOrThrow("git", ["submodule", "update", "--init", "--recursive", "--depth", "1"], { + cwd: temp, + timeout: 300_000, + }); + execOrThrow("yarn", ["install", "--frozen-lockfile"], { cwd: temp, timeout: 900_000 }); + execOrThrow("yarn", ["build"], { cwd: temp, timeout: 900_000 }); + validateTokenBridgeWorkspace(temp, { requireDependencies: true }); + mkdirSync(resolve(destination, ".."), { recursive: true }); + renameSync(temp, destination); + } catch (error) { + rmSync(temp, { recursive: true, force: true }); + throw error; + } +} + +export function prepareTokenBridgeSource(source: TokenBridgeSource): TokenBridgeSource { + if (source.kind === "workspace") { + validateTokenBridgeWorkspace(source.path, { requireDependencies: true }); + return source; + } + + try { + const validated = validateTokenBridgeWorkspace(source.path, { requireDependencies: true }); + return { ...source, packageVersion: validated.packageVersion }; + } catch { + rmSync(source.path, { recursive: true, force: true }); + cloneManagedSource(source.path); + const validated = validateTokenBridgeWorkspace(source.path, { requireDependencies: true }); + return { ...source, packageVersion: validated.packageVersion }; + } +} diff --git a/packages/core/src/token-bridge.ts b/packages/core/src/token-bridge.ts index 6d72b59..242ddb2 100644 --- a/packages/core/src/token-bridge.ts +++ b/packages/core/src/token-bridge.ts @@ -33,20 +33,7 @@ import { } from "./rpc.js"; const ARB_OWNER = "0x0000000000000000000000000000000000000070" as const; -const LOCAL_TOKEN_BRIDGE_DIR = - process.env["TOKEN_BRIDGE_LOCAL_DIR"] ?? - resolve(import.meta.dirname, "../../../../token-bridge-contracts"); -const TOKEN_BRIDGE_TS_NODE = resolve(LOCAL_TOKEN_BRIDGE_DIR, "node_modules/ts-node/dist/bin.js"); const TOKEN_BRIDGE_CREATOR_SCRIPT = "./scripts/deployment/deployTokenBridgeCreator.ts"; -const SDK_LOCAL_NETWORK_PATH = - process.env["ARBITRUM_SDK_LOCAL_NETWORK_PATH"] ?? - resolve(import.meta.dirname, "../../../../arbitrum-sdk/packages/sdk/localNetwork.json"); -const PORTAL_LOCAL_NETWORK_PATH = - process.env["ARBITRUM_PORTAL_LOCAL_NETWORK_PATH"] ?? - resolve( - import.meta.dirname, - "../../../../arbitrum-portal/packages/arb-token-bridge-ui/src/util/networksNitroTestnode.generated.json", - ); const FUNDING_RESERVE_WEI = 1n * 10n ** 18n; const TOKENBRIDGE_DEPLOYER_TARGET_L2_WEI = 100n * 10n ** 18n; const TOKENBRIDGE_DEPLOYER_TARGET_L3_WEI = 10n * 10n ** 18n; @@ -70,9 +57,22 @@ interface BridgeDeployParams { childRpc: string; parentKey: string; childKey: string; + tokenBridgeDir: string; parentWethOverride?: string; } +function localNetworkOutputPaths(): string[] { + return [ + process.env["ARBITRUM_SDK_LOCAL_NETWORK_PATH"] ?? + resolve(import.meta.dirname, "../../../../arbitrum-sdk/packages/sdk/localNetwork.json"), + process.env["ARBITRUM_PORTAL_LOCAL_NETWORK_PATH"] ?? + resolve( + import.meta.dirname, + "../../../../arbitrum-portal/packages/arb-token-bridge-ui/src/util/networksNitroTestnode.generated.json", + ), + ]; +} + interface L1L2NetworkFile { l2Network?: { tokenBridge?: { @@ -248,9 +248,11 @@ function deployTokenBridgeCreator(params: { compose: ComposeContext; parentRpc: string; parentKey: string; + tokenBridgeDir: string; parentWeth?: string | undefined; }): string { - assertTokenBridgeDepsPresent(LOCAL_TOKEN_BRIDGE_DIR); + assertTokenBridgeDepsPresent(params.tokenBridgeDir); + const tokenBridgeTsNode = resolve(params.tokenBridgeDir, "node_modules/ts-node/dist/bin.js"); const requiresParentDeployGasOverride = params.parentRpc !== "http://host.docker.internal:8545" && params.parentRpc !== "http://127.0.0.1:8545"; @@ -265,11 +267,11 @@ function deployTokenBridgeCreator(params: { "DISABLE_CONTRACT_VERIFICATION=true", "GAS_LIMIT_FOR_L2_FACTORY_DEPLOYMENT=10000000", "node", - TOKEN_BRIDGE_TS_NODE, + tokenBridgeTsNode, TOKEN_BRIDGE_CREATOR_SCRIPT, ], { - cwd: LOCAL_TOKEN_BRIDGE_DIR, + cwd: params.tokenBridgeDir, timeout: 600_000, }, ); @@ -543,7 +545,7 @@ function publishLocalNetworkArtifacts(configDir: string): void { } const localNetwork = readFileSync(localNetworkPath, "utf-8"); - for (const targetPath of [SDK_LOCAL_NETWORK_PATH, PORTAL_LOCAL_NETWORK_PATH]) { + for (const targetPath of localNetworkOutputPaths()) { mkdirSync(dirname(targetPath), { recursive: true }); writeFileSync(targetPath, localNetwork); } @@ -619,6 +621,7 @@ export async function deployL1L2TokenBridge(params: BridgeDeployParams): Promise compose: params.compose, parentRpc: params.parentRpc, parentKey: params.parentKey, + tokenBridgeDir: params.tokenBridgeDir, parentWeth: l2Deployment["stake-token"] ?? ZERO_ADDRESS, }); waitForCreatorSettlement(); @@ -671,6 +674,7 @@ export async function deployL2L3TokenBridge(params: BridgeDeployParams): Promise compose: params.compose, parentRpc: params.parentRpc, parentKey: params.parentKey, + tokenBridgeDir: params.tokenBridgeDir, parentWeth: params.parentWethOverride, }); waitForCreatorSettlement(); diff --git a/packages/core/test/token-bridge-source.test.ts b/packages/core/test/token-bridge-source.test.ts new file mode 100644 index 0000000..ef42b03 --- /dev/null +++ b/packages/core/test/token-bridge-source.test.ts @@ -0,0 +1,129 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + DEFAULT_TOKEN_BRIDGE_COMMIT, + prepareTokenBridgeSource, + resolveTokenBridgeSource, + validateTokenBridgeWorkspace, +} from "../src/token-bridge-source.js"; + +const tempDirs: string[] = []; + +function makeProject(): { root: string; parent: string } { + const parent = fs.mkdtempSync(path.join(os.tmpdir(), "token-bridge-source-test-")); + tempDirs.push(parent); + const root = path.join(parent, "arbitrum-testnode"); + fs.mkdirSync(root); + return { root, parent }; +} + +function makeWorkspace(dir: string, options: { dependencies?: boolean; version?: string } = {}) { + fs.mkdirSync(path.join(dir, "scripts/deployment"), { recursive: true }); + fs.mkdirSync(path.join(dir, "lib/forge-std/src"), { recursive: true }); + fs.mkdirSync(path.join(dir, "lib/nitro-contracts/src"), { recursive: true }); + fs.writeFileSync( + path.join(dir, "package.json"), + JSON.stringify({ + name: "@arbitrum/token-bridge-contracts", + version: options.version ?? "1.2.5", + scripts: { build: "hardhat compile" }, + }), + ); + fs.writeFileSync(path.join(dir, "yarn.lock"), ""); + fs.writeFileSync(path.join(dir, "scripts/deployment/deployTokenBridgeCreator.ts"), ""); + if (options.dependencies ?? true) { + fs.mkdirSync(path.join(dir, "node_modules/ts-node/dist"), { recursive: true }); + fs.writeFileSync(path.join(dir, "node_modules/ts-node/dist/bin.js"), ""); + } + return dir; +} + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("resolveTokenBridgeSource", () => { + it("prefers the explicit prepared workspace", () => { + const { root, parent } = makeProject(); + makeWorkspace(path.join(parent, "token-bridge-contracts")); + const explicit = makeWorkspace(path.join(parent, "custom-token-bridge")); + const source = resolveTokenBridgeSource(root, { TOKEN_BRIDGE_LOCAL_DIR: explicit }); + expect(source).toMatchObject({ kind: "workspace", path: explicit }); + }); + + it("uses the prepared sibling checkout", () => { + const { root, parent } = makeProject(); + const sibling = makeWorkspace(path.join(parent, "token-bridge-contracts")); + expect(resolveTokenBridgeSource(root, {})).toMatchObject({ + kind: "workspace", + path: sibling, + }); + }); + + it("falls back to the commit-keyed managed cache", () => { + const { root } = makeProject(); + const source = resolveTokenBridgeSource(root, {}); + expect(source).toEqual({ + kind: "managed", + path: path.join( + root, + ".cache", + "contract-sources", + "token-bridge-contracts", + DEFAULT_TOKEN_BRIDGE_COMMIT, + ), + dockerContext: `https://github.com/OffchainLabs/token-bridge-contracts.git#${DEFAULT_TOKEN_BRIDGE_COMMIT}`, + identity: `commit:${DEFAULT_TOKEN_BRIDGE_COMMIT}`, + }); + }); +}); + +describe("prepareTokenBridgeSource", () => { + it("reuses a complete managed cache entry", () => { + const { root } = makeProject(); + const source = resolveTokenBridgeSource(root, {}); + makeWorkspace(source.path); + expect(prepareTokenBridgeSource(source)).toMatchObject({ + kind: "managed", + path: source.path, + packageVersion: "1.2.5", + }); + }); + + it("rejects caller-managed workspaces without installed dependencies", () => { + const { parent } = makeProject(); + const workspace = makeWorkspace(path.join(parent, "incomplete"), { dependencies: false }); + expect(() => validateTokenBridgeWorkspace(workspace, { requireDependencies: true })).toThrow( + /node_modules\/ts-node/, + ); + }); + + it("rejects unsupported package families", () => { + const { parent } = makeProject(); + const workspace = makeWorkspace(path.join(parent, "future"), { version: "2.0.0" }); + expect(() => validateTokenBridgeWorkspace(workspace)).toThrow(/Unsupported/); + }); +}); + +describe("Token Bridge Docker sources", () => { + const tokenbridge = fs.readFileSync(path.resolve("docker/tokenbridge.Dockerfile"), "utf8"); + const testnode = fs.readFileSync(path.resolve("docker/testnode.Dockerfile"), "utf8"); + const deployer = fs.readFileSync(path.resolve("docker/contract-deployer.Dockerfile"), "utf8"); + + it("uses named contexts instead of fetching independent refs", () => { + for (const contents of [tokenbridge, testnode]) { + expect(contents).toContain("FROM scratch AS tokenbridge"); + expect(contents).toContain("COPY --from=tokenbridge . /workspace"); + expect(contents).not.toContain("github.com/OffchainLabs/token-bridge-contracts"); + } + }); + + it("does not embed an unused Token Bridge build in the Nitro deployer", () => { + expect(deployer).not.toContain("token-bridge-builder"); + expect(deployer).not.toContain("token-bridge-contracts"); + }); +}); diff --git a/packages/core/test/token-bridge.test.ts b/packages/core/test/token-bridge.test.ts index 4fe2810..343e750 100644 --- a/packages/core/test/token-bridge.test.ts +++ b/packages/core/test/token-bridge.test.ts @@ -207,6 +207,15 @@ describe("deployL2L3TokenBridge", () => { beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "token-bridge-test-")); + fs.mkdirSync(path.join(tmpDir, "token-bridge/node_modules/ts-node/dist"), { + recursive: true, + }); + fs.writeFileSync(path.join(tmpDir, "token-bridge/node_modules/ts-node/dist/bin.js"), ""); + fs.mkdirSync(path.join(tmpDir, "token-bridge/scripts/deployment"), { recursive: true }); + fs.writeFileSync( + path.join(tmpDir, "token-bridge/scripts/deployment/deployTokenBridgeCreator.ts"), + "", + ); vi.clearAllMocks(); mocks.createTokenBridge.mockResolvedValue({ tokenBridgeContracts: mocks.tokenBridgeContracts, @@ -301,6 +310,7 @@ describe("deployL2L3TokenBridge", () => { childRpc: "http://l3node:8547", parentKey: "0x3333333333333333333333333333333333333333333333333333333333333333", childKey: "0x3333333333333333333333333333333333333333333333333333333333333333", + tokenBridgeDir: path.join(tmpDir, "token-bridge"), parentWethOverride: "0x5555555555555555555555555555555555555555", }); @@ -347,6 +357,7 @@ describe("deployL2L3TokenBridge", () => { childRpc: "http://l3node:8547", parentKey: "0x3333333333333333333333333333333333333333333333333333333333333333", childKey: "0x3333333333333333333333333333333333333333333333333333333333333333", + tokenBridgeDir: path.join(tmpDir, "token-bridge"), parentWethOverride: "0x5555555555555555555555555555555555555555", }); @@ -557,6 +568,7 @@ describe("deployL2L3TokenBridge", () => { childRpc: "http://l3node:8547", parentKey: "0x3333333333333333333333333333333333333333333333333333333333333333", childKey: "0x3333333333333333333333333333333333333333333333333333333333333333", + tokenBridgeDir: path.join(tmpDir, "token-bridge"), parentWethOverride: "0x5555555555555555555555555555555555555555", }); From 37f66bde1aea2afc7fd8d42ae3edb7a917f707dc Mon Sep 17 00:00:00 2001 From: dlance Date: Mon, 10 Aug 2026 13:52:48 -0400 Subject: [PATCH 2/4] feat(bake): customize published bundles without rebuilding --- .github/workflows/release-testnode-image.yml | 57 ++++ .github/workflows/test-action.yml | 9 +- README.md | 74 +++-- action.yml | 38 ++- apps/cli/src/commands/bake.ts | 300 ++++++++++--------- apps/cli/src/package-metadata.ts | 2 +- apps/cli/test/bake.test.ts | 77 +++++ apps/cli/test/start.test.ts | 7 +- bake/action.yml | 204 +++++-------- docker/custom-testnode.Dockerfile | 16 + docker/testnode-entrypoint.sh | 3 +- docker/testnode.Dockerfile | 20 ++ packages/action/test/action.test.ts | 84 ++++-- packages/core/src/snapshot-image.ts | 18 +- packages/testnode/src/runtime.mjs | 9 +- scripts/ci/publish-latest-aliases.mjs | 48 +++ 16 files changed, 595 insertions(+), 371 deletions(-) create mode 100644 apps/cli/test/bake.test.ts create mode 100644 docker/custom-testnode.Dockerfile create mode 100644 scripts/ci/publish-latest-aliases.mjs diff --git a/.github/workflows/release-testnode-image.yml b/.github/workflows/release-testnode-image.yml index 97aebf6..4966a6a 100644 --- a/.github/workflows/release-testnode-image.yml +++ b/.github/workflows/release-testnode-image.yml @@ -108,9 +108,11 @@ jobs: # Prepare the same pinned Token Bridge checkout for host deployment and # the baked image's named build context. TOKEN_BRIDGE_LOCAL_DIR: ${{ github.workspace }}/../token-bridge-contracts + TOKEN_BRIDGE_REF: 5975d8f7360816341be7f94fd333ef240f4aec23 TOKEN_BRIDGE_COMMIT: 5975d8f7360816341be7f94fd333ef240f4aec23 TOKEN_BRIDGE_DOCKER_CONTEXT: https://github.com/OffchainLabs/token-bridge-contracts.git#5975d8f7360816341be7f94fd333ef240f4aec23 NITRO_CONTRACTS_LOCAL_DIR: ${{ github.workspace }}/../nitro-contracts + NITRO_CONTRACTS_REF: v3.2.0 NITRO_CONTRACTS_COMMIT: 2695e7b3e3f460531e2b77fed48a60561c54d90e steps: - name: Checkout @@ -357,6 +359,13 @@ jobs: build-contexts: | tokenbridge=${{ env.TOKEN_BRIDGE_DOCKER_CONTEXT }} file: docker/testnode.Dockerfile + build-args: | + BUNDLE_VERSION=${{ needs.resolve-publish-matrix.outputs.version }} + BUNDLE_VARIANT=${{ matrix.variant }} + 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: | @@ -367,3 +376,51 @@ jobs: # 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. + publish-latest-bundle: + if: ${{ github.ref_type == 'tag' }} + needs: [resolve-publish-matrix, publish-testnode-image] + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install crane + env: + CRANE_VERSION: v0.21.9 + run: | + set -euo pipefail + url="https://github.com/google/go-containerregistry/releases/download/${CRANE_VERSION}/go-containerregistry_Linux_x86_64.tar.gz" + curl -fsSL "$url" -o /tmp/crane.tar.gz + tar -xzf /tmp/crane.tar.gz -C /usr/local/bin crane + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Log in to Docker Hub + 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" diff --git a/.github/workflows/test-action.yml b/.github/workflows/test-action.yml index 05da09d..deb1aa3 100644 --- a/.github/workflows/test-action.yml +++ b/.github/workflows/test-action.yml @@ -38,7 +38,14 @@ jobs: run: node scripts/ci/prepare-testnode-context.mjs --variant l3-eth --snapshot-id default - name: Build local testnode image - run: docker build -f docker/testnode.Dockerfile -t local/arbitrum-testnode:${{ github.sha }}-nc3.2-l3-eth . + run: >- + docker build + --build-context tokenbridge=https://github.com/OffchainLabs/token-bridge-contracts.git#5975d8f7360816341be7f94fd333ef240f4aec23 + --build-arg BUNDLE_VERSION=${{ github.sha }} + --build-arg BUNDLE_VARIANT=l3-eth + -f docker/testnode.Dockerfile + -t local/arbitrum-testnode:${{ github.sha }}-nc3.2-l3-eth + . - name: Run action id: action diff --git a/README.md b/README.md index e794d2d..6933aa9 100644 --- a/README.md +++ b/README.md @@ -14,10 +14,10 @@ Minimal usage: pnpm dev start ``` -By default, `start` uses the CLI package version as the image version and resolves the `l3-eth` variant image: +By default, `start` resolves the latest published `l3-eth` bundle: ```text -offchainlabs/arbitrum-litro:v0.2.10-nc3.2-l3-eth +offchainlabs/arbitrum-litro:latest-l3-eth ``` Config-driven usage can pin a different image version: @@ -39,7 +39,7 @@ Optional config fields: | Field | Default | Description | |-------|---------|-------------| -| `version` | CLI package version | Testnode image release version override | +| `version` | `latest` | Published bundle version override | | `l3Enabled` | `true` | Boot the L3-enabled testnode | | `feeTokenDecimals` | — | Custom L3 fee token decimals (`6`, `16`, `18`, `20`) | | `nitroContractsVersion` | `v3.2` | Nitro contracts version tag component | @@ -224,9 +224,9 @@ pnpm dev status # Show service and init state ## Custom snapshots Downstream repos can bake their **own** testnode images: boot the base stack, run a -setup script against it (deploy contracts, seed activity, drop extra files into the -config dir), snapshot the result, and build a runnable Docker image. The stock CLI and -GitHub Action then boot those custom images. +setup script against it (deploy contracts, seed activity, or add exported config), +then commit the customized state as a runnable Docker image. The stock CLI and GitHub +Action then boot those custom images. ### Setup-command environment contract @@ -238,45 +238,37 @@ receives these environment variables: | `ARBITRUM_TESTNODE_L1_RPC_URL` | L1 (Anvil) RPC endpoint (`http://127.0.0.1:8545`) | | `ARBITRUM_TESTNODE_L2_RPC_URL` | L2 (Nitro) RPC endpoint (`http://127.0.0.1:8547`) | | `ARBITRUM_TESTNODE_L3_RPC_URL` | L3 (Orbit) RPC endpoint (`http://127.0.0.1:8549`) | -| `ARBITRUM_TESTNODE_CONFIG_DIR` | Config dir; files written here ride along into the snapshot and config export | +| `ARBITRUM_TESTNODE_CONFIG_DIR` | Exported config dir; files written here ride along into the customized image | | `ARBITRUM_TESTNODE_DEPLOYMENT_JSON` | Path to the exported `deployment.json` in the config dir | A non-zero exit from the setup command aborts the bake with a clear error. ### One-shot local bake -`testnode bake` boots the base stack (by default it restores the installed base -snapshot; `--rebuild` runs a full init instead), runs the setup command, captures a -snapshot, and builds the image: +`testnode bake` boots the latest published bundle, runs the setup command, stops the +stack cleanly so Anvil and Nitro flush their state, and commits the container as the +custom image: ```bash pnpm dev bake \ --setup-command "./scripts/deploy-and-seed.sh" \ --image-ref ghcr.io/acme/arbitrum-testnode:governance \ - --snapshot-id custom \ --push # optional; docker login is your responsibility ``` -New builds use the stable Nitro contracts v3.2.0 release by default. For local -development, point `NITRO_CONTRACTS_LOCAL_DIR` at a Nitro 3.x checkout; a sibling -`../nitro-contracts` checkout is detected automatically: +The bundle composes the runtime, initialized chain state, Nitro contracts, and Token +Bridge contracts. Consumers never clone or rebuild either contracts repository. +Override the published base with `--image-version` or `--base-image-ref`. + +Local base development still supports prepared contracts workspaces through +`NITRO_CONTRACTS_LOCAL_DIR` and `TOKEN_BRIDGE_LOCAL_DIR`: ```bash NITRO_CONTRACTS_LOCAL_DIR=../nitro-contracts pnpm dev init --rebuild -NITRO_CONTRACTS_LOCAL_DIR=../nitro-contracts pnpm dev bake --rebuild \ - --setup-command "./scripts/deploy-and-seed.sh" \ - --image-ref ghcr.io/acme/arbitrum-testnode:governance ``` -The checkout determines the contracts family; `init` and `bake` no longer expose -separate Nitro version or branch selectors. New builds require Nitro 3.x. Existing -published v2.1 images remain available through `start` and the run action. - -Token Bridge contracts use the same source model: set `TOKEN_BRIDGE_LOCAL_DIR` to -a prepared checkout, or use a prepared sibling `../token-bridge-contracts` checkout. -When neither exists, the CLI materializes the compatible default commit in its -ignored `.cache/contract-sources` directory. Host deployment, Compose builds, -and baked images all use the same selected source identity. +Those source settings are used only by `init` and release production. New releases +default to Nitro contracts v3.2.0 and the compatible pinned Token Bridge commit. To bake straight from an existing snapshot (no setup step), use the à-la-carte subcommand: @@ -285,6 +277,9 @@ subcommand: pnpm dev snapshot bake --id custom --image-ref ghcr.io/acme/arbitrum-testnode:governance --push ``` +This path also layers the snapshot onto the latest published bundle; it does not +compile contract sources. + ### CI bake via the composite action The `bake` subdirectory action wraps the same flow. Registry login is the consumer's @@ -302,13 +297,12 @@ job — log in before invoking it: setup-command: ./scripts/deploy-and-seed.sh image-ref: ghcr.io/acme/arbitrum-testnode:governance push: true - github-token: ${{ secrets.GITHUB_TOKEN }} # base snapshot download + github-token: ${{ secrets.GITHUB_TOKEN }} # published bundle pull ``` -By default the action installs a base snapshot release (via `github-token`) and -restores it; set `rebuild: true` to run a full init instead. The action accepts -`nitro-contracts-ref` (default `v3.2.0`), `token-bridge-ref`, and -`fee-token-decimals`. +By default the action uses the latest composed bundle. Set `bundle-version` to pin a +release or `bundle-image-ref` to use another already-published bundle. There is no +consumer rebuild path. ### Booting a custom image @@ -373,9 +367,9 @@ pnpm release 0.2.11 --push # ...and push, starting the publish ``` The `Publish Testnode` workflow publishes automatically when a `v*` tag is pushed. -Tag-triggered publishes use the `default` entry in `config/testnodes.json`, with the -Git tag as the image version. 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 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: ```text ghcr.io//arbitrum-litro:-nc- @@ -389,13 +383,15 @@ GHCR package is private and requires a token. Publishing requires the replace a Docker Hub tag that already exists unless the manual 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. + 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. -The `snapshot-version` workflow input provides the snapshot release tag used for every selected variant. -For automatic tag publishes, the snapshot release tag comes from `config/testnodes.json`. - Publish the default testnode image automatically: ```bash @@ -409,13 +405,13 @@ Publish one variant image from GitHub Actions: workflow: Publish Testnode version: v0.2.3 variant: l3-eth -snapshot-version: v0.1.6 ``` Publish every current catalog entry by setting `variant` to `all`. Existing v2.1 images remain resolvable but are not rebuilt by new releases. -The default Timeboost publish target is `l2-timeboost`, which expects the `l2-timeboost` snapshot ID in the selected snapshot release. It can be published directly with `variant: l2-timeboost` or through the `name: timeboost` entry in `config/testnodes.json`. +The default Timeboost publish target is `l2-timeboost`; publish it directly with +`variant: l2-timeboost` or as part of `all`. ## Init Sequence diff --git a/action.yml b/action.yml index 98d0dd8..bd7e334 100644 --- a/action.yml +++ b/action.yml @@ -1,10 +1,10 @@ name: Run Arbitrum Testnode -description: "Boot a snapshot-backed Arbitrum testnode in GitHub CI and export its config files" +description: "Boot a published Arbitrum testnode bundle in GitHub CI and export its config files" inputs: version: required: false - default: "" - description: "Pinned release version; required unless image-ref is set" + default: "latest" + description: "Published bundle version; defaults to latest" github-token: required: false description: "Token for private GHCR images; not needed for the public Docker Hub default" @@ -95,6 +95,15 @@ outputs: nitro-contracts-version: description: "Resolved nitro contracts version" value: ${{ steps.resolve.outputs.nitro-contracts-version }} + bundle-version: + description: "Published bundle version recorded in the image" + value: ${{ steps.bundle.outputs.bundle-version }} + nitro-contracts-commit: + description: "Nitro contracts commit composed into the bundle" + value: ${{ steps.bundle.outputs.nitro-contracts-commit }} + token-bridge-commit: + description: "Token Bridge contracts commit composed into the bundle" + value: ${{ steps.bundle.outputs.token-bridge-commit }} runs: using: composite steps: @@ -117,13 +126,11 @@ runs: # Registry behavior follows the base ref -- the image actually fetched. With # nitro-image set, the booted ref is a locally built rebase of that base. - name: Log in to GHCR - if: ${{ startsWith(steps.resolve.outputs.base-image-ref, 'ghcr.io/') }} + if: >- + ${{ inputs.github-token != '' && + startsWith(steps.resolve.outputs.base-image-ref, 'ghcr.io/') }} shell: bash run: | - if [ -z "${{ inputs.github-token }}" ]; then - echo "github-token is required for ghcr.io images" >&2 - exit 1 - fi echo "${{ inputs.github-token }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin - name: Pull testnode image @@ -142,6 +149,21 @@ runs: NITRO_IMAGE: ${{ steps.resolve.outputs.nitro-image }} run: node "${{ github.action_path }}/packages/action/src/rebase.mjs" + - name: Read bundle provenance + id: bundle + shell: bash + env: + IMAGE_REF: ${{ steps.resolve.outputs.image-ref }} + run: | + set -euo pipefail + label() { + docker inspect --format '{{json .Config.Labels}}' "$IMAGE_REF" | \ + jq -r --arg key "$1" '.[$key] // ""' + } + echo "bundle-version=$(label io.arbitrum.testnode.bundle.version)" >> "$GITHUB_OUTPUT" + echo "nitro-contracts-commit=$(label io.arbitrum.testnode.nitro-contracts.commit)" >> "$GITHUB_OUTPUT" + echo "token-bridge-commit=$(label io.arbitrum.testnode.token-bridge.commit)" >> "$GITHUB_OUTPUT" + - name: Boot testnode image shell: bash env: diff --git a/apps/cli/src/commands/bake.ts b/apps/cli/src/commands/bake.ts index c4e5776..ac19a4c 100644 --- a/apps/cli/src/commands/bake.ts +++ b/apps/cli/src/commands/bake.ts @@ -1,113 +1,54 @@ import { spawnSync } from "node:child_process"; -import { rmSync } from "node:fs"; import { resolve } from "node:path"; -import { waitForRpc } from "@arbitrum/testnode-core/docker.js"; -import { createInitContext, runInitCommand } from "@arbitrum/testnode-core/init-runner.js"; import { - startL1Container, - startNitroFromSnapshot, - stopRuntime, -} from "@arbitrum/testnode-core/runtime.js"; -import { bakeSnapshotImage } from "@arbitrum/testnode-core/snapshot-image.js"; -import { - DEFAULT_SNAPSHOT_ID, - captureSnapshot, - hasSnapshot, - restoreSnapshot, - verifySnapshotSemanticState, - withSnapshotReplacementRollback, -} from "@arbitrum/testnode-core/snapshot.js"; + bootTestnode, + buildStartTestnodeState, + collectContainerDiagnostics, + removeContainer, + runDocker, +} from "@arbitrum/testnode"; +import { verifySnapshotSemanticState } from "@arbitrum/testnode-core/snapshot.js"; import { Cli, z } from "incur"; -import { projectRoot } from "../project-root.js"; - -const PROJECT_NAME = "arbitrum-testnode"; - -const RPCS = { - l1: "http://127.0.0.1:8545", - l2: "http://127.0.0.1:8547", - l3: "http://127.0.0.1:8549", -} as const; - -const bakeOptions = z.object({ - setupCommand: z - .string() - .describe("Shell command run against the booted base stack to customize it"), - setupWorkingDirectory: z - .string() - .optional() - .describe("Working directory for the setup command (default: current directory)"), - imageRef: z - .string() - .describe("Full image reference including tag (e.g. ghcr.io/acme/testnode:custom)"), - snapshotId: z - .string() - .optional() - .describe("Snapshot id the customized stack is captured under (default: custom)"), - push: z.boolean().optional().describe("docker push the baked image"), - rebuild: z - .boolean() - .optional() - .describe("Run a full init instead of restoring the installed default snapshot"), - baseSnapshotId: z - .string() - .optional() - .describe("Base snapshot to restore when not rebuilding (default: default)"), - feeTokenDecimals: z - .number() - .optional() - .describe("Custom fee token decimals (6, 16, 18, or 20) for a rebuild init"), - timeboostEnabled: z.boolean().optional().describe("Enable Timeboost for a rebuild init"), -}); -function rebuildInitOptions(options: z.infer) { - return { - rebuild: true, - ...(options.feeTokenDecimals !== undefined - ? { feeTokenDecimals: options.feeTokenDecimals } - : {}), - ...(options.timeboostEnabled !== undefined - ? { timeboostEnabled: options.timeboostEnabled } - : {}), - }; +interface RpcUrls { + l1: string; + l2: string; + l3: string; } -/** - * Boot the base stack the customization runs against. `--rebuild` runs a full - * init; otherwise the installed base snapshot is restored and started, mirroring - * `snapshot restore`. - */ -async function ensureBaseStack( - root: string, - configDir: string, - composeFile: string, - options: z.infer, -): Promise { - if (options.rebuild) { - await runInitCommand(rebuildInitOptions(options), createInitContext(root)); - return; - } +interface BundleBakeOptions { + baseImageRef?: string | undefined; + feeTokenDecimals?: number | undefined; + imageRef: string; + imageRepository?: string | undefined; + imageVersion?: string | undefined; + l3Enabled?: boolean | undefined; + outputDir?: string | undefined; + push?: boolean | undefined; + setupCommand: string; + setupWorkingDirectory?: string | undefined; + startupTimeoutSeconds?: number | undefined; + timeboostEnabled?: boolean | undefined; +} - const baseSnapshotId = options.baseSnapshotId ?? DEFAULT_SNAPSHOT_ID; - if (!hasSnapshot(configDir, baseSnapshotId)) { - throw new Error( - `No base snapshot '${baseSnapshotId}' installed; install one first or pass --rebuild`, - ); - } - stopRuntime({ composeFile, projectName: PROJECT_NAME, configDir }); - restoreSnapshot(configDir, baseSnapshotId); - startL1Container({ composeFile, projectName: PROJECT_NAME, configDir }); - await waitForRpc(RPCS.l1); - await startNitroFromSnapshot({ composeFile, projectName: PROJECT_NAME, configDir }, RPCS); +interface BundleBakeDependencies { + boot: typeof bootTestnode; + remove: typeof removeContainer; + runDocker: typeof runDocker; + verify: typeof verifySnapshotSemanticState; } -/** - * Run the downstream customization on the host. The stack is already booted, so - * the command sees live L1/L2 RPCs and can write extra files into the config dir - * (they ride along into the snapshot + config export). - */ +const defaultDependencies: BundleBakeDependencies = { + boot: bootTestnode, + remove: removeContainer, + runDocker, + verify: verifySnapshotSemanticState, +}; + function runSetupCommand( setupCommand: string, configDir: string, + rpcUrls: RpcUrls, setupWorkingDirectory?: string, ): void { const result = spawnSync(setupCommand, { @@ -116,9 +57,9 @@ function runSetupCommand( ...(setupWorkingDirectory ? { cwd: resolve(setupWorkingDirectory) } : {}), env: { ...process.env, - ARBITRUM_TESTNODE_L1_RPC_URL: RPCS.l1, - ARBITRUM_TESTNODE_L2_RPC_URL: RPCS.l2, - ARBITRUM_TESTNODE_L3_RPC_URL: RPCS.l3, + ARBITRUM_TESTNODE_L1_RPC_URL: rpcUrls.l1, + ARBITRUM_TESTNODE_L2_RPC_URL: rpcUrls.l2, + ARBITRUM_TESTNODE_L3_RPC_URL: rpcUrls.l3, ARBITRUM_TESTNODE_CONFIG_DIR: configDir, ARBITRUM_TESTNODE_DEPLOYMENT_JSON: resolve(configDir, "deployment.json"), }, @@ -134,52 +75,127 @@ function runSetupCommand( } } -export const bakeCli = Cli.create("bake", { - description: - "Boot the base stack, run a setup command against it, snapshot the result, and build an image", - options: bakeOptions, - async run(c) { - const root = projectRoot(); - const configDir = resolve(root, "config"); - const composeFile = resolve(root, "docker/docker-compose.yaml"); - const snapshotId = c.options.snapshotId ?? "custom"; +function resolveBundleState(options: BundleBakeOptions) { + return buildStartTestnodeState({ + cwd: process.cwd(), + feeTokenDecimals: options.feeTokenDecimals, + imageRef: options.baseImageRef, + imageRepository: options.imageRepository, + l3Enabled: options.l3Enabled ?? true, + outputDir: options.outputDir, + timeboostEnabled: options.timeboostEnabled, + version: options.imageVersion?.trim() || "latest", + }); +} - const contextDir = resolve(root, ".testnode-context"); +function pullBundle(imageRef: string, deps: BundleBakeDependencies): void { + if (!imageRef.startsWith("local/")) { + deps.runDocker(["pull", imageRef], { stdio: "inherit" }); + } +} + +function commitBundle( + containerName: string, + baseImageRef: string, + imageRef: string, + deps: BundleBakeDependencies, +): void { + deps.runDocker(["stop", "--timeout", "30", containerName], { stdio: "inherit" }); + deps.runDocker( + [ + "commit", + "--change", + `LABEL io.arbitrum.testnode.bundle.parent=${baseImageRef}`, + containerName, + imageRef, + ], + { stdio: "inherit" }, + ); +} + +function logBakeDiagnostics(containerName: string): void { + const diagnostics = collectContainerDiagnostics(containerName); + if (diagnostics.inspect) { + console.error(`[bake] container: ${diagnostics.inspect}`); + } + if (diagnostics.logs) { + console.error(`[bake] logs:\n${diagnostics.logs}`); + } +} - try { - await ensureBaseStack(root, configDir, composeFile, c.options); - runSetupCommand(c.options.setupCommand, configDir, c.options.setupWorkingDirectory); +/** + * Customize an already-baked testnode bundle. The container's writable layer + * is the bundle: Anvil persists its state file and Nitro persists its databases + * under /opt/arbitrum-testnode/runtime. A clean stop followed by docker commit + * produces a derived image without rebuilding any contract source. + */ +export async function runBundleBake( + options: BundleBakeOptions, + deps: BundleBakeDependencies = defaultDependencies, +) { + const state = resolveBundleState(options); + const timeoutMs = (options.startupTimeoutSeconds ?? 300) * 1000; - // Verify while the customized stack is live, then stop it before - // exporting volumes so the snapshot is internally consistent. - await verifySnapshotSemanticState(configDir, RPCS); - stopRuntime({ composeFile, projectName: PROJECT_NAME, configDir }); - const result = await withSnapshotReplacementRollback(configDir, snapshotId, () => { - captureSnapshot(configDir, composeFile, snapshotId); - return bakeSnapshotImage({ - configDir, - snapshotId, - imageRef: c.options.imageRef, - projectRoot: root, - ...(c.options.push !== undefined ? { push: c.options.push } : {}), - }); - }); + try { + pullBundle(state.imageRef, deps); + deps.boot(state, timeoutMs); + runSetupCommand( + options.setupCommand, + state.configDir, + state.rpcUrls, + options.setupWorkingDirectory, + ); + if (state.variantDefinition.l3Enabled) { + await deps.verify(state.configDir, state.rpcUrls); + } - return { - success: true, - snapshotId, - imageRef: result.imageRef, - l3Enabled: result.l3Enabled, - pushed: result.pushed, - contextDir: result.contextDir, - }; - } catch (error) { - rmSync(contextDir, { recursive: true, force: true }); - throw error; - } finally { - // Setup commands are arbitrary trusted build code. Always tear down the - // shared runtime, including when setup or semantic verification fails. - stopRuntime({ composeFile, projectName: PROJECT_NAME, configDir }); + // Files written by setup-command are part of the bundle's exported config. + deps.runDocker([ + "cp", + `${state.configDir}/.`, + `${state.containerName}:/opt/arbitrum-testnode/export-config`, + ]); + // A graceful stop flushes Anvil's --state file and Nitro's databases before + // docker commit captures the container layer. + commitBundle(state.containerName, state.imageRef, options.imageRef, deps); + if (options.push) { + deps.runDocker(["push", options.imageRef], { stdio: "inherit" }); } + return { + success: true, + baseImageRef: state.imageRef, + imageRef: options.imageRef, + pushed: options.push ?? false, + variant: state.variant, + }; + } catch (error) { + logBakeDiagnostics(state.containerName); + throw error; + } finally { + deps.remove(state.containerName); + } +} + +export const bakeCli = Cli.create("bake", { + description: "Customize a published testnode bundle and commit it as a new image", + options: z.object({ + baseImageRef: z.string().optional().describe("Published bundle image override"), + feeTokenDecimals: z + .number() + .optional() + .describe("Custom fee token decimals (6, 16, 18, or 20)"), + imageRef: z.string().describe("Full output image reference including tag"), + imageRepository: z.string().optional().describe("Published bundle image repository"), + imageVersion: z.string().optional().describe("Published bundle version (default: latest)"), + l3Enabled: z.boolean().optional().describe("Use an L3-enabled bundle (default: true)"), + outputDir: z.string().optional().describe("Temporary exported-config directory"), + push: z.boolean().optional().describe("Push the customized image"), + setupCommand: z.string().describe("Shell command run against the booted bundle"), + setupWorkingDirectory: z.string().optional().describe("Setup command working directory"), + startupTimeoutSeconds: z.number().optional().describe("Bundle startup timeout (default: 300)"), + timeboostEnabled: z.boolean().optional().describe("Use the L2 Timeboost bundle"), + }), + async run(c) { + return runBundleBake(c.options); }, }); diff --git a/apps/cli/src/package-metadata.ts b/apps/cli/src/package-metadata.ts index 771075d..0b225c3 100644 --- a/apps/cli/src/package-metadata.ts +++ b/apps/cli/src/package-metadata.ts @@ -15,4 +15,4 @@ function readPackageMetadata(): PackageMetadata { } export const PACKAGE_METADATA = readPackageMetadata(); -export const DEFAULT_START_IMAGE_VERSION = `v${PACKAGE_METADATA.version}`; +export const DEFAULT_START_IMAGE_VERSION = "latest"; diff --git a/apps/cli/test/bake.test.ts b/apps/cli/test/bake.test.ts new file mode 100644 index 0000000..60387d1 --- /dev/null +++ b/apps/cli/test/bake.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it, vi } from "vitest"; +import { runBundleBake } from "../src/commands/bake.js"; + +function dependencies(commands: string[][]) { + return { + boot: vi.fn(() => []), + remove: vi.fn(), + runDocker: vi.fn((args: string[]) => { + commands.push(args); + return ""; + }), + verify: vi.fn(async () => undefined), + }; +} + +describe("runBundleBake", () => { + it("customizes and commits an explicit local bundle without rebuilding sources", async () => { + const commands: string[][] = []; + const deps = dependencies(commands); + + const result = await runBundleBake( + { + baseImageRef: "local/base:test", + imageRef: "local/custom:test", + push: true, + setupCommand: "true", + }, + deps, + ); + + expect(result).toMatchObject({ + baseImageRef: "local/base:test", + imageRef: "local/custom:test", + pushed: true, + variant: "l3-eth", + }); + expect(commands.some(([command]) => command === "pull")).toBe(false); + expect(commands.map(([command]) => command)).toEqual(["cp", "stop", "commit", "push"]); + expect(commands.flat()).not.toContain("build"); + expect(commands.flat()).not.toContain("git"); + expect(deps.remove).toHaveBeenCalledOnce(); + }); + + it("pulls the latest composed bundle by default", async () => { + const commands: string[][] = []; + const deps = dependencies(commands); + + const result = await runBundleBake( + { imageRef: "local/custom:test", setupCommand: "true" }, + deps, + ); + + expect(result.baseImageRef).toBe("ghcr.io/offchainlabs/arbitrum-testnode-ci:latest-l3-eth"); + expect(commands[0]).toEqual([ + "pull", + "ghcr.io/offchainlabs/arbitrum-testnode-ci:latest-l3-eth", + ]); + }); + + it("supports an L2 bundle without running L3 semantic checks", async () => { + const commands: string[][] = []; + const deps = dependencies(commands); + + const result = await runBundleBake( + { + baseImageRef: "local/base:l2", + imageRef: "local/custom:l2", + l3Enabled: false, + setupCommand: "true", + }, + deps, + ); + + expect(result.variant).toBe("l2"); + expect(deps.verify).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/cli/test/start.test.ts b/apps/cli/test/start.test.ts index 579b148..a8a2046 100644 --- a/apps/cli/test/start.test.ts +++ b/apps/cli/test/start.test.ts @@ -19,15 +19,12 @@ describe("resolveStartInput", () => { expect(resolved.version).toBe(DEFAULT_START_IMAGE_VERSION); }); - it("derives the built-in default image version from the CLI package version", () => { - const cliPackage = JSON.parse(fs.readFileSync("apps/cli/package.json", "utf-8")) as { - version?: string; - }; + it("uses the latest published bundle by default", () => { const catalog = JSON.parse(fs.readFileSync("config/testnodes.json", "utf-8")) as { testnodes?: { default?: { version?: string } }; }; - expect(DEFAULT_START_IMAGE_VERSION).toBe(`v${cliPackage.version}`); + expect(DEFAULT_START_IMAGE_VERSION).toBe("latest"); expect(catalog.testnodes?.default?.version).toBeUndefined(); }); diff --git a/bake/action.yml b/bake/action.yml index 62a11c9..d3d0bf9 100644 --- a/bake/action.yml +++ b/bake/action.yml @@ -1,71 +1,58 @@ name: Bake Custom Arbitrum Testnode Image description: >- - Boot a base Arbitrum testnode, run a downstream setup command against it, - snapshot the result, and build (optionally push) a runnable custom image. + Boot a published Arbitrum testnode bundle, run a downstream setup command, + and commit the customized state as a new image. inputs: setup-command: required: true description: >- - Shell command run against the booted base stack. Receives - ARBITRUM_TESTNODE_L1_RPC_URL, ARBITRUM_TESTNODE_L2_RPC_URL, - ARBITRUM_TESTNODE_L3_RPC_URL, ARBITRUM_TESTNODE_CONFIG_DIR and - ARBITRUM_TESTNODE_DEPLOYMENT_JSON in its environment. + Shell command run against the booted bundle. Receives the testnode RPC, + config, and deployment paths in its environment. image-ref: required: true - description: "Full image reference incl. tag to build (e.g. ghcr.io/acme/testnode:custom)" + description: "Full output image reference including tag" push: required: false default: "true" - description: "docker push the baked image (registry login is the caller's responsibility)" - snapshot-id: + description: "Push the customized image" + bundle-version: required: false - default: "custom" - description: "Snapshot id the customized stack is captured under" - rebuild: + default: "latest" + description: "Published bundle version; defaults to the latest bundle" + bundle-image-repository: required: false - default: "false" - description: "Run a full init for the base stack instead of installing/restoring a base snapshot" - base-snapshot-id: - required: false - default: "default" - description: "Base snapshot id to install and restore when not rebuilding" - snapshot-repo: - required: false - default: "OffchainLabs/arbitrum-litro" - description: "owner/repo hosting base snapshot releases (used when not rebuilding)" - version: + default: "offchainlabs/arbitrum-litro" + description: "Container repository hosting published bundles" + bundle-image-ref: required: false default: "" - description: "Base snapshot release tag to install (defaults to latest) when not rebuilding" - nitro-contracts-ref: + description: "Explicit published bundle image override" + l3-enabled: required: false - default: "v3.2.0" - description: >- - Nitro contracts release, branch, or commit used during a rebuild init. - token-bridge-ref: + default: "true" + description: "Use the L3 ETH bundle" + timeboost-enabled: required: false - default: "5975d8f7360816341be7f94fd333ef240f4aec23" - description: >- - Token Bridge contracts release, branch, or commit used by init and the baked image. + default: "false" + description: "Use the L2 Timeboost bundle" fee-token-decimals: required: false default: "" - description: >- - Custom L3 fee-token decimals (6, 16, 18, or 20) for a rebuild init. - Empty = ETH-fee (no custom fee token). + description: "Custom L3 fee-token decimals (6, 16, 18, or 20)" + startup-timeout-seconds: + required: false + default: "300" + description: "Maximum time to wait for the published bundle to start" github-token: required: false default: "" - description: "Token used to download base snapshot releases (the snapshot-install path)" + description: "Token used to pull a private GHCR bundle" runs: using: composite steps: - name: Setup Node.js uses: actions/setup-node@v4 with: - # Node 20 abandons an unsettled promise as a silent exit-13 (losing all - # output) in the bake's async restore path; 22 propagates errors - # normally. Node 20 is also deprecated on GitHub runners. node-version: 22 - name: Setup pnpm @@ -78,110 +65,55 @@ runs: pnpm install --frozen-lockfile pnpm build - - name: Prepare Token Bridge contracts checkout + - name: Log in to GHCR for the base bundle + if: >- + ${{ inputs.github-token != '' && + (startsWith(inputs.bundle-image-ref, 'ghcr.io/') || + (inputs.bundle-image-ref == '' && startsWith(inputs.bundle-image-repository, 'ghcr.io/'))) }} shell: bash - env: - TOKEN_BRIDGE_REF_INPUT: ${{ inputs.token-bridge-ref }} - TOKEN_BRIDGE_LOCAL_DIR: ${{ runner.temp }}/token-bridge-contracts - run: | - set -euxo pipefail - npm install -g yarn@1.22.22 - mkdir -p "$TOKEN_BRIDGE_LOCAL_DIR" - cd "$TOKEN_BRIDGE_LOCAL_DIR" - git init . - git remote add origin https://github.com/OffchainLabs/token-bridge-contracts.git - git fetch --depth 1 origin "$TOKEN_BRIDGE_REF_INPUT" - git checkout --detach FETCH_HEAD - git submodule update --init --recursive --depth 1 - TOKEN_BRIDGE_RESOLVED_COMMIT="$(git rev-parse HEAD)" - yarn install --frozen-lockfile - yarn build - test -f node_modules/ts-node/dist/bin.js - test -f scripts/deployment/deployTokenBridgeCreator.ts - echo "TOKEN_BRIDGE_LOCAL_DIR=$TOKEN_BRIDGE_LOCAL_DIR" >> "$GITHUB_ENV" - echo "TOKEN_BRIDGE_DOCKER_CONTEXT=https://github.com/OffchainLabs/token-bridge-contracts.git#$TOKEN_BRIDGE_RESOLVED_COMMIT" >> "$GITHUB_ENV" - - - name: Install base snapshot - if: ${{ inputs.rebuild != 'true' }} - shell: bash - working-directory: ${{ github.action_path }}/.. - env: - GITHUB_TOKEN: ${{ inputs.github-token }} - TESTNODE_SNAPSHOT_GH_REPO: ${{ inputs.snapshot-repo }} run: | set -euo pipefail - node apps/cli/dist/index.js snapshot install \ - --id "${{ inputs.base-snapshot-id }}" \ - --force \ - ${{ inputs.version != '' && format('--release-tag {0}', inputs.version) || '' }} + echo "${{ inputs.github-token }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin - # No base-snapshot release exists for the default variant, so rebuild the - # base stack from scratch and capture it locally. init needs a - # token-bridge-contracts checkout with installed deps (it deploys the bridge - # via ts-node) and is intermittently flaky on cold runners, so provision the - # pinned checkout and retry until the snapshot manifest lands. The - # subsequent bake then restores this local snapshot — isolating the flaky - # init from the expensive setup-command. - - name: Rebuild and capture base snapshot - if: ${{ inputs.rebuild == 'true' }} + - name: Bake customized bundle shell: bash working-directory: ${{ github.action_path }}/.. env: - BASE_SNAPSHOT_ID_INPUT: ${{ inputs.base-snapshot-id }} - FEE_TOKEN_DECIMALS_INPUT: ${{ inputs.fee-token-decimals }} - NITRO_CONTRACTS_REF_INPUT: ${{ inputs.nitro-contracts-ref }} - NITRO_CONTRACTS_DEFAULT_COMMIT: 2695e7b3e3f460531e2b77fed48a60561c54d90e - NITRO_CONTRACTS_LOCAL_DIR: ${{ runner.temp }}/nitro-contracts - TOKEN_BRIDGE_LOCAL_DIR: ${{ runner.temp }}/token-bridge-contracts + BASE_IMAGE_REF: ${{ inputs.bundle-image-ref }} + BASE_IMAGE_REPOSITORY: ${{ inputs.bundle-image-repository }} + BASE_IMAGE_VERSION: ${{ inputs.bundle-version }} + FEE_TOKEN_DECIMALS: ${{ inputs.fee-token-decimals }} + L3_ENABLED: ${{ inputs.l3-enabled }} + OUTPUT_IMAGE_REF: ${{ inputs.image-ref }} + PUSH_IMAGE: ${{ inputs.push }} + SETUP_COMMAND: ${{ inputs.setup-command }} + STARTUP_TIMEOUT_SECONDS: ${{ inputs.startup-timeout-seconds }} + TIMEBOOST_ENABLED: ${{ inputs.timeboost-enabled }} run: | - set -euxo pipefail - if [ ! -f "$NITRO_CONTRACTS_LOCAL_DIR/package.json" ]; then - mkdir -p "$NITRO_CONTRACTS_LOCAL_DIR" - ( - cd "$NITRO_CONTRACTS_LOCAL_DIR" - git init . - git remote add origin https://github.com/OffchainLabs/nitro-contracts.git - git fetch --depth 1 origin "$NITRO_CONTRACTS_REF_INPUT" - git checkout --detach FETCH_HEAD - git submodule update --init --recursive --depth 1 - if [ "$NITRO_CONTRACTS_REF_INPUT" = "v3.2.0" ]; then - test "$(git rev-parse HEAD)" = "$NITRO_CONTRACTS_DEFAULT_COMMIT" - fi - ) + set -euo pipefail + args=( + --setup-command "$SETUP_COMMAND" + --setup-working-directory "${{ github.workspace }}" + --image-ref "$OUTPUT_IMAGE_REF" + --image-repository "$BASE_IMAGE_REPOSITORY" + --image-version "$BASE_IMAGE_VERSION" + --startup-timeout-seconds "$STARTUP_TIMEOUT_SECONDS" + ) + if [ -n "$BASE_IMAGE_REF" ]; then + args+=(--base-image-ref "$BASE_IMAGE_REF") fi - base="$BASE_SNAPSHOT_ID_INPUT" - init_args=(--rebuild --capture-id "$base" --skip-post-capture-verify) - if [ -n "$FEE_TOKEN_DECIMALS_INPUT" ]; then - init_args+=(--fee-token-decimals "$FEE_TOKEN_DECIMALS_INPUT") + if [ -n "$FEE_TOKEN_DECIMALS" ]; then + args+=(--fee-token-decimals "$FEE_TOKEN_DECIMALS") fi - for attempt in 1 2 3; do - if node apps/cli/dist/index.js init "${init_args[@]}" \ - && test -f "config/snapshots/$base/manifest.json"; then - echo "base snapshot '$base' captured" - exit 0 - fi - echo "init attempt $attempt did not capture; cleaning and retrying" - node apps/cli/dist/index.js clean || true - done - echo "base init failed to capture after 3 attempts" >&2 - exit 1 - - - name: Bake custom testnode image - shell: bash - working-directory: ${{ github.action_path }}/.. - env: - GITHUB_TOKEN: ${{ inputs.github-token }} - TESTNODE_SNAPSHOT_GH_REPO: ${{ inputs.snapshot-repo }} - # Booting nitro from the restored snapshot is slower on cold CI runners - # than the default 120s budget. - TESTNODE_RPC_TIMEOUT_MS: "300000" - TOKEN_BRIDGE_LOCAL_DIR: ${{ runner.temp }}/token-bridge-contracts - run: | - set -euo pipefail - node apps/cli/dist/index.js bake \ - --setup-command "${{ inputs.setup-command }}" \ - --setup-working-directory "${{ github.workspace }}" \ - --image-ref "${{ inputs.image-ref }}" \ - --snapshot-id "${{ inputs.snapshot-id }}" \ - --base-snapshot-id "${{ inputs.base-snapshot-id }}" \ - ${{ inputs.push == 'true' && '--push' || '' }} + if [ "$L3_ENABLED" = "true" ]; then + args+=(--l3-enabled) + else + args+=(--no-l3-enabled) + fi + if [ "$TIMEBOOST_ENABLED" = "true" ]; then + args+=(--timeboost-enabled) + fi + if [ "$PUSH_IMAGE" = "true" ]; then + args+=(--push) + fi + node apps/cli/dist/index.js bake "${args[@]}" diff --git a/docker/custom-testnode.Dockerfile b/docker/custom-testnode.Dockerfile new file mode 100644 index 0000000..b633b60 --- /dev/null +++ b/docker/custom-testnode.Dockerfile @@ -0,0 +1,16 @@ +ARG BASE_IMAGE +FROM ${BASE_IMAGE} + +ARG BASE_IMAGE +LABEL io.arbitrum.testnode.bundle.parent="${BASE_IMAGE}" + +USER root +RUN rm -rf \ + /opt/arbitrum-testnode/export-config \ + /opt/arbitrum-testnode/runtime \ + /opt/arbitrum-testnode/runtime-config +COPY --chown=user:user .testnode-context/export-config /opt/arbitrum-testnode/export-config +COPY .testnode-context/metadata.json /opt/arbitrum-testnode/metadata.json +COPY --chown=user:user .testnode-context/runtime /opt/arbitrum-testnode/runtime +COPY --chown=user:user .testnode-context/runtime-config /opt/arbitrum-testnode/runtime-config +USER user diff --git a/docker/testnode-entrypoint.sh b/docker/testnode-entrypoint.sh index 413365e..aa640df 100644 --- a/docker/testnode-entrypoint.sh +++ b/docker/testnode-entrypoint.sh @@ -76,7 +76,8 @@ start_background /usr/local/bin/anvil \ --block-time 1 \ --chain-id 1337 \ --mnemonic "indoor dish desk flag debris potato excuse depart ticket judge file exit" \ - --load-state "$DATA_ROOT/anvil-state" + --state "$DATA_ROOT/anvil-state/state.json" \ + --state-interval 1 echo "waiting for anvil on port 8545..." DEADLINE=$(($(date +%s) + 60)) diff --git a/docker/testnode.Dockerfile b/docker/testnode.Dockerfile index 1b9a7c5..03e6354 100644 --- a/docker/testnode.Dockerfile +++ b/docker/testnode.Dockerfile @@ -1,6 +1,12 @@ ARG NODE_IMAGE=node:20-bullseye-slim ARG FOUNDRY_IMAGE=ghcr.io/foundry-rs/foundry:v1.3.5 ARG NITRO_IMAGE=offchainlabs/nitro-node:v3.9.5-66e42c4 +ARG BUNDLE_VERSION=dev +ARG BUNDLE_VARIANT=unknown +ARG NITRO_CONTRACTS_REF=v3.2.0 +ARG NITRO_CONTRACTS_COMMIT=2695e7b3e3f460531e2b77fed48a60561c54d90e +ARG TOKENBRIDGE_REF=5975d8f7360816341be7f94fd333ef240f4aec23 +ARG TOKENBRIDGE_COMMIT=5975d8f7360816341be7f94fd333ef240f4aec23 FROM scratch AS tokenbridge @@ -27,6 +33,20 @@ FROM ${FOUNDRY_IMAGE} AS foundry FROM ${NITRO_IMAGE} +ARG BUNDLE_VERSION +ARG BUNDLE_VARIANT +ARG NITRO_CONTRACTS_REF +ARG NITRO_CONTRACTS_COMMIT +ARG TOKENBRIDGE_REF +ARG TOKENBRIDGE_COMMIT + +LABEL 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}" \ + io.arbitrum.testnode.token-bridge.ref="${TOKENBRIDGE_REF}" \ + io.arbitrum.testnode.token-bridge.commit="${TOKENBRIDGE_COMMIT}" + COPY --from=foundry /usr/local/bin/anvil /usr/local/bin/anvil COPY --from=token-bridge-contracts /usr/local/bin/node /usr/local/bin/node COPY --from=token-bridge-contracts /opt/yarn-v1.22.22 /opt/yarn-v1.22.22 diff --git a/packages/action/test/action.test.ts b/packages/action/test/action.test.ts index 0057c05..c0de753 100644 --- a/packages/action/test/action.test.ts +++ b/packages/action/test/action.test.ts @@ -18,10 +18,11 @@ describe("action metadata", () => { const action = readFileSync("action.yml", "utf-8"); const inputs = action.slice(action.indexOf("inputs:"), action.indexOf("outputs:")); - it("makes version optional and resolves registry behavior from the base image ref", () => { - expect(action).toContain( - 'description: "Pinned release version; required unless image-ref is set"', - ); + it("defaults to the latest bundle and resolves registry behavior from the base image ref", () => { + expect(action).toContain('description: "Published bundle version; defaults to latest"'); + expect(action).toContain('default: "latest"'); + // Gating reads baseImageRef, not the booted ref: with nitro-image set the + // booted ref is local, and the pull still has to act on the remote source. expect(action).toContain("startsWith(steps.resolve.outputs.base-image-ref, 'ghcr.io/')"); expect(action).toContain("!startsWith(steps.resolve.outputs.base-image-ref, 'local/')"); }); @@ -100,26 +101,50 @@ describe("action metadata", () => { describe("bake action metadata", () => { const action = readFileSync("bake/action.yml", "utf-8"); - it("defaults the snapshot repo to the canonical (post-rename) name", () => { - // The old name only works via GitHub's rename redirect, which dies the - // moment any repo reclaims it. - expect(action).toContain('default: "OffchainLabs/arbitrum-litro"'); - expect(action).not.toContain("OffchainLabs/arbitrum-testnode"); + it("defaults to the latest published bundle", () => { + expect(action).toContain("bundle-version:"); + expect(action).toContain('default: "latest"'); + expect(action).toContain("BASE_IMAGE_VERSION: ${{ inputs.bundle-version }}"); + expect(action).toContain('node apps/cli/dist/index.js bake "${args[@]}"'); + }); + + it("defaults the bundle repository to the public one, so no token is needed", () => { + expect(action).toContain(`default: "${DEFAULT_TESTNODE_IMAGE_REPOSITORY}"`); }); - it("prepares the selected Nitro source without passing a version selector to init", () => { - expect(action).toContain('default: "v3.2.0"'); - expect(action).toContain("NITRO_CONTRACTS_REF_INPUT: ${{ inputs.nitro-contracts-ref }}"); - expect(action).toContain("NITRO_CONTRACTS_LOCAL_DIR: ${{ runner.temp }}/nitro-contracts"); - expect(action).not.toContain("--nitro-contracts-version"); - expect(action).not.toContain("--nitro-contracts-branch"); - expect(action).toContain('node apps/cli/dist/index.js init "${init_args[@]}"'); + it("never rebuilds contract sources in the consumer bake", () => { + expect(action).not.toContain("node apps/cli/dist/index.js init"); + expect(action).not.toContain("git fetch"); + expect(action).not.toContain("yarn build"); + expect(action).not.toContain("token-bridge-ref"); + expect(action).not.toContain("nitro-contracts-ref"); }); - it("uses one selected Token Bridge checkout for init and image baking", () => { - expect(action).toContain("TOKEN_BRIDGE_REF_INPUT: ${{ inputs.token-bridge-ref }}"); - expect(action).toContain("TOKEN_BRIDGE_LOCAL_DIR: ${{ runner.temp }}/token-bridge-contracts"); - expect(action).toContain('git fetch --depth 1 origin "$TOKEN_BRIDGE_REF_INPUT"'); + it("only authenticates GHCR pulls when a token is supplied", () => { + expect(action).toContain("inputs.github-token != ''"); + }); +}); + +describe("published bundle metadata", () => { + const dockerfile = readFileSync("docker/testnode.Dockerfile", "utf-8"); + const entrypoint = readFileSync("docker/testnode-entrypoint.sh", "utf-8"); + const workflow = readFileSync(".github/workflows/release-testnode-image.yml", "utf-8"); + + it("records exact contract provenance and bundle identity", () => { + expect(dockerfile).toContain("io.arbitrum.testnode.bundle.version"); + expect(dockerfile).toContain("io.arbitrum.testnode.nitro-contracts.commit"); + expect(dockerfile).toContain("io.arbitrum.testnode.token-bridge.commit"); + }); + + it("persists Anvil state for derived bundle commits", () => { + expect(entrypoint).toContain('--state "$DATA_ROOT/anvil-state/state.json"'); + expect(entrypoint).toContain("--state-interval 1"); + }); + + it("publishes latest aliases only after every release image succeeds", () => { + expect(workflow).toContain("publish-latest-bundle:"); + expect(workflow).toContain("needs: [resolve-publish-matrix, publish-testnode-image]"); + expect(workflow).toContain("docker buildx imagetools create --tag"); }); }); @@ -161,6 +186,12 @@ describe("buildTestnodeImageRef", () => { buildTestnodeImageRef({ contractsVersion: "v3.2", variant: "l3-eth", version: "v1.2.3" }), ).toBe(`${DEFAULT_TESTNODE_IMAGE_REPOSITORY}:v1.2.3-nc3.2-l3-eth`); }); + + it("resolves latest without coupling the consumer to a contracts family", () => { + expect( + buildTestnodeImageRef({ contractsVersion: "v3.2", variant: "l3-eth", version: "latest" }), + ).toBe(`${DEFAULT_TESTNODE_IMAGE_REPOSITORY}:latest-l3-eth`); + }); }); describe("buildActionTestnodeState", () => { @@ -331,13 +362,12 @@ describe("buildActionTestnodeState", () => { expect(state.imageRef).toBe(state.baseImageRef); }); - it("requires version when image-ref is omitted", () => { - expect(() => - buildActionTestnodeState({ - l3Enabled: "false", - runnerTemp: "/tmp/runner", - }), - ).toThrow("version is required when image-ref is not provided"); + it("uses the latest bundle when version and image-ref are omitted", () => { + const state = buildActionTestnodeState({ + l3Enabled: "false", + runnerTemp: "/tmp/runner", + }); + expect(state.imageRef).toBe(`${DEFAULT_TESTNODE_IMAGE_REPOSITORY}:latest-l2`); }); it("defaults to v3.2 when contractsVersion is not provided", () => { diff --git a/packages/core/src/snapshot-image.ts b/packages/core/src/snapshot-image.ts index 115e5a8..ed8bbab 100644 --- a/packages/core/src/snapshot-image.ts +++ b/packages/core/src/snapshot-image.ts @@ -16,7 +16,6 @@ import { getSnapshotVolumesDir, verifySnapshotManifest, } from "./snapshot.js"; -import { prepareTokenBridgeSource, resolveTokenBridgeSource } from "./token-bridge-source.js"; /** * Turn a captured snapshot into a runnable testnode docker image. @@ -172,9 +171,11 @@ export interface BakeSnapshotImageOptions { imageRef: string; /** Docker build context / repo root containing `docker/testnode.Dockerfile`. */ projectRoot: string; + /** Published testnode bundle to layer the snapshot onto. */ + baseImageRef?: string; /** `docker push` the image after building. */ push?: boolean; - /** Override the Dockerfile (default `docker/testnode.Dockerfile` under root). */ + /** Override the Dockerfile (default `docker/custom-testnode.Dockerfile` under root). */ dockerfile?: string; /** Override the `.testnode-context` output dir (default under the build context). */ contextDir?: string; @@ -193,6 +194,11 @@ export interface BakeSnapshotImageResult { contextDir: string; } +function defaultBaseImageRef(l3Enabled: boolean): string { + const variant = l3Enabled ? "l3-eth" : "l2"; + return `ghcr.io/offchainlabs/arbitrum-testnode-ci:latest-${variant}`; +} + /** * Prepare a snapshot's docker context and build (optionally push) a runnable * testnode image from it. Registry auth is the caller's responsibility. @@ -204,7 +210,7 @@ export function bakeSnapshotImage(options: BakeSnapshotImageOptions): BakeSnapsh const projectRoot = resolve(options.projectRoot); const dockerfile = options.dockerfile ? resolve(options.dockerfile) - : join(projectRoot, "docker", "testnode.Dockerfile"); + : join(projectRoot, "docker", "custom-testnode.Dockerfile"); if (!existsSync(dockerfile)) { throw new Error(`Dockerfile not found: ${dockerfile}`); } @@ -225,14 +231,14 @@ export function bakeSnapshotImage(options: BakeSnapshotImageOptions): BakeSnapsh : {}), ...(options.variant !== undefined ? { variant: options.variant } : {}), }); - const tokenBridgeSource = prepareTokenBridgeSource(resolveTokenBridgeSource(projectRoot)); + const baseImageRef = options.baseImageRef ?? defaultBaseImageRef(prepared.l3Enabled); execOrThrow( "docker", [ "build", - "--build-context", - `tokenbridge=${tokenBridgeSource.dockerContext}`, + "--build-arg", + `BASE_IMAGE=${baseImageRef}`, "-f", dockerfile, "-t", diff --git a/packages/testnode/src/runtime.mjs b/packages/testnode/src/runtime.mjs index 5395422..23cb87c 100644 --- a/packages/testnode/src/runtime.mjs +++ b/packages/testnode/src/runtime.mjs @@ -255,6 +255,9 @@ export function buildTestnodeImageRef({ contractsVersion, imageRepository, varia if (!definition) { throw new Error(`Unknown variant ${variant}`); } + if (version === "latest") { + return `${repository}:latest-${definition.name}`; + } const cv = normalizeNitroContractsVersion(contractsVersion); const contractsDefinition = NITRO_CONTRACTS_VERSIONS[/** @type {keyof typeof NITRO_CONTRACTS_VERSIONS} */ (cv)]; @@ -531,11 +534,7 @@ function sanitizeContainerName(value) { * @param {string | undefined} imageRef */ function resolveRuntimeVersion(version, imageRef) { - const resolvedVersion = version || (imageRef ? "custom" : undefined); - if (!resolvedVersion) { - throw new Error("version is required when image-ref is not provided"); - } - return resolvedVersion; + return version || (imageRef ? "custom" : "latest"); } /** diff --git a/scripts/ci/publish-latest-aliases.mjs b/scripts/ci/publish-latest-aliases.mjs new file mode 100644 index 0000000..8664669 --- /dev/null +++ b/scripts/ci/publish-latest-aliases.mjs @@ -0,0 +1,48 @@ +import { execFileSync } from "node:child_process"; + +/** + * Points `latest-` at the just-published version of that variant, in + * every repository the release was pushed to. + * + * Copies with crane rather than `docker buildx imagetools create`, which wraps a + * single-arch source in a new index and so would give the alias a different + * digest than the version tag it names. Matching digests are what let a consumer + * 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]); + } + } + return values; +} + +const repositories = readArgs("--repository"); +if (repositories.length === 0) { + throw new Error("at least one --repository is required"); +} + +const version = process.env.VERSION; +if (!version) { + throw new Error("VERSION is required"); +} + +const matrix = JSON.parse(process.env.MATRIX ?? "{}"); +const rows = matrix.include ?? []; +if (rows.length === 0) { + throw new Error("MATRIX contained no rows"); +} + +const contractsTag = (contractsVersion) => `nc${contractsVersion.replace(/^v/, "")}`; + +for (const repository of repositories) { + for (const row of rows) { + const source = `${repository}:${version}-${contractsTag(row.contractsVersion)}-${row.variant}`; + const target = `${repository}:latest-${row.variant}`; + console.log(`${target} -> ${source}`); + execFileSync("crane", ["copy", source, target], { stdio: "inherit" }); + } +} From ec09a0b485f997cc7fb51e4b281d4965321424c7 Mon Sep 17 00:00:00 2001 From: Doug Lance <4741454+douglance@users.noreply.github.com> Date: Fri, 21 Aug 2026 10:30:50 -0400 Subject: [PATCH 3/4] fix(bake): resolve published bundles from the public repository The bundle inputs landed pointing at ghcr.io/offchainlabs/arbitrum-testnode-ci, which is private and no longer the published name. Consumers of the bake action would need a token for what is now a credential-free pull, and the repository they pulled from would not be the one a release writes to. bundle-image-repository and the CLI default now resolve DEFAULT_TESTNODE_IMAGE_REPOSITORY, so the bake path and the boot path agree on where bundles come from, and moving registries again stays a one-line change. Two tests pasted the old ref as a literal and now import the constant. The latest-alias step also publishes to both registries rather than GHCR alone; an alias that exists in one registry and not the other makes the same tag name resolve differently depending on where it is pulled from. Its assertion checks that, rather than a specific command string. Co-Authored-By: Claude Opus 5 (1M context) --- apps/cli/test/bake.test.ts | 8 +++----- packages/action/test/action.test.ts | 12 +++++++++++- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/apps/cli/test/bake.test.ts b/apps/cli/test/bake.test.ts index 60387d1..7fe4d6f 100644 --- a/apps/cli/test/bake.test.ts +++ b/apps/cli/test/bake.test.ts @@ -1,3 +1,4 @@ +import { DEFAULT_TESTNODE_IMAGE_REPOSITORY } from "@arbitrum/testnode"; import { describe, expect, it, vi } from "vitest"; import { runBundleBake } from "../src/commands/bake.js"; @@ -50,11 +51,8 @@ describe("runBundleBake", () => { deps, ); - expect(result.baseImageRef).toBe("ghcr.io/offchainlabs/arbitrum-testnode-ci:latest-l3-eth"); - expect(commands[0]).toEqual([ - "pull", - "ghcr.io/offchainlabs/arbitrum-testnode-ci:latest-l3-eth", - ]); + expect(result.baseImageRef).toBe(`${DEFAULT_TESTNODE_IMAGE_REPOSITORY}:latest-l3-eth`); + expect(commands[0]).toEqual(["pull", `${DEFAULT_TESTNODE_IMAGE_REPOSITORY}:latest-l3-eth`]); }); it("supports an L2 bundle without running L3 semantic checks", async () => { diff --git a/packages/action/test/action.test.ts b/packages/action/test/action.test.ts index c0de753..317d59e 100644 --- a/packages/action/test/action.test.ts +++ b/packages/action/test/action.test.ts @@ -144,7 +144,17 @@ describe("published bundle metadata", () => { it("publishes latest aliases only after every release image succeeds", () => { expect(workflow).toContain("publish-latest-bundle:"); expect(workflow).toContain("needs: [resolve-publish-matrix, publish-testnode-image]"); - expect(workflow).toContain("docker buildx imagetools create --tag"); + 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. + const aliases = readFileSync("scripts/ci/publish-latest-aliases.mjs", "utf-8"); + expect(workflow).toContain('--repository "offchainlabs/arbitrum-litro"'); + expect(workflow).toContain('/arbitrum-litro"'); + // crane preserves the digest, so an alias and its version tag match. + expect(aliases).toContain('execFileSync("crane", ["copy"'); }); }); From dc98ef16f496c10b6b4bef98eaa0bb2934ef9025 Mon Sep 17 00:00:00 2001 From: Doug Lance <4741454+douglance@users.noreply.github.com> Date: Fri, 21 Aug 2026 14:08:51 -0400 Subject: [PATCH 4/4] fix(ci): give every local image build the Token Bridge context testnode.Dockerfile now takes the Token Bridge workspace from a named context defaulting to `scratch`, so a build that omits it copies nothing into /workspace and dies at `git commit` with an empty tree. test-l3-eth-action passed the context; test-nitro-image-rebase-action still used the old plain build and failed. The pin now lives in one workflow-level env rather than being repeated per job, since the next job to build the image would hit the same trap. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/test-action.yml | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test-action.yml b/.github/workflows/test-action.yml index deb1aa3..3627d44 100644 --- a/.github/workflows/test-action.yml +++ b/.github/workflows/test-action.yml @@ -10,6 +10,13 @@ on: default: "v0.1.6" type: string +# testnode.Dockerfile takes the Token Bridge workspace from a named context whose +# default is `scratch`, so a build that omits it produces an empty /workspace and +# fails at the git commit. Defined once here because every job that builds the +# image needs the same pin. +env: + TOKEN_BRIDGE_CONTEXT: https://github.com/OffchainLabs/token-bridge-contracts.git#5975d8f7360816341be7f94fd333ef240f4aec23 + jobs: test-l3-eth-action: runs-on: ubuntu-latest @@ -40,7 +47,7 @@ jobs: - name: Build local testnode image run: >- docker build - --build-context tokenbridge=https://github.com/OffchainLabs/token-bridge-contracts.git#5975d8f7360816341be7f94fd333ef240f4aec23 + --build-context tokenbridge=${{ env.TOKEN_BRIDGE_CONTEXT }} --build-arg BUNDLE_VERSION=${{ github.sha }} --build-arg BUNDLE_VARIANT=l3-eth -f docker/testnode.Dockerfile @@ -111,7 +118,12 @@ jobs: run: node scripts/ci/prepare-testnode-context.mjs --variant l3-eth --snapshot-id default - name: Build local testnode image - run: docker build -f docker/testnode.Dockerfile -t local/arbitrum-testnode:${{ github.sha }}-nc3.2-l3-eth . + run: >- + docker build + --build-context tokenbridge=${{ env.TOKEN_BRIDGE_CONTEXT }} + -f docker/testnode.Dockerfile + -t local/arbitrum-testnode:${{ github.sha }}-nc3.2-l3-eth + . # Read the pin from the Dockerfile the image above was built with (its ARG # default, since the build passes no --build-arg) so this job cannot drift