From 88a20ad9180ce636d0eb15f36a52e19598f274a7 Mon Sep 17 00:00:00 2001 From: dlance Date: Mon, 27 Jul 2026 14:00:18 -0400 Subject: [PATCH] feat(init): support nitro contracts source overrides --- README.md | 19 +- apps/cli/src/commands/bake.ts | 38 ++-- apps/cli/src/commands/init.ts | 6 + bake/action.yml | 29 ++- docker/contract-deployer-v2.1.Dockerfile | 36 +++- docker/contract-deployer.Dockerfile | 36 +++- packages/core/src/init/chain-steps.ts | 178 +++++++++++++++--- packages/core/src/init/runner.ts | 16 +- .../core/test/deployer-image-spec.test.ts | 169 +++++++++++++++++ 9 files changed, 470 insertions(+), 57 deletions(-) create mode 100644 packages/core/test/deployer-image-spec.test.ts diff --git a/README.md b/README.md index 3b3bf1e..c8b8dd7 100644 --- a/README.md +++ b/README.md @@ -180,6 +180,22 @@ pnpm dev bake \ --push # optional; docker login is your responsibility ``` +When rebuilding, `init` and `bake` can build the contract-deployer image from a +nitro-contracts branch, tag, or commit instead of the Dockerfile's pinned commit: + +```bash +pnpm dev init --rebuild --nitro-contracts-branch feature/my-change +pnpm dev bake --rebuild \ + --nitro-contracts-branch feature/my-change \ + --setup-command "./scripts/deploy-and-seed.sh" \ + --image-ref ghcr.io/acme/arbitrum-testnode:governance +``` + +For local development, set `NITRO_CONTRACTS_LOCAL_DIR` to a nitro-contracts checkout. +The local checkout takes precedence over `--nitro-contracts-branch`. The +`--nitro-contracts-version` option still selects the v2.1 or v3.2 deployer recipe; +the source override only replaces the checkout used by that recipe. + To bake straight from an existing snapshot (no setup step), use the à-la-carte subcommand: @@ -208,7 +224,8 @@ 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. +restores it; set `rebuild: true` to run a full init instead. Rebuilds also accept +`nitro-contracts-branch`, `nitro-contracts-version`, and `fee-token-decimals`. ### Booting a custom image diff --git a/apps/cli/src/commands/bake.ts b/apps/cli/src/commands/bake.ts index 965fd54..5ac0101 100644 --- a/apps/cli/src/commands/bake.ts +++ b/apps/cli/src/commands/bake.ts @@ -56,6 +56,12 @@ const bakeOptions = z.object({ .string() .optional() .describe("Nitro contracts version for a rebuild init (e.g. v2.1, v3.2)"), + nitroContractsBranch: z + .string() + .optional() + .describe( + "Build the core rollup contracts from this nitro-contracts branch, tag, or commit for a rebuild init", + ), feeTokenDecimals: z .number() .optional() @@ -63,6 +69,22 @@ const bakeOptions = z.object({ timeboostEnabled: z.boolean().optional().describe("Enable Timeboost for a rebuild init"), }); +function rebuildInitOptions(options: z.infer) { + return { + rebuild: true, + ...(options.nitroContractsBranch ? { nitroContractsBranch: options.nitroContractsBranch } : {}), + ...(options.nitroContractsVersion + ? { nitroContractsVersion: options.nitroContractsVersion } + : {}), + ...(options.feeTokenDecimals !== undefined + ? { feeTokenDecimals: options.feeTokenDecimals } + : {}), + ...(options.timeboostEnabled !== undefined + ? { timeboostEnabled: options.timeboostEnabled } + : {}), + }; +} + /** * Boot the base stack the customization runs against. `--rebuild` runs a full * init; otherwise the installed base snapshot is restored and started, mirroring @@ -75,21 +97,7 @@ async function ensureBaseStack( options: z.infer, ): Promise { if (options.rebuild) { - await runInitCommand( - { - rebuild: true, - ...(options.nitroContractsVersion - ? { nitroContractsVersion: options.nitroContractsVersion } - : {}), - ...(options.feeTokenDecimals !== undefined - ? { feeTokenDecimals: options.feeTokenDecimals } - : {}), - ...(options.timeboostEnabled !== undefined - ? { timeboostEnabled: options.timeboostEnabled } - : {}), - }, - createInitContext(root), - ); + await runInitCommand(rebuildInitOptions(options), createInitContext(root)); return; } diff --git a/apps/cli/src/commands/init.ts b/apps/cli/src/commands/init.ts index ff2e4f3..3bd561a 100644 --- a/apps/cli/src/commands/init.ts +++ b/apps/cli/src/commands/init.ts @@ -18,6 +18,12 @@ export const initCli = Cli.create("init", { .optional() .describe("Deploy a custom fee token ERC20 on L2 with this many decimals (6, 16, 18, or 20)"), foreground: z.boolean().optional().describe("Internal worker mode for detached init runs"), + nitroContractsBranch: z + .string() + .optional() + .describe( + "Build the core rollup contracts from this nitro-contracts branch, tag, or commit instead of the pinned default", + ), nitroContractsVersion: z .string() .optional() diff --git a/bake/action.yml b/bake/action.yml index aaa8a80..329be3d 100644 --- a/bake/action.yml +++ b/bake/action.yml @@ -41,6 +41,18 @@ inputs: required: false default: "v3.2" description: "Nitro contracts version passed through to a rebuild init" + nitro-contracts-branch: + required: false + default: "" + description: >- + Build the core rollup contracts from this nitro-contracts branch/commit + (instead of the pinned default) during a rebuild init. Empty = pinned default. + 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). github-token: required: false default: "" @@ -92,6 +104,10 @@ runs: 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_BRANCH_INPUT: ${{ inputs.nitro-contracts-branch }} + NITRO_CONTRACTS_VERSION_INPUT: ${{ inputs.nitro-contracts-version }} TOKEN_BRIDGE_COMMIT: 5975d8f7360816341be7f94fd333ef240f4aec23 TOKEN_BRIDGE_LOCAL_DIR: ${{ runner.temp }}/token-bridge-contracts run: | @@ -109,10 +125,17 @@ runs: yarn build ) fi - base="${{ inputs.base-snapshot-id }}" + base="$BASE_SNAPSHOT_ID_INPUT" + init_args=(--rebuild --capture-id "$base" --skip-post-capture-verify) + init_args+=(--nitro-contracts-version "$NITRO_CONTRACTS_VERSION_INPUT") + if [ -n "$NITRO_CONTRACTS_BRANCH_INPUT" ]; then + init_args+=(--nitro-contracts-branch "$NITRO_CONTRACTS_BRANCH_INPUT") + fi + if [ -n "$FEE_TOKEN_DECIMALS_INPUT" ]; then + init_args+=(--fee-token-decimals "$FEE_TOKEN_DECIMALS_INPUT") + fi for attempt in 1 2 3; do - if node apps/cli/dist/index.js init --rebuild --capture-id "$base" \ - --skip-post-capture-verify \ + 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 diff --git a/docker/contract-deployer-v2.1.Dockerfile b/docker/contract-deployer-v2.1.Dockerfile index d65d321..e61b7fb 100644 --- a/docker/contract-deployer-v2.1.Dockerfile +++ b/docker/contract-deployer-v2.1.Dockerfile @@ -1,5 +1,35 @@ +# Nitro-contracts source is selectable so consumers can bake an image against +# their own branch/commit (NITRO_CONTRACTS_BRANCH) or a local checkout +# (NITRO_CONTRACTS_SOURCE=local + `--build-context nitrocontracts=`). +# Defaults reproduce the pinned upstream v2.1 commit. +ARG NITRO_CONTRACTS_SOURCE=git +ARG NITRO_CONTRACTS_BRANCH=f9cd1aa4b5bba209211e8df9993e0eba89eaedda + FROM ghcr.io/foundry-rs/foundry:v1.3.1 AS foundry +# Source stage: fetch nitro-contracts from git at the requested ref. +FROM node:20-trixie-slim AS nitro-src-git +RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/* +WORKDIR /workspace/nitro-contracts +ARG NITRO_CONTRACTS_BRANCH +RUN git init . \ + && git remote add origin https://github.com/OffchainLabs/nitro-contracts.git \ + && git fetch --depth 1 origin "$NITRO_CONTRACTS_BRANCH" \ + && git checkout --detach FETCH_HEAD \ + && git submodule update --init --recursive --depth 1 + +# Fallback for builds without a named context. Passing +# `--build-context nitrocontracts=` overrides this same-named stage. +FROM scratch AS nitrocontracts + +# Source stage: use a local checkout supplied via `--build-context nitrocontracts=`. +FROM node:20-trixie-slim AS nitro-src-local +WORKDIR /workspace/nitro-contracts +COPY --from=nitrocontracts . /workspace/nitro-contracts + +# Select the active source (git by default, local when NITRO_CONTRACTS_SOURCE=local). +FROM nitro-src-${NITRO_CONTRACTS_SOURCE} AS nitro-src + FROM node:20-trixie-slim AS nitro-builder COPY --from=foundry /usr/local/bin/forge /usr/local/bin/forge @@ -8,11 +38,7 @@ RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/* WORKDIR /workspace/nitro-contracts -RUN git init . \ - && git remote add origin https://github.com/OffchainLabs/nitro-contracts.git \ - && git fetch --depth 1 origin f9cd1aa4b5bba209211e8df9993e0eba89eaedda \ - && git checkout --detach FETCH_HEAD \ - && git submodule update --init --recursive --depth 1 +COPY --from=nitro-src /workspace/nitro-contracts /workspace/nitro-contracts RUN cp scripts/config.ts.example scripts/config.ts RUN yarn install --frozen-lockfile diff --git a/docker/contract-deployer.Dockerfile b/docker/contract-deployer.Dockerfile index 9d23bed..0747be4 100644 --- a/docker/contract-deployer.Dockerfile +++ b/docker/contract-deployer.Dockerfile @@ -1,5 +1,35 @@ +# Nitro-contracts source is selectable so consumers can bake an image against +# their own branch/commit (NITRO_CONTRACTS_BRANCH) or a local checkout +# (NITRO_CONTRACTS_SOURCE=local + `--build-context nitrocontracts=`). +# Defaults reproduce the pinned upstream commit. +ARG NITRO_CONTRACTS_SOURCE=git +ARG NITRO_CONTRACTS_BRANCH=cd4eb69e3c4cb87161b1433ad238902ea5c32ebd + FROM ghcr.io/foundry-rs/foundry:v1.3.1 AS foundry +# Source stage: fetch nitro-contracts from git at the requested ref. +FROM node:20-trixie-slim AS nitro-src-git +RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/* +WORKDIR /workspace/nitro-contracts +ARG NITRO_CONTRACTS_BRANCH +RUN git init . \ + && git remote add origin https://github.com/OffchainLabs/nitro-contracts.git \ + && git fetch --depth 1 origin "$NITRO_CONTRACTS_BRANCH" \ + && git checkout --detach FETCH_HEAD \ + && git submodule update --init --recursive --depth 1 + +# Fallback for builds without a named context. Passing +# `--build-context nitrocontracts=` overrides this same-named stage. +FROM scratch AS nitrocontracts + +# Source stage: use a local checkout supplied via `--build-context nitrocontracts=`. +FROM node:20-trixie-slim AS nitro-src-local +WORKDIR /workspace/nitro-contracts +COPY --from=nitrocontracts . /workspace/nitro-contracts + +# Select the active source (git by default, local when NITRO_CONTRACTS_SOURCE=local). +FROM nitro-src-${NITRO_CONTRACTS_SOURCE} AS nitro-src + FROM node:20-trixie-slim AS nitro-builder COPY --from=foundry /usr/local/bin/forge /usr/local/bin/forge @@ -8,11 +38,7 @@ RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/* WORKDIR /workspace/nitro-contracts -RUN git init . \ - && git remote add origin https://github.com/OffchainLabs/nitro-contracts.git \ - && git fetch --depth 1 origin cd4eb69e3c4cb87161b1433ad238902ea5c32ebd \ - && git checkout --detach FETCH_HEAD \ - && git submodule update --init --recursive --depth 1 +COPY --from=nitro-src /workspace/nitro-contracts /workspace/nitro-contracts RUN cp scripts/config.example.ts scripts/config.ts RUN yarn install --frozen-lockfile diff --git a/packages/core/src/init/chain-steps.ts b/packages/core/src/init/chain-steps.ts index ee6d757..ab85e3a 100644 --- a/packages/core/src/init/chain-steps.ts +++ b/packages/core/src/init/chain-steps.ts @@ -1,4 +1,5 @@ -import { readFileSync, writeFileSync } from "node:fs"; +import { createHash } from "node:crypto"; +import { existsSync, readFileSync, writeFileSync } from "node:fs"; import { resolve } from "node:path"; import type { Address } from "viem"; import { parseEther } from "viem"; @@ -43,7 +44,7 @@ const L3_DEPOSIT_TARGET_WEI = 50n * 10n ** 18n; const L3_DEPOSIT_RESERVE_WEI = 1n * 10n ** 18n; const L3_DEPOSIT_READY_THRESHOLD_WEI = 10n * 10n ** 18n; const L2_OWNER_DEPLOYER_FUNDING_WEI = 100n * 10n ** 18n; -const CONTRACT_DEPLOYER_IMAGE = "nitro-testnode-contract-deployer:latest"; +const CONTRACT_DEPLOYER_IMAGE_BASE = "nitro-testnode-contract-deployer"; const CONTRACT_DEPLOYER_POLLING_INTERVAL_MS = 100; const CONTRACT_DEPLOYER_CREATE2_CONFIRMATIONS = 1; const WASM_MODULE_ROOT = "0xdb698a2576298f25448bc092e52cf13b1e24141c997135d70f217d674bbeb69a"; @@ -78,17 +79,114 @@ import { const builtContractDeployerImages = new Set(); +export interface DeployerImageSpec { + image: string; + dockerfile: string; + buildArgs: string[]; + buildContexts: string[]; + // Skip the cross-run `docker image inspect` reuse (an override's source may + // have changed since a prior run) while still honoring the in-run build cache. + skipInspectCache: boolean; +} + +function sanitizeImageTagComponent(value: string): string { + const slug = value + .toLowerCase() + .replace(/[^a-z0-9._-]+/g, "-") + .replace(/^[-.]+|[-.]+$/g, "") + .slice(0, 28); + return slug === "" ? "custom" : slug; +} + +function sourceTag(prefix: string, identity: string): string { + const slug = sanitizeImageTagComponent(identity); + const digest = createHash("sha256").update(identity).digest("hex").slice(0, 12); + return `${prefix}-${slug}-${digest}`; +} + +/** + * Verify NITRO_CONTRACTS_LOCAL_DIR points at a nitro-contracts checkout before + * we hand it to `docker build --build-context`, so a wrong path surfaces as an + * actionable error instead of a cryptic build failure. Mirrors + * assertTokenBridgeDepsPresent. + */ +export function assertNitroContractsLocalDir(dir: string): void { + const packageJson = resolve(dir, "package.json"); + if (!existsSync(packageJson)) { + throw new Error( + `NITRO_CONTRACTS_LOCAL_DIR is set to ${dir} but no package.json was found there ` + + `(expected ${packageJson}). Point NITRO_CONTRACTS_LOCAL_DIR at a nitro-contracts checkout.`, + ); + } +} + +/** + * Resolve which contract-deployer image to build/run. By default this is the + * version-pinned `:latest` image built from the commit baked into the + * Dockerfile. Setting NITRO_CONTRACTS_LOCAL_DIR (a local checkout) or + * NITRO_CONTRACTS_BRANCH (a branch/commit) overrides ONLY the nitro-contracts + * source the image is built from — mirroring TOKEN_BRIDGE_LOCAL_DIR. The + * `--nitro-contracts-version` enum still selects the Dockerfile/build recipe. + * Local dir wins over branch when both are set. + */ +export function resolveDeployerImageSpec( + opts: { isV21?: boolean; nitroContractsBranch?: string | undefined } = {}, +): DeployerImageSpec { + const isV21 = opts.isV21 ?? false; + const baseImage = isV21 ? `${CONTRACT_DEPLOYER_IMAGE_BASE}-v2.1` : CONTRACT_DEPLOYER_IMAGE_BASE; + const dockerfile = isV21 + ? "docker/contract-deployer-v2.1.Dockerfile" + : "docker/contract-deployer.Dockerfile"; + + const localDir = process.env["NITRO_CONTRACTS_LOCAL_DIR"]; + if (localDir && localDir.trim() !== "") { + const absDir = resolve(localDir.trim()); + assertNitroContractsLocalDir(absDir); + return { + image: `${baseImage}:${sourceTag("local", absDir)}`, + dockerfile, + buildArgs: ["--build-arg", "NITRO_CONTRACTS_SOURCE=local"], + buildContexts: ["--build-context", `nitrocontracts=${absDir}`], + skipInspectCache: true, + }; + } + + const branch = opts.nitroContractsBranch ?? process.env["NITRO_CONTRACTS_BRANCH"]; + if (branch && branch.trim() !== "") { + const ref = branch.trim(); + return { + image: `${baseImage}:${sourceTag("nc", ref)}`, + dockerfile, + buildArgs: ["--build-arg", `NITRO_CONTRACTS_BRANCH=${ref}`], + buildContexts: [], + skipInspectCache: true, + }; + } + + return { + image: `${baseImage}:latest`, + dockerfile, + buildArgs: [], + buildContexts: [], + skipInspectCache: false, + }; +} + async function ensureContractDeployerImage( runtime: InitRuntime, - image: string = CONTRACT_DEPLOYER_IMAGE, - dockerfile = "docker/contract-deployer.Dockerfile", + spec: DeployerImageSpec, forceRebuild = false, ): Promise { - if (builtContractDeployerImages.has(image) && !forceRebuild) { - console.log(`[init] Contract deployer image already checked: ${image}`); + const { image, dockerfile, buildArgs, buildContexts, skipInspectCache } = spec; + // forceRebuild (stale-image recovery) must rebuild even if we already built + // this tag this run; drop the in-run marker so the build below actually runs. + if (forceRebuild) { + builtContractDeployerImages.delete(image); + } else if (builtContractDeployerImages.has(image)) { + console.log(`[init] Contract deployer image already built this run: ${image}`); return; } - if (!forceRebuild) { + if (!forceRebuild && !skipInspectCache) { console.log(`[init] Checking contract deployer image: ${image}`); const inspect = exec("docker", ["image", "inspect", image], { timeout: 30_000, @@ -99,12 +197,20 @@ async function ensureContractDeployerImage( return; } } + if (buildContexts.length > 0) { + // `--build-context` needs BuildKit; Docker 23+ enables it by default, but + // set it explicitly so an older daemon doesn't fall back to the classic + // builder (which can't resolve named build contexts). + process.env["DOCKER_BUILDKIT"] ??= "1"; + } console.log(`[init] Building contract deployer image: ${image}`); execOrThrow( "docker", [ "build", "--progress=plain", + ...buildArgs, + ...buildContexts, "-t", image, "-f", @@ -124,15 +230,18 @@ async function deployRollupCreatorViaDocker( dockerParentRpc: string; deployerKey: string; maxDataSize: string; - image?: string; - dockerfile?: string; + isV21?: boolean; + nitroContractsBranch?: string | undefined; retryAfterImageRebuild?: boolean; }, ): Promise { const retryAfterImageRebuild = params.retryAfterImageRebuild ?? true; - const image = params.image ?? CONTRACT_DEPLOYER_IMAGE; - const dockerfile = params.dockerfile ?? "docker/contract-deployer.Dockerfile"; - await ensureContractDeployerImage(runtime, image, dockerfile); + const spec = resolveDeployerImageSpec({ + isV21: params.isV21 ?? false, + nitroContractsBranch: params.nitroContractsBranch, + }); + const image = spec.image; + await ensureContractDeployerImage(runtime, spec); await waitForRpc(params.hostParentRpc); console.log(`[init] Deploying RollupCreator on ${params.dockerParentRpc}`); const args = [ @@ -173,7 +282,7 @@ async function deployRollupCreatorViaDocker( if (!output.stakeToken) { if (retryAfterImageRebuild) { console.warn("[init] Contract deployer image is stale; rebuilding and retrying once"); - await ensureContractDeployerImage(runtime, image, dockerfile, true); + await ensureContractDeployerImage(runtime, spec, true); return deployRollupCreatorViaDocker(runtime, { ...params, retryAfterImageRebuild: false, @@ -195,9 +304,16 @@ async function deployTimeboostAuctionViaDocker( hostRpc: string; dockerRpc: string; deployerKey: string; + nitroContractsBranch?: string | undefined; }, ): Promise { - await ensureContractDeployerImage(runtime); + // Timeboost is L2-only (v3.2); resolve the same deployer spec so a + // nitro-contracts source override applies here too. + const spec = resolveDeployerImageSpec({ + isV21: false, + nitroContractsBranch: params.nitroContractsBranch, + }); + await ensureContractDeployerImage(runtime, spec); await waitForRpc(params.hostRpc); console.log(`[init] Deploying Timeboost auction contract on ${params.dockerRpc}`); const args = [ @@ -227,7 +343,7 @@ async function deployTimeboostAuctionViaDocker( `TIMEBOOST_BENEFICIARY_ADDRESS=${accounts.l2owner.address}`, "-e", "TIMEBOOST_AUCTION_OUTPUT=/config/timeboost-auction.json", - CONTRACT_DEPLOYER_IMAGE, + spec.image, "hardhat", "run", "--no-compile", @@ -286,7 +402,10 @@ function createL1Steps(runtime: InitRuntime): Record { }; } -function createL2DeploySteps(runtime: InitRuntime): Record { +function createL2DeploySteps( + runtime: InitRuntime, + nitroContractsBranch?: string, +): Record { return { "deploy-l2-rollup": async (state) => { writeChainConfig(runtime.configDir, "l2_chain_config.json", { @@ -298,6 +417,7 @@ function createL2DeploySteps(runtime: InitRuntime): Record { dockerParentRpc: L1_RPC_DOCKER, deployerKey: accounts.l2owner.privateKey, maxDataSize: "117964", + nitroContractsBranch, }); await deployRollupViaSdk({ chainConfigPath: resolve(runtime.configDir, "l2_chain_config.json"), @@ -382,7 +502,10 @@ function createL2DeploySteps(runtime: InitRuntime): Record { }; } -function createL2RuntimeSteps(runtime: InitRuntime): Record { +function createL2RuntimeSteps( + runtime: InitRuntime, + nitroContractsBranch?: string, +): Record { return { "start-l2": async (state) => { composeUp(["sequencer", "validator"], runtime.dockerOpts); @@ -397,6 +520,7 @@ function createL2RuntimeSteps(runtime: InitRuntime): Record hostRpc: L2_RPC, dockerRpc: L2_RPC_DOCKER, deployerKey: accounts.l2owner.privateKey, + nitroContractsBranch, }); return markStepDone(state, "deploy-timeboost-auction", { ...deployment }); }, @@ -586,6 +710,7 @@ async function deployL3Rollup( runtime: InitRuntime, feeTokenDecimals: number | undefined, isV21: boolean, + nitroContractsBranch?: string, ): Promise { await fundL3DeployerAccounts(); writeChainConfig(runtime.configDir, "l3_chain_config.json", { @@ -606,12 +731,8 @@ async function deployL3Rollup( dockerParentRpc: L2_RPC_DOCKER, deployerKey: accounts.l3owner.privateKey, maxDataSize: "104857", - ...(isV21 - ? { - image: "nitro-testnode-contract-deployer-v2.1:latest", - dockerfile: "docker/contract-deployer-v2.1.Dockerfile", - } - : {}), + isV21, + nitroContractsBranch, }); await deployRollupViaSdk({ chainConfigPath: resolve(runtime.configDir, "l3_chain_config.json"), @@ -657,10 +778,12 @@ function createL3Steps( runtime: InitRuntime, feeTokenDecimals?: number, nitroContractsVersion?: string, + nitroContractsBranch?: string, ): Record { const isV21 = nitroContractsVersion === "v2.1"; return { - "deploy-l3-rollup": (state) => deployL3Rollup(state, runtime, feeTokenDecimals, isV21), + "deploy-l3-rollup": (state) => + deployL3Rollup(state, runtime, feeTokenDecimals, isV21, nitroContractsBranch), "generate-l3-config": async (state) => { const rollupData = state.steps["deploy-l3-rollup"]?.data; if (!rollupData) { @@ -821,11 +944,12 @@ export function makeStepRunners( runtime: InitRuntime, feeTokenDecimals?: number, nitroContractsVersion?: string, + nitroContractsBranch?: string, ): Record { return { ...createL1Steps(runtime), - ...createL2DeploySteps(runtime), - ...createL2RuntimeSteps(runtime), - ...createL3Steps(runtime, feeTokenDecimals, nitroContractsVersion), + ...createL2DeploySteps(runtime, nitroContractsBranch), + ...createL2RuntimeSteps(runtime, nitroContractsBranch), + ...createL3Steps(runtime, feeTokenDecimals, nitroContractsVersion, nitroContractsBranch), }; } diff --git a/packages/core/src/init/runner.ts b/packages/core/src/init/runner.ts index 36249fd..f6e1feb 100644 --- a/packages/core/src/init/runner.ts +++ b/packages/core/src/init/runner.ts @@ -61,6 +61,7 @@ async function runInitLoop( rebuild?: boolean, timeboostEnabled?: boolean, nitroContractsVersion?: string, + nitroContractsBranch?: string, ): Promise<{ success: boolean; failedStep?: string; @@ -69,7 +70,12 @@ async function runInitLoop( steps: string[]; }> { let state = rebuild ? createState() : (loadState(runtime.configDir) ?? createState()); - const runners = makeStepRunners(runtime, feeTokenDecimals, nitroContractsVersion); + const runners = makeStepRunners( + runtime, + feeTokenDecimals, + nitroContractsVersion, + nitroContractsBranch, + ); const steps = getInitSteps({ timeboostEnabled }); const timings: Record = {}; @@ -117,6 +123,7 @@ export interface InitCommandOptions { captureId?: string | undefined; feeTokenDecimals?: number | undefined; foreground?: boolean | undefined; + nitroContractsBranch?: string | undefined; nitroContractsVersion?: string | undefined; rebuild?: boolean | undefined; skipPostCaptureVerify?: boolean | undefined; @@ -152,6 +159,7 @@ export async function runInitCommand(options: InitCommandOptions, context: InitC snapshotVersion: options.snapshotVersion, feeTokenDecimals, timeboostEnabled: options.timeboostEnabled, + nitroContractsBranch: options.nitroContractsBranch, nitroContractsVersion: options.nitroContractsVersion, }); } @@ -177,6 +185,7 @@ async function runInitForeground( runtime: InitRuntime, options: { foreground?: boolean | undefined; + nitroContractsBranch?: string | undefined; nitroContractsVersion?: string | undefined; rebuild?: boolean | undefined; skipPostCaptureVerify?: boolean | undefined; @@ -218,6 +227,7 @@ async function runInitForeground( options.rebuild, options.timeboostEnabled, options.nitroContractsVersion, + options.nitroContractsBranch, ); const totalElapsed = Date.now() - totalStart; logInitTimeline(result.timings, totalElapsed); @@ -274,6 +284,7 @@ function startBackgroundInit( snapshotVersion: string | undefined; feeTokenDecimals: number | undefined; timeboostEnabled: boolean | undefined; + nitroContractsBranch: string | undefined; nitroContractsVersion: string | undefined; }, ) { @@ -283,6 +294,9 @@ function startBackgroundInit( ? ["--fee-token-decimals", String(params.feeTokenDecimals)] : []), ...(params.timeboostEnabled ? ["--timeboost-enabled"] : []), + ...(params.nitroContractsBranch + ? ["--nitro-contracts-branch", params.nitroContractsBranch] + : []), ...(params.nitroContractsVersion ? ["--nitro-contracts-version", params.nitroContractsVersion] : []), diff --git a/packages/core/test/deployer-image-spec.test.ts b/packages/core/test/deployer-image-spec.test.ts new file mode 100644 index 0000000..33d49b7 --- /dev/null +++ b/packages/core/test/deployer-image-spec.test.ts @@ -0,0 +1,169 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { assertNitroContractsLocalDir, resolveDeployerImageSpec } from "../src/init/chain-steps.js"; + +const ENV_KEYS = ["NITRO_CONTRACTS_BRANCH", "NITRO_CONTRACTS_LOCAL_DIR"] as const; + +describe("resolveDeployerImageSpec", () => { + const saved: Record = {}; + const tempDirs: string[] = []; + + function makeLocalCheckout(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "nitro-local-")); + tempDirs.push(dir); + fs.writeFileSync(path.join(dir, "package.json"), "{}"); + return dir; + } + + beforeEach(() => { + for (const key of ENV_KEYS) { + saved[key] = process.env[key]; + delete process.env[key]; + } + }); + + afterEach(() => { + for (const key of ENV_KEYS) { + if (saved[key] === undefined) { + delete process.env[key]; + } else { + process.env[key] = saved[key]; + } + } + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it("defaults to the pinned v3.2 :latest image with no overrides", () => { + const spec = resolveDeployerImageSpec(); + expect(spec).toEqual({ + image: "nitro-testnode-contract-deployer:latest", + dockerfile: "docker/contract-deployer.Dockerfile", + buildArgs: [], + buildContexts: [], + skipInspectCache: false, + }); + }); + + it("selects the v2.1 image/dockerfile when isV21", () => { + const spec = resolveDeployerImageSpec({ isV21: true }); + expect(spec.image).toBe("nitro-testnode-contract-deployer-v2.1:latest"); + expect(spec.dockerfile).toBe("docker/contract-deployer-v2.1.Dockerfile"); + expect(spec.skipInspectCache).toBe(false); + }); + + it("overrides the source with a branch/commit and forces a fresh build", () => { + process.env["NITRO_CONTRACTS_BRANCH"] = "abc123"; + const spec = resolveDeployerImageSpec(); + expect(spec.image).toMatch(/^nitro-testnode-contract-deployer:nc-abc123-[a-f0-9]{12}$/); + expect(spec.buildArgs).toEqual(["--build-arg", "NITRO_CONTRACTS_BRANCH=abc123"]); + expect(spec.buildContexts).toEqual([]); + expect(spec.skipInspectCache).toBe(true); + }); + + it("sanitizes branch names into a valid image tag component", () => { + process.env["NITRO_CONTRACTS_BRANCH"] = "feature/My_Branch"; + const spec = resolveDeployerImageSpec(); + expect(spec.image).toMatch( + /^nitro-testnode-contract-deployer:nc-feature-my_branch-[a-f0-9]{12}$/, + ); + expect(spec.buildArgs).toEqual(["--build-arg", "NITRO_CONTRACTS_BRANCH=feature/My_Branch"]); + }); + + it("keeps refs with the same sanitized slug on distinct image tags", () => { + const slash = resolveDeployerImageSpec({ nitroContractsBranch: "feature/foo" }); + const colon = resolveDeployerImageSpec({ nitroContractsBranch: "feature:foo" }); + expect(slash.image).not.toBe(colon.image); + }); + + it("prefers an explicit branch option over the environment fallback", () => { + process.env["NITRO_CONTRACTS_BRANCH"] = "from-env"; + const spec = resolveDeployerImageSpec({ nitroContractsBranch: "from-cli" }); + expect(spec.buildArgs).toEqual(["--build-arg", "NITRO_CONTRACTS_BRANCH=from-cli"]); + }); + + it("keeps the branch override on the v2.1 image", () => { + process.env["NITRO_CONTRACTS_BRANCH"] = "deadbeef"; + const spec = resolveDeployerImageSpec({ isV21: true }); + expect(spec.image).toMatch(/^nitro-testnode-contract-deployer-v2\.1:nc-deadbeef-[a-f0-9]{12}$/); + expect(spec.dockerfile).toBe("docker/contract-deployer-v2.1.Dockerfile"); + }); + + it("overrides the source with a local checkout via --build-context", () => { + const dir = makeLocalCheckout(); + process.env["NITRO_CONTRACTS_LOCAL_DIR"] = dir; + const spec = resolveDeployerImageSpec(); + expect(spec.image).toMatch(/^nitro-testnode-contract-deployer:local-.+-[a-f0-9]{12}$/); + expect(spec.buildArgs).toEqual(["--build-arg", "NITRO_CONTRACTS_SOURCE=local"]); + expect(spec.buildContexts).toEqual(["--build-context", `nitrocontracts=${dir}`]); + expect(spec.skipInspectCache).toBe(true); + }); + + it("prefers the local checkout over a branch when both are set", () => { + const dir = makeLocalCheckout(); + process.env["NITRO_CONTRACTS_LOCAL_DIR"] = dir; + process.env["NITRO_CONTRACTS_BRANCH"] = "abc123"; + const spec = resolveDeployerImageSpec(); + expect(spec.image).toContain("nitro-testnode-contract-deployer:local-"); + expect(spec.buildContexts).toEqual(["--build-context", `nitrocontracts=${dir}`]); + }); + + it("treats empty override env vars as unset", () => { + process.env["NITRO_CONTRACTS_BRANCH"] = ""; + process.env["NITRO_CONTRACTS_LOCAL_DIR"] = ""; + expect(resolveDeployerImageSpec().image).toBe("nitro-testnode-contract-deployer:latest"); + }); +}); + +describe("assertNitroContractsLocalDir", () => { + const tempDirs: string[] = []; + + afterEach(() => { + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it("passes for a directory containing package.json", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "nitro-local-")); + tempDirs.push(dir); + fs.writeFileSync(path.join(dir, "package.json"), "{}"); + expect(() => assertNitroContractsLocalDir(dir)).not.toThrow(); + }); + + it("throws an actionable error naming the env var when package.json is missing", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "nitro-empty-")); + tempDirs.push(dir); + expect(() => assertNitroContractsLocalDir(dir)).toThrow(/NITRO_CONTRACTS_LOCAL_DIR/); + }); +}); + +describe.each([ + { + dockerfile: "docker/contract-deployer.Dockerfile", + pinnedRef: "cd4eb69e3c4cb87161b1433ad238902ea5c32ebd", + }, + { + dockerfile: "docker/contract-deployer-v2.1.Dockerfile", + pinnedRef: "f9cd1aa4b5bba209211e8df9993e0eba89eaedda", + }, +])("$dockerfile source selection", ({ dockerfile, pinnedRef }) => { + const contents = fs.readFileSync(path.resolve(dockerfile), "utf8"); + + it("retains the pinned default ref", () => { + expect(contents).toContain(`ARG NITRO_CONTRACTS_BRANCH=${pinnedRef}`); + }); + + it("quotes the caller-provided git ref", () => { + expect(contents).toContain('git fetch --depth 1 origin "$NITRO_CONTRACTS_BRANCH"'); + }); + + it("supports a named local build context", () => { + expect(contents).toContain("FROM scratch AS nitrocontracts"); + expect(contents).toContain("COPY --from=nitrocontracts . /workspace/nitro-contracts"); + expect(contents).toContain("FROM nitro-src-${NITRO_CONTRACTS_SOURCE} AS nitro-src"); + }); +});