From 41e0ea8810bb177c77d02d3b1ebb46937b55d74a Mon Sep 17 00:00:00 2001 From: yen <5915590+antigenius0910@users.noreply.github.com> Date: Thu, 22 Oct 2026 17:41:01 -0500 Subject: [PATCH 1/9] chore(docker): pin Node.js base image via NODE22_VERSION/NODE24_VERSION Replace the floating node:22-trixie-slim and node:24-trixie-slim bases with global build ARGs that hold the full Node.js version (22.23.4 and 24.22.1, the October 2026 security releases). The pin makes the Node.js version shipped in each image visible in the repo and changes it only through a reviewed bump, as decided in #1567. The smoke tests now check that node --version in each Node-based image matches the ARG default. A new assert_line keeps the ARG value identical across every Dockerfile that declares it. Nightly keeps floating as the Node.js canary. It passes only the major version (NODE22_VERSION=22, NODE24_VERSION=24), so its base resolves to the floating tags, and it does not run the version assertion. Also fix docs/opencode.md, which still named node:22-bookworm-slim. Refs #1567 Co-Authored-By: Claude Opus 5.5 --- .github/workflows/docker-smoke-test-unified.yml | 15 +++++++++++++++ .github/workflows/docker-smoke-test.yml | 17 +++++++++++++++++ .github/workflows/nightly-build.yml | 4 +++- Dockerfile.claude | 6 +++++- Dockerfile.codex | 6 +++++- Dockerfile.copilot | 6 +++++- Dockerfile.gemini | 6 +++++- Dockerfile.kimi | 6 +++++- Dockerfile.mimocode | 6 +++++- Dockerfile.opencode | 12 ++++++++---- Dockerfile.package | 9 +++++++-- Dockerfile.pi | 6 +++++- Dockerfile.unified | 9 +++++++-- docs/opencode.md | 2 +- 14 files changed, 93 insertions(+), 17 deletions(-) diff --git a/.github/workflows/docker-smoke-test-unified.yml b/.github/workflows/docker-smoke-test-unified.yml index d1e701ddd..b456baca7 100644 --- a/.github/workflows/docker-smoke-test-unified.yml +++ b/.github/workflows/docker-smoke-test-unified.yml @@ -85,6 +85,21 @@ jobs: - name: Verify agent CLI exists run: docker run --rm --entrypoint which openab-unified-${{ matrix.target.name }}:test ${{ matrix.target.agent }} + - name: Verify Node.js runtime version + if: contains(fromJSON('["claude","codex","copilot","gemini","kimi","mimocode","opencode","pi"]'), matrix.target.name) + shell: bash + run: | + set -euo pipefail + case "${{ matrix.target.name }}" in + kimi) arg=NODE24_VERSION ;; + *) arg=NODE22_VERSION ;; + esac + expected=$(awk -F= -v key="ARG $arg" '$1 == key {print $2; exit}' Dockerfile.unified) + test -n "$expected" || { echo "::error::Failed to extract $arg from Dockerfile.unified"; exit 1; } + actual=$(docker run --rm --entrypoint node openab-unified-${{ matrix.target.name }}:test --version) + [ "$actual" = "v$expected" ] || { echo "::error::Node.js version mismatch: expected v$expected ($arg), got $actual"; exit 1; } + echo "✅ Node.js $actual" + - name: Verify Codex runtime versions if: matrix.target.name == 'codex' run: | diff --git a/.github/workflows/docker-smoke-test.yml b/.github/workflows/docker-smoke-test.yml index c7baa30e0..9a1a586ac 100644 --- a/.github/workflows/docker-smoke-test.yml +++ b/.github/workflows/docker-smoke-test.yml @@ -54,6 +54,21 @@ jobs: - name: Verify agent CLI exists run: docker run --rm --entrypoint which openab-test${{ matrix.variant.suffix }} ${{ matrix.variant.agent }} + - name: Verify Node.js runtime version + if: contains(fromJSON('["-claude","-codex","-gemini","-copilot","-opencode","-kimi","-mimocode","-pi"]'), matrix.variant.suffix) + shell: bash + run: | + set -euo pipefail + case "${{ matrix.variant.suffix }}" in + -kimi) arg=NODE24_VERSION ;; + *) arg=NODE22_VERSION ;; + esac + expected=$(awk -F= -v key="ARG $arg" '$1 == key {print $2; exit}' ${{ matrix.variant.dockerfile }}) + test -n "$expected" || { echo "::error::Failed to extract $arg from ${{ matrix.variant.dockerfile }}"; exit 1; } + actual=$(docker run --rm --entrypoint node openab-test${{ matrix.variant.suffix }} --version) + [ "$actual" = "v$expected" ] || { echo "::error::Node.js version mismatch: expected v$expected ($arg), got $actual"; exit 1; } + echo "✅ Node.js $actual" + - name: Verify Codex runtime versions if: matrix.variant.suffix == '-codex' run: | @@ -169,6 +184,8 @@ jobs: } } + assert_line 'ARG NODE22_VERSION=22.23.4' Dockerfile.claude Dockerfile.codex Dockerfile.copilot Dockerfile.gemini Dockerfile.mimocode Dockerfile.opencode Dockerfile.pi Dockerfile.package Dockerfile.unified + assert_line 'ARG NODE24_VERSION=24.22.1' Dockerfile.kimi Dockerfile.package Dockerfile.unified assert_line 'ARG KIRO_CLI_VERSION=2.25.0' Dockerfile Dockerfile.package Dockerfile.unified assert_line 'ARG KIRO_SHA256_AMD64=3e924de9697717b99891bb5f7d2a4bd9c768b8bfa1c498b35b3d242948512984' Dockerfile Dockerfile.package Dockerfile.unified assert_line 'ARG KIRO_SHA256_ARM64=cc8a2df90cd354b569b81ed4653db4796c86a69866568b4eb7fa7906afc95309' Dockerfile Dockerfile.package Dockerfile.unified diff --git a/.github/workflows/nightly-build.yml b/.github/workflows/nightly-build.yml index 0c0edac13..6ee492243 100644 --- a/.github/workflows/nightly-build.yml +++ b/.github/workflows/nightly-build.yml @@ -328,7 +328,9 @@ jobs: # The BUILD_ARGS literals above are indented for readability; strip any # leading whitespace so each emitted line is a clean KEY=VALUE that # build-push-action's build-args parser accepts. Drop blank lines too. - printf '%s\n' "$BUILD_ARGS" | sed 's/^[[:space:]]*//' | grep -v '^$' > resolved/build_args + # Nightly is the Node canary: pass only the major so the base image + # floats to the latest patch instead of the pinned Dockerfile default. + printf '%s\n' "$BUILD_ARGS" NODE22_VERSION=22 NODE24_VERSION=24 | sed 's/^[[:space:]]*//' | grep -v '^$' > resolved/build_args printf '%s\n' "$REPORT" > resolved/report - name: Upload resolved build args diff --git a/Dockerfile.claude b/Dockerfile.claude index a10d21abf..a5ac97dc3 100644 --- a/Dockerfile.claude +++ b/Dockerfile.claude @@ -1,3 +1,7 @@ +# Pinned so the shipped Node.js version is explicit; bump it on Node security +# releases. Passing just the major (e.g. NODE22_VERSION=22) floats to the latest. +ARG NODE22_VERSION=22.23.4 + # --- Build stage --- FROM rust:1-bookworm AS builder WORKDIR /build @@ -19,7 +23,7 @@ COPY src/ src/ RUN touch src/main.rs crates/openab-core/src/lib.rs crates/openab-gateway/src/lib.rs crates/openab-mcp/src/lib.rs crates/openab-cp/src/lib.rs && cargo build --release # --- Runtime stage --- -FROM node:22-trixie-slim +FROM node:${NODE22_VERSION}-trixie-slim RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl procps ripgrep tini bubblewrap socat unzip && rm -rf /var/lib/apt/lists/* # Install claude-agent-acp adapter and Claude Code CLI. diff --git a/Dockerfile.codex b/Dockerfile.codex index c73a21031..56bfe4c25 100644 --- a/Dockerfile.codex +++ b/Dockerfile.codex @@ -1,3 +1,7 @@ +# Pinned so the shipped Node.js version is explicit; bump it on Node security +# releases. Passing just the major (e.g. NODE22_VERSION=22) floats to the latest. +ARG NODE22_VERSION=22.23.4 + # --- Build stage --- FROM rust:1-bookworm AS builder WORKDIR /build @@ -19,7 +23,7 @@ COPY src/ src/ RUN touch src/main.rs crates/openab-core/src/lib.rs crates/openab-gateway/src/lib.rs crates/openab-mcp/src/lib.rs crates/openab-cp/src/lib.rs && cargo build --release # --- Runtime stage --- -FROM node:22-trixie-slim +FROM node:${NODE22_VERSION}-trixie-slim RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl procps ripgrep tini bubblewrap unzip && rm -rf /var/lib/apt/lists/* # Pre-install codex-acp and codex CLI globally diff --git a/Dockerfile.copilot b/Dockerfile.copilot index fa56a0ae0..628680cce 100644 --- a/Dockerfile.copilot +++ b/Dockerfile.copilot @@ -1,3 +1,7 @@ +# Pinned so the shipped Node.js version is explicit; bump it on Node security +# releases. Passing just the major (e.g. NODE22_VERSION=22) floats to the latest. +ARG NODE22_VERSION=22.23.4 + # --- Build stage --- FROM rust:1-bookworm AS builder WORKDIR /build @@ -19,7 +23,7 @@ COPY src/ src/ RUN touch src/main.rs crates/openab-core/src/lib.rs crates/openab-gateway/src/lib.rs crates/openab-mcp/src/lib.rs crates/openab-cp/src/lib.rs && cargo build --release # --- Runtime stage --- -FROM node:22-trixie-slim +FROM node:${NODE22_VERSION}-trixie-slim RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl procps ripgrep tini unzip && rm -rf /var/lib/apt/lists/* # Install GitHub Copilot CLI via npm (pinned version) diff --git a/Dockerfile.gemini b/Dockerfile.gemini index 0c2c7e9c2..ed5fe272b 100644 --- a/Dockerfile.gemini +++ b/Dockerfile.gemini @@ -1,3 +1,7 @@ +# Pinned so the shipped Node.js version is explicit; bump it on Node security +# releases. Passing just the major (e.g. NODE22_VERSION=22) floats to the latest. +ARG NODE22_VERSION=22.23.4 + # --- Build stage --- FROM rust:1-bookworm AS builder WORKDIR /build @@ -19,7 +23,7 @@ COPY src/ src/ RUN touch src/main.rs crates/openab-core/src/lib.rs crates/openab-gateway/src/lib.rs crates/openab-mcp/src/lib.rs crates/openab-cp/src/lib.rs && cargo build --release # --- Runtime stage --- -FROM node:22-trixie-slim +FROM node:${NODE22_VERSION}-trixie-slim RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl procps ripgrep tini unzip && rm -rf /var/lib/apt/lists/* # Install Gemini CLI (native ACP support via --acp) diff --git a/Dockerfile.kimi b/Dockerfile.kimi index e68cdd78e..466accd87 100644 --- a/Dockerfile.kimi +++ b/Dockerfile.kimi @@ -1,3 +1,7 @@ +# Pinned so the shipped Node.js version is explicit; bump it on Node security +# releases. Passing just the major (e.g. NODE24_VERSION=24) floats to the latest. +ARG NODE24_VERSION=24.22.1 + # --- Build stage --- FROM rust:1-bookworm AS builder WORKDIR /build @@ -23,7 +27,7 @@ RUN touch src/main.rs crates/openab-core/src/lib.rs crates/openab-gateway/src/li # Kimi Code CLI is published as @moonshot-ai/kimi-code and exposes the # `kimi` binary. Its native ACP adapter is started with `kimi acp`. # Keep the package version pinned so image rebuilds are reproducible. -FROM node:24-trixie-slim +FROM node:${NODE24_VERSION}-trixie-slim RUN apt-get update && apt-get install -y --no-install-recommends \ ca-certificates curl procps ripgrep tini bubblewrap socat unzip git \ && rm -rf /var/lib/apt/lists/* diff --git a/Dockerfile.mimocode b/Dockerfile.mimocode index f5fdfe03e..705fb0fd6 100644 --- a/Dockerfile.mimocode +++ b/Dockerfile.mimocode @@ -1,3 +1,7 @@ +# Pinned so the shipped Node.js version is explicit; bump it on Node security +# releases. Passing just the major (e.g. NODE22_VERSION=22) floats to the latest. +ARG NODE22_VERSION=22.23.4 + # --- Build stage --- FROM rust:1-bookworm AS builder WORKDIR /build @@ -27,7 +31,7 @@ RUN touch src/main.rs crates/openab-core/src/lib.rs crates/openab-gateway/src/li # ACP is supported via `mimo acp` (same JSON-RPC/stdio protocol as OpenCode). # # Version is pinned for reproducible builds. -FROM node:22-trixie-slim +FROM node:${NODE22_VERSION}-trixie-slim RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl procps ripgrep tini unzip && rm -rf /var/lib/apt/lists/* # Install MiMo-Code diff --git a/Dockerfile.opencode b/Dockerfile.opencode index 4333e25ac..ac17da44c 100644 --- a/Dockerfile.opencode +++ b/Dockerfile.opencode @@ -1,3 +1,7 @@ +# Pinned so the shipped Node.js version is explicit; bump it on Node security +# releases. Passing just the major (e.g. NODE22_VERSION=22) floats to the latest. +ARG NODE22_VERSION=22.23.4 + # --- Build stage --- FROM rust:1-bookworm AS builder WORKDIR /build @@ -19,9 +23,9 @@ COPY src/ src/ RUN touch src/main.rs crates/openab-core/src/lib.rs crates/openab-gateway/src/lib.rs crates/openab-mcp/src/lib.rs crates/openab-cp/src/lib.rs && cargo build --release # --- Runtime stage --- -# node:22-trixie-slim mirrors the base image used by Dockerfile.claude, -# Dockerfile.codex, and Dockerfile.gemini, keeping the project on a single -# consistent runtime base. +# node:${NODE22_VERSION}-trixie-slim mirrors the base image used by +# Dockerfile.claude, Dockerfile.codex, and Dockerfile.gemini, keeping the +# project on a single consistent runtime base. # # opencode is published to npm as `opencode-ai`; the npm package is a thin # postinstall wrapper (~9 KB) that downloads the same pre-compiled binary @@ -44,7 +48,7 @@ RUN touch src/main.rs crates/openab-core/src/lib.rs crates/openab-gateway/src/li # The upstream fix landed in 1.17.0 (sst/opencode#30332, # "fix(opencode): generate reasoning variants for all OpenRouter models"). # 1.17.3 is the latest 1.17.x verified by users to recover correctly. -FROM node:22-trixie-slim +FROM node:${NODE22_VERSION}-trixie-slim RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl procps ripgrep tini unzip && rm -rf /var/lib/apt/lists/* # Install opencode diff --git a/Dockerfile.package b/Dockerfile.package index c5016fd49..ffa44e3aa 100644 --- a/Dockerfile.package +++ b/Dockerfile.package @@ -6,6 +6,11 @@ # # Usage: docker build --target -f Dockerfile.package --build-context bins=./bin . +# Pinned so the shipped Node.js version is explicit; bump it on Node security +# releases. Nightly passes just the major (22 / 24) to float to the latest. +ARG NODE22_VERSION=22.23.4 +ARG NODE24_VERSION=24.22.1 + # ============================================================================= # Stage: bins — pre-built binaries passed in via build context # ============================================================================= @@ -30,7 +35,7 @@ RUN useradd -m -s /bin/bash -u 1000 agent # ============================================================================= # Stage: base-node — shared runtime base for node-based agents # ============================================================================= -FROM node:22-trixie-slim AS base-node +FROM node:${NODE22_VERSION}-trixie-slim AS base-node RUN apt-get update && apt-get install -y --no-install-recommends \ ca-certificates curl procps ripgrep tini bubblewrap socat unzip git \ && rm -rf /var/lib/apt/lists/* @@ -133,7 +138,7 @@ CMD ["openab", "run", "-c", "/etc/openab/config.toml"] # ============================================================================= # Target: kimi # ============================================================================= -FROM node:24-trixie-slim AS kimi +FROM node:${NODE24_VERSION}-trixie-slim AS kimi RUN apt-get update && apt-get install -y --no-install-recommends \ ca-certificates curl procps ripgrep tini bubblewrap socat unzip git \ && rm -rf /var/lib/apt/lists/* diff --git a/Dockerfile.pi b/Dockerfile.pi index ae5fb99cc..f219aba44 100644 --- a/Dockerfile.pi +++ b/Dockerfile.pi @@ -1,3 +1,7 @@ +# Pinned so the shipped Node.js version is explicit; bump it on Node security +# releases. Passing just the major (e.g. NODE22_VERSION=22) floats to the latest. +ARG NODE22_VERSION=22.23.4 + # --- Build stage --- FROM rust:1-bookworm AS builder WORKDIR /build @@ -19,7 +23,7 @@ COPY src/ src/ RUN touch src/main.rs crates/openab-core/src/lib.rs crates/openab-gateway/src/lib.rs crates/openab-mcp/src/lib.rs crates/openab-cp/src/lib.rs && cargo build --release # --- Runtime stage --- -FROM node:22-trixie-slim +FROM node:${NODE22_VERSION}-trixie-slim RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl git procps ripgrep tini bubblewrap socat unzip && rm -rf /var/lib/apt/lists/* # Install pi-acp adapter and Pi coding agent CLI. diff --git a/Dockerfile.unified b/Dockerfile.unified index 4b802e1d4..91aa279db 100644 --- a/Dockerfile.unified +++ b/Dockerfile.unified @@ -14,6 +14,11 @@ # Global ARG — must be declared before first FROM for use in FROM instructions ARG BUILDER_IMAGE=local_builder +# Pinned so the shipped Node.js version is explicit; bump it on Node security +# releases. Passing just the major (e.g. NODE22_VERSION=22) floats to the latest. +ARG NODE22_VERSION=22.23.4 +ARG NODE24_VERSION=24.22.1 + # ============================================================================= # Stage: local_builder — compile openab binary (unified mode) # Only executed during build-core or local dev. When BUILDER_IMAGE is overridden @@ -81,7 +86,7 @@ RUN useradd -m -s /bin/bash -u 1000 agent # ============================================================================= # Stage: base-node — shared runtime base for node-based agents # ============================================================================= -FROM node:22-trixie-slim AS base-node +FROM node:${NODE22_VERSION}-trixie-slim AS base-node RUN apt-get update && apt-get install -y --no-install-recommends \ ca-certificates curl procps ripgrep tini bubblewrap socat unzip git \ && rm -rf /var/lib/apt/lists/* @@ -168,7 +173,7 @@ CMD ["openab", "run", "-c", "/etc/openab/config.toml"] # ============================================================================= # Target: kimi # ============================================================================= -FROM node:24-trixie-slim AS kimi +FROM node:${NODE24_VERSION}-trixie-slim AS kimi RUN apt-get update && apt-get install -y --no-install-recommends \ ca-certificates curl procps ripgrep tini bubblewrap socat unzip git \ && rm -rf /var/lib/apt/lists/* diff --git a/docs/opencode.md b/docs/opencode.md index f02d538d3..38d647856 100644 --- a/docs/opencode.md +++ b/docs/opencode.md @@ -28,7 +28,7 @@ OpenCode supports [75+ LLM providers](https://opencode.ai/docs/providers/) via t docker build -f Dockerfile.opencode -t openab-opencode:latest . ``` -The image installs `opencode-ai` globally via npm on `node:22-bookworm-slim`. +The image installs `opencode-ai` globally via npm on `node:-trixie-slim`, where `NODE22_VERSION` is the pinned Node.js 22 release in `Dockerfile.opencode`. ## Helm Install From 5ae17536a517c9f6dc39ae326dcf8b37ef94163a Mon Sep 17 00:00:00 2001 From: chaodu-agent Date: Fri, 23 Oct 2026 02:31:40 +0000 Subject: [PATCH 2/9] fix(review): derive Node.js pin checks from Dockerfiles, record nightly Node version Address round-1 review findings on #1570: - F1: Replace the hardcoded 8-agent allowlist and kimi case mapping in both smoke-test workflows with .github/scripts/node_base_arg.sh, which resolves the NODE_VERSION ARG from the (target) stage's base image, following stage inheritance. Non-Node images skip; an unpinned FROM node: fails. validate-packaged-pins now also requires every Dockerfile built FROM node:${NODE_VERSION} to carry the same pin as Dockerfile.unified, so a new Node-based Dockerfile cannot fail open or ship a different version. - F2: Nightly records the Node.js version that actually shipped for each Node-based variant/arch in the job summary (warn-only; the float is intentional). - F3: Generalize the Dockerfile.opencode base-image comment instead of listing a partial set of sibling Dockerfiles. --- .github/scripts/node_base_arg.sh | 48 +++++++++++++++++++ .../workflows/docker-smoke-test-unified.yml | 11 +++-- .github/workflows/docker-smoke-test.yml | 24 ++++++++-- .github/workflows/nightly-build.yml | 21 ++++++++ Dockerfile.opencode | 4 +- 5 files changed, 96 insertions(+), 12 deletions(-) create mode 100755 .github/scripts/node_base_arg.sh diff --git a/.github/scripts/node_base_arg.sh b/.github/scripts/node_base_arg.sh new file mode 100755 index 000000000..9e9bf35fd --- /dev/null +++ b/.github/scripts/node_base_arg.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# Print the Node.js version ARG (e.g. NODE22_VERSION) that a Dockerfile stage +# is ultimately built FROM, so CI can verify every Node-based image against its +# pin without a hand-maintained allowlist of agent names. +# +# Usage: node_base_arg.sh [target] +# target stage name to resolve; defaults to the last stage in the file. +# +# Output / exit status: +# prints NODE_VERSION, exit 0 -> stage resolves to node:${NODE_VERSION}-... +# prints nothing, exit 0 -> stage is not Node-based +# error message, exit 1 -> unpinned node: base, or unknown target +set -euo pipefail + +dockerfile=${1:?usage: node_base_arg.sh [target]} +target=${2:-} + +awk -v target="$target" ' + toupper($1) == "FROM" { + image = ""; name = "" + for (i = 2; i <= NF; i++) { + if ($i ~ /^--/) continue # skip --platform=... + if (image == "") { image = $i; continue } + if (toupper($i) == "AS" && i < NF) { name = $(i + 1); break } + } + if (name != "") by_name[name] = image + last_image = image + } + END { + if (target == "") { + image = last_image + } else if (target in by_name) { + image = by_name[target] + } else { + print "::error::Stage \"" target "\" not found in " FILENAME > "/dev/stderr" + exit 1 + } + # Follow stage-to-stage inheritance (e.g. claude -> base-node -> node:...). + for (hops = 0; (image in by_name) && hops < 64; hops++) image = by_name[image] + if (match(image, /^node:[$][{]NODE[0-9]+_VERSION[}]-/)) { + arg = substr(image, 8, RLENGTH - 9) # strip "node:${" and "}-" + print arg + } else if (image ~ /^node:/) { + print "::error::" FILENAME " uses unpinned base image " image "; use node:${NODE_VERSION}-..." > "/dev/stderr" + exit 1 + } + } +' "$dockerfile" diff --git a/.github/workflows/docker-smoke-test-unified.yml b/.github/workflows/docker-smoke-test-unified.yml index b456baca7..3e1bae060 100644 --- a/.github/workflows/docker-smoke-test-unified.yml +++ b/.github/workflows/docker-smoke-test-unified.yml @@ -10,6 +10,7 @@ on: - 'Cargo.*' - 'openab-agent/**' - 'agy-acp/**' + - '.github/scripts/node_base_arg.sh' concurrency: group: smoke-test-unified-${{ github.event.pull_request.number }} @@ -86,14 +87,14 @@ jobs: run: docker run --rm --entrypoint which openab-unified-${{ matrix.target.name }}:test ${{ matrix.target.agent }} - name: Verify Node.js runtime version - if: contains(fromJSON('["claude","codex","copilot","gemini","kimi","mimocode","opencode","pi"]'), matrix.target.name) shell: bash run: | set -euo pipefail - case "${{ matrix.target.name }}" in - kimi) arg=NODE24_VERSION ;; - *) arg=NODE22_VERSION ;; - esac + # Derive the pin from the target stage's base image rather than an + # agent allowlist, so a new Node-based target cannot skip this check, + # and an unpinned FROM node: fails here. + arg=$(.github/scripts/node_base_arg.sh Dockerfile.unified ${{ matrix.target.name }}) + if [ -z "$arg" ]; then echo "Not a Node.js-based image; skipping."; exit 0; fi expected=$(awk -F= -v key="ARG $arg" '$1 == key {print $2; exit}' Dockerfile.unified) test -n "$expected" || { echo "::error::Failed to extract $arg from Dockerfile.unified"; exit 1; } actual=$(docker run --rm --entrypoint node openab-unified-${{ matrix.target.name }}:test --version) diff --git a/.github/workflows/docker-smoke-test.yml b/.github/workflows/docker-smoke-test.yml index 9a1a586ac..69313d4b0 100644 --- a/.github/workflows/docker-smoke-test.yml +++ b/.github/workflows/docker-smoke-test.yml @@ -8,6 +8,7 @@ on: - 'src/**' - 'crates/**' - 'Cargo.*' + - '.github/scripts/node_base_arg.sh' concurrency: group: smoke-test-${{ github.event.pull_request.number }} @@ -55,14 +56,14 @@ jobs: run: docker run --rm --entrypoint which openab-test${{ matrix.variant.suffix }} ${{ matrix.variant.agent }} - name: Verify Node.js runtime version - if: contains(fromJSON('["-claude","-codex","-gemini","-copilot","-opencode","-kimi","-mimocode","-pi"]'), matrix.variant.suffix) shell: bash run: | set -euo pipefail - case "${{ matrix.variant.suffix }}" in - -kimi) arg=NODE24_VERSION ;; - *) arg=NODE22_VERSION ;; - esac + # Derive the pin from the Dockerfile's base image rather than an agent + # allowlist, so a new Node-based variant cannot skip this check, and an + # unpinned FROM node: fails here. + arg=$(.github/scripts/node_base_arg.sh ${{ matrix.variant.dockerfile }}) + if [ -z "$arg" ]; then echo "Not a Node.js-based image; skipping."; exit 0; fi expected=$(awk -F= -v key="ARG $arg" '$1 == key {print $2; exit}' ${{ matrix.variant.dockerfile }}) test -n "$expected" || { echo "::error::Failed to extract $arg from ${{ matrix.variant.dockerfile }}"; exit 1; } actual=$(docker run --rm --entrypoint node openab-test${{ matrix.variant.suffix }} --version) @@ -186,6 +187,19 @@ jobs: assert_line 'ARG NODE22_VERSION=22.23.4' Dockerfile.claude Dockerfile.codex Dockerfile.copilot Dockerfile.gemini Dockerfile.mimocode Dockerfile.opencode Dockerfile.pi Dockerfile.package Dockerfile.unified assert_line 'ARG NODE24_VERSION=24.22.1' Dockerfile.kimi Dockerfile.package Dockerfile.unified + # Any Dockerfile built FROM node:${NODE_VERSION} must carry the same + # pin as Dockerfile.unified, so a Node-based Dockerfile missing from the + # lists above cannot ship a different version; a literal node: tag fails. + while IFS= read -r f; do + if grep -n '^FROM node:[^$]' "$f"; then + echo "::error::$f uses an unpinned node: base image; use node:\${NODE_VERSION}-..." >&2 + exit 1 + fi + for arg in $(grep -o '^FROM node:[$]{NODE[0-9]*_VERSION}' "$f" | sed 's/^FROM node:[$]{//; s/}$//' | sort -u); do + pin=$(grep -m1 "^ARG $arg=" Dockerfile.unified) || { echo "::error::$arg is not pinned in Dockerfile.unified" >&2; exit 1; } + assert_line "$pin" "$f" + done + done < <(git grep -l '^FROM node:' -- 'Dockerfile*') assert_line 'ARG KIRO_CLI_VERSION=2.25.0' Dockerfile Dockerfile.package Dockerfile.unified assert_line 'ARG KIRO_SHA256_AMD64=3e924de9697717b99891bb5f7d2a4bd9c768b8bfa1c498b35b3d242948512984' Dockerfile Dockerfile.package Dockerfile.unified assert_line 'ARG KIRO_SHA256_ARM64=cc8a2df90cd354b569b81ed4653db4796c86a69866568b4eb7fa7906afc95309' Dockerfile Dockerfile.package Dockerfile.unified diff --git a/.github/workflows/nightly-build.yml b/.github/workflows/nightly-build.yml index 6ee492243..7065dec0c 100644 --- a/.github/workflows/nightly-build.yml +++ b/.github/workflows/nightly-build.yml @@ -420,6 +420,27 @@ jobs: no-cache: true outputs: type=image,name=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true + - name: Report installed Node.js version + # Nightly floats the Node.js patch (NODE_VERSION=, see resolve). + # Record what actually shipped so a red canary or a rollback can be + # triaged without pulling the image. Warn-only: the float is intentional. + env: + DIGEST: ${{ steps.build.outputs.digest }} + ARCH: ${{ matrix.platform.arch }} + VARIANT: ${{ matrix.variant }} + run: | + set -uo pipefail + arg=$(.github/scripts/node_base_arg.sh Dockerfile.package "$VARIANT") || { + echo "::warning::Could not resolve the Node.js base for $VARIANT"; exit 0; } + if [ -z "$arg" ]; then echo "$VARIANT is not Node.js-based; skipping."; exit 0; fi + IMAGE="${REGISTRY}/${IMAGE_NAME}@${DIGEST}" + if docker pull -q "$IMAGE" >/dev/null && VERSION=$(docker run --rm --entrypoint node "$IMAGE" --version); then + echo "Node.js ${VARIANT} ${ARCH}: ${VERSION}" + echo "- Node.js \`${VARIANT}\` (${ARCH}, ${arg}): \`${VERSION}\`" >> "$GITHUB_STEP_SUMMARY" + else + echo "::warning::Could not read node --version from $IMAGE" + fi + - name: Verify and report installed Kiro version if: matrix.variant == 'kiro' env: diff --git a/Dockerfile.opencode b/Dockerfile.opencode index ac17da44c..05a85d95b 100644 --- a/Dockerfile.opencode +++ b/Dockerfile.opencode @@ -23,8 +23,8 @@ COPY src/ src/ RUN touch src/main.rs crates/openab-core/src/lib.rs crates/openab-gateway/src/lib.rs crates/openab-mcp/src/lib.rs crates/openab-cp/src/lib.rs && cargo build --release # --- Runtime stage --- -# node:${NODE22_VERSION}-trixie-slim mirrors the base image used by -# Dockerfile.claude, Dockerfile.codex, and Dockerfile.gemini, keeping the +# node:${NODE22_VERSION}-trixie-slim mirrors the base image used by every +# Node 22-based agent Dockerfile (all pinned via NODE22_VERSION), keeping the # project on a single consistent runtime base. # # opencode is published to npm as `opencode-ai`; the npm package is a thin From 1f1fec49ae4b01a41bcd38eb5baa43d3da3fab98 Mon Sep 17 00:00:00 2001 From: yen <5915590+antigenius0910@users.noreply.github.com> Date: Thu, 22 Oct 2026 21:51:23 -0500 Subject: [PATCH 3/9] chore(docker): drop hard-coded Node pin assertions The derived loop in validate-packaged-pins already enforces that every Dockerfile built FROM node:${NODE_VERSION} carries the same pin as Dockerfile.unified. The two literal assert_line lines added only the version itself, which forced a workflow edit on every Node bump. That contradicts the Dockerfile-only bump described in RELEASING.md (#1572) and in the node-security-check issue text (#1571). Verified with the extracted step script: the tree as committed passes, bumping all nine NODE22 Dockerfiles to 22.23.5 passes, and bumping only Dockerfile.pi fails. Refs #1567 Co-Authored-By: Claude Opus 5.5 --- .github/workflows/docker-smoke-test.yml | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/.github/workflows/docker-smoke-test.yml b/.github/workflows/docker-smoke-test.yml index 69313d4b0..0fe134659 100644 --- a/.github/workflows/docker-smoke-test.yml +++ b/.github/workflows/docker-smoke-test.yml @@ -185,11 +185,9 @@ jobs: } } - assert_line 'ARG NODE22_VERSION=22.23.4' Dockerfile.claude Dockerfile.codex Dockerfile.copilot Dockerfile.gemini Dockerfile.mimocode Dockerfile.opencode Dockerfile.pi Dockerfile.package Dockerfile.unified - assert_line 'ARG NODE24_VERSION=24.22.1' Dockerfile.kimi Dockerfile.package Dockerfile.unified # Any Dockerfile built FROM node:${NODE_VERSION} must carry the same - # pin as Dockerfile.unified, so a Node-based Dockerfile missing from the - # lists above cannot ship a different version; a literal node: tag fails. + # pin as Dockerfile.unified, so a Node bump edits only Dockerfiles and a + # partial bump fails; a literal node: tag fails. while IFS= read -r f; do if grep -n '^FROM node:[^$]' "$f"; then echo "::error::$f uses an unpinned node: base image; use node:\${NODE_VERSION}-..." >&2 From f18717d8bec2462bad918c124a66878860d02010 Mon Sep 17 00:00:00 2001 From: yen <5915590+antigenius0910@users.noreply.github.com> Date: Thu, 22 Oct 2026 22:12:49 -0500 Subject: [PATCH 4/9] chore(docker): make node_base_arg.sh the only Node FROM parser validate-packaged-pins found Node-based Dockerfiles and unpinned bases with its own ^FROM node: regex. node_base_arg.sh already skips --platform and matches FROM in any case, and the parity gate's regex did neither. An unpinned FROM --platform=... node: or from node: in Dockerfile.package, which has no PR-time smoke test, therefore passed silently. node_base_arg.sh --all now checks every FROM line in a file. It prints each NODE_VERSION in use and fails on any unpinned node: base. The parity gate runs it on every tracked Dockerfile, including those in subdirectories, so the gate and the runtime check share one parser. The file list is assigned before the loop, so a git failure stops the step instead of skipping every check. Inline fixtures pin the two reported forms and a pinned --platform base as regression cases. Verified with the extracted step in ubuntu:24.04: - passes as committed and after a full NODE22 bump - fails on a partial bump, a missing Dockerfile.unified pin, an unpinned --platform or lowercase from base in Dockerfile.package, an unpinned base in openshell/Dockerfile, and outside a git repo Refs #1567 Co-Authored-By: Claude Opus 5.5 --- .github/scripts/node_base_arg.sh | 21 +++++++++++++++++++-- .github/workflows/docker-smoke-test.yml | 25 ++++++++++++++++++------- 2 files changed, 37 insertions(+), 9 deletions(-) diff --git a/.github/scripts/node_base_arg.sh b/.github/scripts/node_base_arg.sh index 9e9bf35fd..85c1c30a5 100755 --- a/.github/scripts/node_base_arg.sh +++ b/.github/scripts/node_base_arg.sh @@ -4,7 +4,10 @@ # pin without a hand-maintained allowlist of agent names. # # Usage: node_base_arg.sh [target] +# node_base_arg.sh --all # target stage name to resolve; defaults to the last stage in the file. +# --all check every FROM line instead of one stage: print each +# NODE_VERSION used (one per line), fail on any unpinned node: base. # # Output / exit status: # prints NODE_VERSION, exit 0 -> stage resolves to node:${NODE_VERSION}-... @@ -12,10 +15,12 @@ # error message, exit 1 -> unpinned node: base, or unknown target set -euo pipefail -dockerfile=${1:?usage: node_base_arg.sh [target]} +all=0 +if [ "${1:-}" = "--all" ]; then all=1; shift; fi +dockerfile=${1:?usage: node_base_arg.sh [--all] [target]} target=${2:-} -awk -v target="$target" ' +awk -v target="$target" -v all="$all" ' toupper($1) == "FROM" { image = ""; name = "" for (i = 2; i <= NF; i++) { @@ -25,8 +30,20 @@ awk -v target="$target" ' } if (name != "") by_name[name] = image last_image = image + if (all) { + if (match(image, /^node:[$][{]NODE[0-9]+_VERSION[}]-/)) used[substr(image, 8, RLENGTH - 9)] = 1 + else if (image ~ /^node:/) { + print "::error::" FILENAME ":" FNR " uses unpinned base image " image "; use node:${NODE_VERSION}-..." > "/dev/stderr" + bad = 1 + } + } } END { + if (all) { + if (bad) exit 1 + for (arg in used) print arg + exit 0 + } if (target == "") { image = last_image } else if (target in by_name) { diff --git a/.github/workflows/docker-smoke-test.yml b/.github/workflows/docker-smoke-test.yml index 0fe134659..ac1ce11b6 100644 --- a/.github/workflows/docker-smoke-test.yml +++ b/.github/workflows/docker-smoke-test.yml @@ -188,16 +188,27 @@ jobs: # Any Dockerfile built FROM node:${NODE_VERSION} must carry the same # pin as Dockerfile.unified, so a Node bump edits only Dockerfiles and a # partial bump fails; a literal node: tag fails. - while IFS= read -r f; do - if grep -n '^FROM node:[^$]' "$f"; then - echo "::error::$f uses an unpinned node: base image; use node:\${NODE_VERSION}-..." >&2 - exit 1 - fi - for arg in $(grep -o '^FROM node:[$]{NODE[0-9]*_VERSION}' "$f" | sed 's/^FROM node:[$]{//; s/}$//' | sort -u); do + # node_base_arg.sh --all is the only Dockerfile parser, shared with the + # runtime check, so both accept the same FROM forms. + dockerfiles=$(git ls-files 'Dockerfile*' '*/Dockerfile*') + [ -n "$dockerfiles" ] || { echo "::error::no Dockerfiles found" >&2; exit 1; } + for f in $dockerfiles; do + args=$(.github/scripts/node_base_arg.sh --all "$f") + for arg in $(sort -u <<<"$args"); do pin=$(grep -m1 "^ARG $arg=" Dockerfile.unified) || { echo "::error::$arg is not pinned in Dockerfile.unified" >&2; exit 1; } assert_line "$pin" "$f" done - done < <(git grep -l '^FROM node:' -- 'Dockerfile*') + done + fixture=$(mktemp) + for unpinned in 'FROM --platform=$TARGETPLATFORM node:22-trixie-slim AS x' 'from node:22-trixie-slim as x'; do + printf '%s\n' "$unpinned" > "$fixture" + if .github/scripts/node_base_arg.sh --all "$fixture" 2>/dev/null; then + echo "::error::node_base_arg.sh --all accepted an unpinned base: $unpinned" >&2 + exit 1 + fi + done + printf '%s\n' 'ARG NODE22_VERSION=22.0.0' 'FROM --platform=$BUILDPLATFORM node:${NODE22_VERSION}-trixie-slim AS x' > "$fixture" + [ "$(.github/scripts/node_base_arg.sh --all "$fixture")" = NODE22_VERSION ] || { echo "::error::node_base_arg.sh --all missed a pinned --platform base" >&2; exit 1; } assert_line 'ARG KIRO_CLI_VERSION=2.25.0' Dockerfile Dockerfile.package Dockerfile.unified assert_line 'ARG KIRO_SHA256_AMD64=3e924de9697717b99891bb5f7d2a4bd9c768b8bfa1c498b35b3d242948512984' Dockerfile Dockerfile.package Dockerfile.unified assert_line 'ARG KIRO_SHA256_ARM64=cc8a2df90cd354b569b81ed4653db4796c86a69866568b4eb7fa7906afc95309' Dockerfile Dockerfile.package Dockerfile.unified From 5cb31c34fd4033abfa2fb94855960c30696543df Mon Sep 17 00:00:00 2001 From: chaodu-agent Date: Fri, 23 Oct 2026 03:13:28 +0000 Subject: [PATCH 5/9] fix(review): make node_base_arg.sh the single fail-closed Node base parser Address round-3 F1 on #1570: the parity gate kept its own `^FROM node:` regex, so `FROM --platform=... node:`, lowercase `from node:`, digest-only, registry-qualified, or build-arg Node bases passed silently, and the resolver itself skipped several of those forms. - node_base_arg.sh classifies every FROM in the file. Any reference to a Node.js image (node:, node@, library/node, docker.io/library/node) that is not node:${NODE_VERSION}- fails, as does FROM ${VAR} unless VAR is allowlisted (BUILDER_IMAGE). A missing tag suffix gets its own message. Stage names are matched case-insensitively, as Docker does. - New --check mode prints " NODE_VERSION" per pin used. validate-packaged-pins now uses it over every tracked Dockerfile (including nested ones) instead of a second grep parser, fails if no Node-based Dockerfile is found, requires a full x.y.z pin in Dockerfile.unified, and names the file and expected pin on mismatch. - Add test_node_base_arg.sh (21 fixtures plus the current repository targets) and run it in validate-packaged-pins. --- .github/scripts/node_base_arg.sh | 139 ++++++++++++++---------- .github/scripts/test_node_base_arg.sh | 72 ++++++++++++ .github/workflows/docker-smoke-test.yml | 40 +++---- 3 files changed, 172 insertions(+), 79 deletions(-) create mode 100755 .github/scripts/test_node_base_arg.sh diff --git a/.github/scripts/node_base_arg.sh b/.github/scripts/node_base_arg.sh index 85c1c30a5..ed5fb6a38 100755 --- a/.github/scripts/node_base_arg.sh +++ b/.github/scripts/node_base_arg.sh @@ -1,65 +1,94 @@ #!/usr/bin/env bash -# Print the Node.js version ARG (e.g. NODE22_VERSION) that a Dockerfile stage -# is ultimately built FROM, so CI can verify every Node-based image against its -# pin without a hand-maintained allowlist of agent names. +# Single parser for Node.js base images in Dockerfiles. CI uses it both to +# decide which images need a runtime `node --version` check and to enforce that +# every Node.js base is pinned as node:${NODE_VERSION}-. # -# Usage: node_base_arg.sh [target] -# node_base_arg.sh --all -# target stage name to resolve; defaults to the last stage in the file. -# --all check every FROM line instead of one stage: print each -# NODE_VERSION used (one per line), fail on any unpinned node: base. +# Usage: +# node_base_arg.sh [target] +# Print the NODE_VERSION ARG that (default: the last stage) +# is ultimately built FROM, or nothing if it is not Node-based. +# node_base_arg.sh --check ... +# Validate every stage of every file and print " NODE_VERSION" +# once per pin each file uses. # -# Output / exit status: -# prints NODE_VERSION, exit 0 -> stage resolves to node:${NODE_VERSION}-... -# prints nothing, exit 0 -> stage is not Node-based -# error message, exit 1 -> unpinned node: base, or unknown target +# Both modes scan every FROM in the file and exit 1 if any stage: +# - references a Node.js image (node:, node@, library/node, docker.io/library/node) +# not written as node:${NODE_VERSION}-; or +# - takes its base from a build arg (FROM ${VAR}) other than an allowlisted one, +# since that can hide an unpinned Node.js base. +# The first mode also fails if is not a stage in the file. +# Stage names are matched case-insensitively, as Docker does. set -euo pipefail -all=0 -if [ "${1:-}" = "--all" ]; then all=1; shift; fi -dockerfile=${1:?usage: node_base_arg.sh [--all] [target]} -target=${2:-} +# Build args that may be used directly as a base image. BUILDER_IMAGE selects +# the openab builder in Dockerfile.unified and is never a Node.js runtime base. +ALLOWED_VAR_BASES="BUILDER_IMAGE" -awk -v target="$target" -v all="$all" ' - toupper($1) == "FROM" { - image = ""; name = "" - for (i = 2; i <= NF; i++) { - if ($i ~ /^--/) continue # skip --platform=... - if (image == "") { image = $i; continue } - if (toupper($i) == "AS" && i < NF) { name = $(i + 1); break } - } - if (name != "") by_name[name] = image - last_image = image - if (all) { - if (match(image, /^node:[$][{]NODE[0-9]+_VERSION[}]-/)) used[substr(image, 8, RLENGTH - 9)] = 1 - else if (image ~ /^node:/) { - print "::error::" FILENAME ":" FNR " uses unpinned base image " image "; use node:${NODE_VERSION}-..." > "/dev/stderr" - bad = 1 +parse() { # parse [target] + awk -v mode="$1" -v target="${3:-}" -v allowed=" $ALLOWED_VAR_BASES " ' + # Classify one FROM image reference. Returns "node ", "none", or + # "error ". Stage references inherit the referenced stage. + function classify(img, key, ref, var) { + key = tolower(img) + if (key in stage) return stage[key] + if (img ~ /^[$][{]?[A-Za-z_][A-Za-z0-9_]*[}]?$/) { + var = img; gsub(/[${}]/, "", var) + if (index(allowed, " " var " ")) return "none" + return "error FROM " img " takes its base image from a build arg, which can hide an unpinned Node.js base; use node:${NODE_VERSION}- or a named stage (allowed build args:" allowed ")" } + ref = img + if (tolower(substr(ref, 1, 10)) == "docker.io/") ref = substr(ref, 11) + if (tolower(substr(ref, 1, 8)) == "library/") ref = substr(ref, 9) + if (match(ref, /^node:[$][{]NODE[0-9]+_VERSION[}]-./)) + return "node " substr(ref, 8, index(ref, "}") - 8) # text between "node:${" and "}" + if (ref ~ /^node:[$][{]NODE[0-9]+_VERSION[}]$/) + return "error " img " has no tag suffix; use node:${NODE_VERSION}- (e.g. -trixie-slim)" + if (tolower(ref) ~ /^node([:@]|$)/) + return "error " img " is not pinned; use node:${NODE_VERSION}-" + return "none" } - } - END { - if (all) { - if (bad) exit 1 - for (arg in used) print arg - exit 0 - } - if (target == "") { - image = last_image - } else if (target in by_name) { - image = by_name[target] - } else { - print "::error::Stage \"" target "\" not found in " FILENAME > "/dev/stderr" - exit 1 + toupper($1) == "FROM" { + img = ""; name = "" + for (i = 2; i <= NF; i++) { + if ($i ~ /^--/) continue # skip --platform=... and other flags + if (img == "") { img = $i; continue } + if (toupper($i) == "AS" && i < NF) { name = tolower($(i + 1)); break } + } + class = classify(img) + if (class ~ /^error / && !(tolower(img) in stage)) { # report each bad base once, not per child stage + print "::error file=" FILENAME ",line=" FNR "::" substr(class, 7) > "/dev/stderr" + failed = 1 + } + if (class ~ /^node /) used[substr(class, 6)] = 1 + if (name != "") stage[name] = class + last = class } - # Follow stage-to-stage inheritance (e.g. claude -> base-node -> node:...). - for (hops = 0; (image in by_name) && hops < 64; hops++) image = by_name[image] - if (match(image, /^node:[$][{]NODE[0-9]+_VERSION[}]-/)) { - arg = substr(image, 8, RLENGTH - 9) # strip "node:${" and "}-" - print arg - } else if (image ~ /^node:/) { - print "::error::" FILENAME " uses unpinned base image " image "; use node:${NODE_VERSION}-..." > "/dev/stderr" - exit 1 + END { + if (failed) exit 1 + if (mode == "check") { + for (arg in used) print FILENAME, arg + exit 0 + } + if (target == "") { + class = last + } else if (tolower(target) in stage) { + class = stage[tolower(target)] + } else { + print "::error::Stage \"" target "\" not found in " FILENAME > "/dev/stderr" + exit 1 + } + if (class ~ /^node /) print substr(class, 6) } - } -' "$dockerfile" + ' "$2" +} + +if [ "${1:-}" = "--check" ]; then + shift + [ "$#" -gt 0 ] || { echo "usage: node_base_arg.sh --check ..." >&2; exit 2; } + rc=0 + for f in "$@"; do parse check "$f" || rc=1; done # report every bad file, then fail + exit "$rc" +fi + +[ "$#" -ge 1 ] && [ "$#" -le 2 ] || { echo "usage: node_base_arg.sh [target]" >&2; exit 2; } +parse resolve "$1" "${2:-}" diff --git a/.github/scripts/test_node_base_arg.sh b/.github/scripts/test_node_base_arg.sh new file mode 100755 index 000000000..e4e0d8ce0 --- /dev/null +++ b/.github/scripts/test_node_base_arg.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +# Regression tests for node_base_arg.sh. Run from the repository root: +# .github/scripts/test_node_base_arg.sh +set -euo pipefail + +script=.github/scripts/node_base_arg.sh +tmp=$(mktemp -d) +trap 'rm -rf "$tmp"' EXIT +failures=0 + +# expect [script args...] +# The Dockerfile body is written to a fixture; "@" in args is replaced by its path. +expect() { + local name=$1 want_rc=$2 want_out=$3 want_err=$4 body=$5 + shift 5 + local file="$tmp/$name.Dockerfile" args=() rc=0 out err + printf '%b' "$body" > "$file" + for a in "$@"; do args+=("${a//@/$file}"); done + out=$("$script" "${args[@]}" 2> "$tmp/err") || rc=$? + err=$(cat "$tmp/err") + if [ "$rc" != "$want_rc" ] || [ "$out" != "$want_out" ] || [[ "$err" != *"$want_err"* ]]; then + echo "FAIL $name: rc=$rc (want $want_rc) out=[$out] (want [$want_out]) err=[$err] (want *$want_err*)" + failures=$((failures + 1)) + else + echo "ok $name" + fi +} + +# Resolution of pinned bases +expect stage-chain 0 NODE22_VERSION '' 'FROM node:${NODE22_VERSION}-trixie-slim AS base-node\nFROM base-node AS claude\n' @ claude +expect last-stage 0 NODE24_VERSION '' 'FROM rust AS builder\nFROM node:${NODE24_VERSION}-trixie-slim\n' @ +expect flags-and-case 0 NODE22_VERSION '' 'from --platform=$BUILDPLATFORM node:${NODE22_VERSION}-trixie-slim as base\nFROM base As app\n' @ app +expect stage-name-case 0 NODE22_VERSION '' 'FROM node:${NODE22_VERSION}-slim AS Base\nFROM base AS app\n' @ app +expect target-case 0 NODE22_VERSION '' 'FROM node:${NODE22_VERSION}-slim AS App\n' @ app +expect registry-pinned 0 NODE22_VERSION '' 'FROM docker.io/library/node:${NODE22_VERSION}-slim AS app\n' @ app +expect digest-on-pin 0 NODE22_VERSION '' 'FROM node:${NODE22_VERSION}-slim@sha256:0000 AS app\n' @ app +expect non-node 0 '' '' 'FROM debian:trixie-slim AS base\nFROM base AS app\n' @ app +expect allowed-var 0 '' '' 'FROM ${BUILDER_IMAGE} AS builder\nFROM debian AS app\n' @ app + +# Fail closed on Node.js bases that are not pinned through the ARG +expect literal-tag 1 '' 'is not pinned' 'FROM node:22-trixie-slim AS app\n' @ app +expect digest-only 1 '' 'is not pinned' 'FROM node@sha256:0000 AS app\n' @ app +expect registry-tag 1 '' 'is not pinned' 'FROM docker.io/library/node:22-trixie-slim AS app\n' @ app +expect library-tag 1 '' 'is not pinned' 'FROM library/node:22-trixie-slim AS app\n' @ app +expect bare-node 1 '' 'is not pinned' 'FROM node AS app\n' @ app +expect missing-suffix 1 '' 'has no tag suffix' 'FROM node:${NODE22_VERSION} AS app\n' @ app +expect var-base 1 '' 'from a build arg' 'ARG BASE=node:22-slim\nFROM ${BASE} AS app\n' @ app +expect var-base-plain 1 '' 'from a build arg' 'ARG BASE=node:22-slim\nFROM $BASE AS app\n' @ app +expect hidden-stage 1 '' 'is not pinned' 'FROM node:22-slim AS Base\nFROM base AS mid\nFROM debian AS app\n' @ app +expect unknown-target 1 '' 'not found' 'FROM debian AS app\n' @ nope + +# --check mode: every stage, one line per pin used +expect check-pins 0 "$tmp/check-pins.Dockerfile NODE22_VERSION" '' \ + 'FROM node:${NODE22_VERSION}-slim AS a\nFROM a AS b\nFROM debian AS c\n' --check @ +expect check-bad 1 '' 'is not pinned' 'FROM debian AS a\nFROM --platform=linux/amd64 node:22 AS b\n' --check @ + +# Current repository Dockerfiles resolve exactly as the old hand-written allowlist did +for t in claude codex copilot gemini mimocode opencode pi; do + for f in Dockerfile.unified Dockerfile.package; do + [ "$("$script" "$f" "$t")" = NODE22_VERSION ] || { echo "FAIL repo $f $t"; failures=$((failures + 1)); } + done +done +for f in Dockerfile.unified Dockerfile.package; do + [ "$("$script" "$f" kimi)" = NODE24_VERSION ] || { echo "FAIL repo $f kimi"; failures=$((failures + 1)); } + for t in kiro cursor grok devin antigravity native agentcore; do + [ -z "$("$script" "$f" "$t")" ] || { echo "FAIL repo $f $t"; failures=$((failures + 1)); } + done +done +echo "ok repository targets" + +[ "$failures" -eq 0 ] || { echo "$failures test(s) failed"; exit 1; } +echo "all node_base_arg.sh tests passed" diff --git a/.github/workflows/docker-smoke-test.yml b/.github/workflows/docker-smoke-test.yml index ac1ce11b6..ec2aa16c9 100644 --- a/.github/workflows/docker-smoke-test.yml +++ b/.github/workflows/docker-smoke-test.yml @@ -9,6 +9,7 @@ on: - 'crates/**' - 'Cargo.*' - '.github/scripts/node_base_arg.sh' + - '.github/scripts/test_node_base_arg.sh' concurrency: group: smoke-test-${{ github.event.pull_request.number }} @@ -185,30 +186,21 @@ jobs: } } - # Any Dockerfile built FROM node:${NODE_VERSION} must carry the same - # pin as Dockerfile.unified, so a Node bump edits only Dockerfiles and a - # partial bump fails; a literal node: tag fails. - # node_base_arg.sh --all is the only Dockerfile parser, shared with the - # runtime check, so both accept the same FROM forms. - dockerfiles=$(git ls-files 'Dockerfile*' '*/Dockerfile*') - [ -n "$dockerfiles" ] || { echo "::error::no Dockerfiles found" >&2; exit 1; } - for f in $dockerfiles; do - args=$(.github/scripts/node_base_arg.sh --all "$f") - for arg in $(sort -u <<<"$args"); do - pin=$(grep -m1 "^ARG $arg=" Dockerfile.unified) || { echo "::error::$arg is not pinned in Dockerfile.unified" >&2; exit 1; } - assert_line "$pin" "$f" - done - done - fixture=$(mktemp) - for unpinned in 'FROM --platform=$TARGETPLATFORM node:22-trixie-slim AS x' 'from node:22-trixie-slim as x'; do - printf '%s\n' "$unpinned" > "$fixture" - if .github/scripts/node_base_arg.sh --all "$fixture" 2>/dev/null; then - echo "::error::node_base_arg.sh --all accepted an unpinned base: $unpinned" >&2 - exit 1 - fi - done - printf '%s\n' 'ARG NODE22_VERSION=22.0.0' 'FROM --platform=$BUILDPLATFORM node:${NODE22_VERSION}-trixie-slim AS x' > "$fixture" - [ "$(.github/scripts/node_base_arg.sh --all "$fixture")" = NODE22_VERSION ] || { echo "::error::node_base_arg.sh --all missed a pinned --platform base" >&2; exit 1; } + # Every Dockerfile that builds on Node.js must carry the same pin as + # Dockerfile.unified, so a Node bump edits only Dockerfiles and a partial + # bump fails. node_base_arg.sh is the only Dockerfile parser for this: + # --check fails on any Node base not written as + # node:${NODE_VERSION}- (literal tag, digest-only, --platform, + # registry-qualified, or hidden behind a build arg or another stage). + .github/scripts/test_node_base_arg.sh + mapfile -t dockerfiles < <(git ls-files -- 'Dockerfile*' '*/Dockerfile*') + node_pins=$(.github/scripts/node_base_arg.sh --check "${dockerfiles[@]}") + [ -n "$node_pins" ] || { echo "::error::No Node.js-based Dockerfile found; the Node pin parity check would pass vacuously" >&2; exit 1; } + while read -r f arg; do + pin=$(grep -m1 "^ARG $arg=" Dockerfile.unified) || { echo "::error::$arg is not pinned in Dockerfile.unified" >&2; exit 1; } + [[ "$pin" =~ ^ARG\ NODE[0-9]+_VERSION=[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "::error::Dockerfile.unified must pin a full Node.js version (got '$pin')" >&2; exit 1; } + grep -Fqx -- "$pin" "$f" || { echo "::error file=$f::$f must declare '$pin' to match Dockerfile.unified" >&2; exit 1; } + done <<< "$node_pins" assert_line 'ARG KIRO_CLI_VERSION=2.25.0' Dockerfile Dockerfile.package Dockerfile.unified assert_line 'ARG KIRO_SHA256_AMD64=3e924de9697717b99891bb5f7d2a4bd9c768b8bfa1c498b35b3d242948512984' Dockerfile Dockerfile.package Dockerfile.unified assert_line 'ARG KIRO_SHA256_ARM64=cc8a2df90cd354b569b81ed4653db4796c86a69866568b4eb7fa7906afc95309' Dockerfile Dockerfile.package Dockerfile.unified From b8c46ed6e881e9f6fc000a7995f8f28367302003 Mon Sep 17 00:00:00 2001 From: chaodu-agent Date: Fri, 23 Oct 2026 03:18:13 +0000 Subject: [PATCH 6/9] fix(review): recognize any registry Node base and continued FROM lines Address round-4 findings on #1570: node_base_arg.sh --check still passed FROM index.docker.io/library/node:22, FROM mirror.gcr.io/library/node:22, and a FROM split across lines with "\" (parsed as an empty image). - Treat any image whose repository name is "node", under any registry or namespace, as a Node.js base that must be node:${NODE_VERSION}-. - Join "\" continuation lines (skipping comment lines) before parsing, as Docker does; reject a FROM with no image and the "# escape=" parser directive, which would change the continuation character. - Add 9 fixtures (30 total) covering these forms. --- .github/scripts/node_base_arg.sh | 47 ++++++++++++++++++++------- .github/scripts/test_node_base_arg.sh | 9 +++++ 2 files changed, 44 insertions(+), 12 deletions(-) diff --git a/.github/scripts/node_base_arg.sh b/.github/scripts/node_base_arg.sh index ed5fb6a38..7a50d6181 100755 --- a/.github/scripts/node_base_arg.sh +++ b/.github/scripts/node_base_arg.sh @@ -12,10 +12,13 @@ # once per pin each file uses. # # Both modes scan every FROM in the file and exit 1 if any stage: -# - references a Node.js image (node:, node@, library/node, docker.io/library/node) +# - references a Node.js image (any registry or namespace whose repository is +# "node", e.g. node:, node@, library/node, mirror.gcr.io/library/node) # not written as node:${NODE_VERSION}-; or # - takes its base from a build arg (FROM ${VAR}) other than an allowlisted one, # since that can hide an unpinned Node.js base. +# FROM lines continued with "\" are joined first, as Docker does; a FROM with no +# image, or an "# escape=" parser directive, is rejected. # The first mode also fails if is not a stage in the file. # Stage names are matched case-insensitively, as Docker does. set -euo pipefail @@ -37,8 +40,7 @@ parse() { # parse [target] return "error FROM " img " takes its base image from a build arg, which can hide an unpinned Node.js base; use node:${NODE_VERSION}- or a named stage (allowed build args:" allowed ")" } ref = img - if (tolower(substr(ref, 1, 10)) == "docker.io/") ref = substr(ref, 11) - if (tolower(substr(ref, 1, 8)) == "library/") ref = substr(ref, 9) + sub(/^.*\//, "", ref) # any registry/namespace: keep the repository name if (match(ref, /^node:[$][{]NODE[0-9]+_VERSION[}]-./)) return "node " substr(ref, 8, index(ref, "}") - 8) # text between "node:${" and "}" if (ref ~ /^node:[$][{]NODE[0-9]+_VERSION[}]$/) @@ -47,23 +49,44 @@ parse() { # parse [target] return "error " img " is not pinned; use node:${NODE_VERSION}-" return "none" } - toupper($1) == "FROM" { + function report(lineno, msg) { + print "::error file=" FILENAME ",line=" lineno "::" msg > "/dev/stderr" + failed = 1 + } + function from(line, lineno, f, n, i, img, name, class) { + n = split(line, f) + if (toupper(f[1]) != "FROM") return img = ""; name = "" - for (i = 2; i <= NF; i++) { - if ($i ~ /^--/) continue # skip --platform=... and other flags - if (img == "") { img = $i; continue } - if (toupper($i) == "AS" && i < NF) { name = tolower($(i + 1)); break } + for (i = 2; i <= n; i++) { + if (f[i] ~ /^--/) continue # skip --platform=... and other flags + if (img == "") { img = f[i]; continue } + if (toupper(f[i]) == "AS" && i < n) { name = tolower(f[i + 1]); break } } + if (img == "") { report(lineno, "FROM without an image reference"); return } class = classify(img) - if (class ~ /^error / && !(tolower(img) in stage)) { # report each bad base once, not per child stage - print "::error file=" FILENAME ",line=" FNR "::" substr(class, 7) > "/dev/stderr" - failed = 1 - } + if (class ~ /^error / && !(tolower(img) in stage)) # report each bad base once, not per child stage + report(lineno, substr(class, 7)) if (class ~ /^node /) used[substr(class, 6)] = 1 if (name != "") stage[name] = class last = class } + # A parser directive can change the line-continuation character; refuse it + # rather than misread continued FROM lines. + tolower($0) ~ /^#[[:space:]]*escape[[:space:]]*=/ { + report(FNR, "the escape parser directive is not supported by node_base_arg.sh") + next + } + /^[[:space:]]*#/ { next } # comments, also inside continuations + { + # Docker joins lines ending in "\" before parsing an instruction. + line = $0 + if (buf == "") start = FNR + if (line ~ /\\[[:space:]]*$/) { sub(/\\[[:space:]]*$/, " ", line); buf = buf line; next } + from(buf line, start) + buf = "" + } END { + if (buf != "") from(buf, start) if (failed) exit 1 if (mode == "check") { for (arg in used) print FILENAME, arg diff --git a/.github/scripts/test_node_base_arg.sh b/.github/scripts/test_node_base_arg.sh index e4e0d8ce0..23be3749e 100755 --- a/.github/scripts/test_node_base_arg.sh +++ b/.github/scripts/test_node_base_arg.sh @@ -47,7 +47,16 @@ expect missing-suffix 1 '' 'has no tag suffix' 'FROM node:${NODE22_VERSION} AS expect var-base 1 '' 'from a build arg' 'ARG BASE=node:22-slim\nFROM ${BASE} AS app\n' @ app expect var-base-plain 1 '' 'from a build arg' 'ARG BASE=node:22-slim\nFROM $BASE AS app\n' @ app expect hidden-stage 1 '' 'is not pinned' 'FROM node:22-slim AS Base\nFROM base AS mid\nFROM debian AS app\n' @ app +expect index-registry 1 '' 'is not pinned' 'FROM index.docker.io/library/node:22 AS app\n' @ app +expect mirror-registry 1 '' 'is not pinned' 'FROM mirror.gcr.io/library/node:22 AS app\n' @ app +expect ecr-mirror 1 '' 'is not pinned' 'FROM public.ecr.aws/docker/library/node:22 AS app\n' @ app +expect continued-bad 1 '' 'is not pinned' 'FROM \\\n node:22-slim AS app\n' @ app +expect continued-cmt 1 '' 'is not pinned' 'FROM --platform=linux/amd64 \\\n# a comment\n node:22-slim \\\n AS app\n' @ app +expect empty-from 1 '' 'without an image' 'FROM\n' @ +expect escape-directive 1 '' 'escape parser' '# escape=`\nFROM debian AS app\n' @ app expect unknown-target 1 '' 'not found' 'FROM debian AS app\n' @ nope +expect continued-ok 0 NODE24_VERSION '' 'FROM \\\n node:${NODE24_VERSION}-trixie-slim \\\n AS app\n' @ app +expect other-repo 0 '' '' 'FROM ghcr.io/openabdev/openab-native:beta AS app\nFROM rust:1@sha256:00 AS b\n' @ app # --check mode: every stage, one line per pin used expect check-pins 0 "$tmp/check-pins.Dockerfile NODE22_VERSION" '' \ From a91ddc8627660d12c1a21bfbf3d92e2083a73760 Mon Sep 17 00:00:00 2001 From: chaodu-agent Date: Fri, 23 Oct 2026 03:19:41 +0000 Subject: [PATCH 7/9] fix(review): reject unresolvable build args in base images; trigger on nested Dockerfiles Address round-4 findings on #1570: - node_base_arg.sh: any build arg other than ${NODE_VERSION} inside a base image reference (node${TAG}-slim, ${IMG}:22, ${REG}/node:...) now fails, since it cannot be resolved statically and could hide an unpinned Node.js base. Document the RUN-heredoc limitation (false error only). - docker-smoke-test.yml: also trigger on '**/Dockerfile*', so a PR that only changes a nested Dockerfile still runs the validate-packaged-pins check that scans every tracked Dockerfile. - Add 4 fixtures (34 total). --- .github/scripts/node_base_arg.sh | 13 +++++++++++-- .github/scripts/test_node_base_arg.sh | 4 ++++ .github/workflows/docker-smoke-test.yml | 3 +++ 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/.github/scripts/node_base_arg.sh b/.github/scripts/node_base_arg.sh index 7a50d6181..6abfb5c36 100755 --- a/.github/scripts/node_base_arg.sh +++ b/.github/scripts/node_base_arg.sh @@ -16,9 +16,13 @@ # "node", e.g. node:, node@, library/node, mirror.gcr.io/library/node) # not written as node:${NODE_VERSION}-; or # - takes its base from a build arg (FROM ${VAR}) other than an allowlisted one, -# since that can hide an unpinned Node.js base. +# or uses any build arg other than ${NODE_VERSION} inside the reference +# (e.g. node${TAG}-slim, ${REG}/node:...), since that can hide an unpinned +# Node.js base. # FROM lines continued with "\" are joined first, as Docker does; a FROM with no # image, or an "# escape=" parser directive, is rejected. +# Known limitation: a line starting with FROM inside a RUN heredoc is parsed as +# an instruction. That can only add a false error, never hide a Node.js base. # The first mode also fails if is not a stage in the file. # Stage names are matched case-insensitively, as Docker does. set -euo pipefail @@ -31,7 +35,7 @@ parse() { # parse [target] awk -v mode="$1" -v target="${3:-}" -v allowed=" $ALLOWED_VAR_BASES " ' # Classify one FROM image reference. Returns "node ", "none", or # "error ". Stage references inherit the referenced stage. - function classify(img, key, ref, var) { + function classify(img, key, ref, var, probe) { key = tolower(img) if (key in stage) return stage[key] if (img ~ /^[$][{]?[A-Za-z_][A-Za-z0-9_]*[}]?$/) { @@ -39,6 +43,11 @@ parse() { # parse [target] if (index(allowed, " " var " ")) return "none" return "error FROM " img " takes its base image from a build arg, which can hide an unpinned Node.js base; use node:${NODE_VERSION}- or a named stage (allowed build args:" allowed ")" } + # Any other build arg in the reference (e.g. node${TAG}-slim, ${REG}/node:...) + # cannot be resolved statically, so it could hide an unpinned Node.js base. + probe = img; gsub(/[$][{]NODE[0-9]+_VERSION[}]/, "", probe) + if (probe ~ /[$]/) + return "error " img " uses a build arg that cannot be resolved statically; only ${NODE_VERSION} may appear in a base image reference" ref = img sub(/^.*\//, "", ref) # any registry/namespace: keep the repository name if (match(ref, /^node:[$][{]NODE[0-9]+_VERSION[}]-./)) diff --git a/.github/scripts/test_node_base_arg.sh b/.github/scripts/test_node_base_arg.sh index 23be3749e..513b99e1f 100755 --- a/.github/scripts/test_node_base_arg.sh +++ b/.github/scripts/test_node_base_arg.sh @@ -46,6 +46,10 @@ expect bare-node 1 '' 'is not pinned' 'FROM node AS app\n' @ app expect missing-suffix 1 '' 'has no tag suffix' 'FROM node:${NODE22_VERSION} AS app\n' @ app expect var-base 1 '' 'from a build arg' 'ARG BASE=node:22-slim\nFROM ${BASE} AS app\n' @ app expect var-base-plain 1 '' 'from a build arg' 'ARG BASE=node:22-slim\nFROM $BASE AS app\n' @ app +expect var-in-tag 1 '' 'cannot be resolved' 'FROM node${TAG}-slim AS app\n' @ app +expect var-image-name 1 '' 'cannot be resolved' 'FROM ${IMG}:22 AS app\n' @ app +expect var-registry 1 '' 'cannot be resolved' 'FROM ${REG}/node:${NODE22_VERSION}-slim AS app\n' @ app +expect var-node-tag 1 '' 'cannot be resolved' 'FROM node:${TAG} AS app\n' @ app expect hidden-stage 1 '' 'is not pinned' 'FROM node:22-slim AS Base\nFROM base AS mid\nFROM debian AS app\n' @ app expect index-registry 1 '' 'is not pinned' 'FROM index.docker.io/library/node:22 AS app\n' @ app expect mirror-registry 1 '' 'is not pinned' 'FROM mirror.gcr.io/library/node:22 AS app\n' @ app diff --git a/.github/workflows/docker-smoke-test.yml b/.github/workflows/docker-smoke-test.yml index ec2aa16c9..3d8f6cc4e 100644 --- a/.github/workflows/docker-smoke-test.yml +++ b/.github/workflows/docker-smoke-test.yml @@ -5,6 +5,9 @@ on: types: [opened, synchronize, reopened, ready_for_review] paths: - 'Dockerfile*' + # validate-packaged-pins checks Node.js pins in every tracked Dockerfile, + # including nested ones such as openshell/Dockerfile. + - '**/Dockerfile*' - 'src/**' - 'crates/**' - 'Cargo.*' From 08f8ca705f91e67dc0b46404df121fc1b4bd4200 Mon Sep 17 00:00:00 2001 From: chaodu-agent Date: Fri, 23 Oct 2026 03:20:53 +0000 Subject: [PATCH 8/9] fix(review): fail repository-target tests when node_base_arg.sh exits non-zero The repository-target checks compared command-substitution output only, so a non-Node target whose lookup crashed (empty output, non-zero exit) passed as "not Node-based". Check the exit status explicitly. --- .github/scripts/test_node_base_arg.sh | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/.github/scripts/test_node_base_arg.sh b/.github/scripts/test_node_base_arg.sh index 513b99e1f..16276534a 100755 --- a/.github/scripts/test_node_base_arg.sh +++ b/.github/scripts/test_node_base_arg.sh @@ -67,17 +67,20 @@ expect check-pins 0 "$tmp/check-pins.Dockerfile NODE22_VERSION" '' \ 'FROM node:${NODE22_VERSION}-slim AS a\nFROM a AS b\nFROM debian AS c\n' --check @ expect check-bad 1 '' 'is not pinned' 'FROM debian AS a\nFROM --platform=linux/amd64 node:22 AS b\n' --check @ -# Current repository Dockerfiles resolve exactly as the old hand-written allowlist did -for t in claude codex copilot gemini mimocode opencode pi; do - for f in Dockerfile.unified Dockerfile.package; do - [ "$("$script" "$f" "$t")" = NODE22_VERSION ] || { echo "FAIL repo $f $t"; failures=$((failures + 1)); } - done -done +# Current repository Dockerfiles resolve exactly as the old hand-written allowlist did. +# repo_expect checks the exit status too, so a crash cannot pass as "not Node-based". +repo_expect() { # repo_expect + local out + if ! out=$("$script" "$1" "$2"); then + echo "FAIL repo $1 $2: exited non-zero"; failures=$((failures + 1)) + elif [ "$out" != "$3" ]; then + echo "FAIL repo $1 $2: got [$out], want [$3]"; failures=$((failures + 1)) + fi +} for f in Dockerfile.unified Dockerfile.package; do - [ "$("$script" "$f" kimi)" = NODE24_VERSION ] || { echo "FAIL repo $f kimi"; failures=$((failures + 1)); } - for t in kiro cursor grok devin antigravity native agentcore; do - [ -z "$("$script" "$f" "$t")" ] || { echo "FAIL repo $f $t"; failures=$((failures + 1)); } - done + for t in claude codex copilot gemini mimocode opencode pi; do repo_expect "$f" "$t" NODE22_VERSION; done + repo_expect "$f" kimi NODE24_VERSION + for t in kiro cursor grok devin antigravity native agentcore; do repo_expect "$f" "$t" ''; done done echo "ok repository targets" From f44623d2ff633af84ae3ee18e8264764e1f7c1d2 Mon Sep 17 00:00:00 2001 From: chaodu-agent Date: Fri, 23 Oct 2026 03:22:12 +0000 Subject: [PATCH 9/9] fix(review): check the declared default of allowlisted base-image build args FROM ${BUILDER_IMAGE} was trusted by name, so ARG BUILDER_IMAGE=node:22 would pass. Classify the declared ARG default as well (CI overrides are not statically known), and guard against a self-referencing default. Add fixtures for the default, parameter-expansion and bare-composite forms (39 total). --- .github/scripts/node_base_arg.sh | 24 ++++++++++++++++++++---- .github/scripts/test_node_base_arg.sh | 5 +++++ 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/.github/scripts/node_base_arg.sh b/.github/scripts/node_base_arg.sh index 6abfb5c36..dad66671e 100755 --- a/.github/scripts/node_base_arg.sh +++ b/.github/scripts/node_base_arg.sh @@ -29,18 +29,25 @@ set -euo pipefail # Build args that may be used directly as a base image. BUILDER_IMAGE selects # the openab builder in Dockerfile.unified and is never a Node.js runtime base. +# CI can override it, so only its declared ARG default is checked. ALLOWED_VAR_BASES="BUILDER_IMAGE" parse() { # parse [target] awk -v mode="$1" -v target="${3:-}" -v allowed=" $ALLOWED_VAR_BASES " ' # Classify one FROM image reference. Returns "node ", "none", or # "error ". Stage references inherit the referenced stage. - function classify(img, key, ref, var, probe) { + function classify(img, depth, key, ref, var, probe) { key = tolower(img) if (key in stage) return stage[key] if (img ~ /^[$][{]?[A-Za-z_][A-Za-z0-9_]*[}]?$/) { var = img; gsub(/[${}]/, "", var) - if (index(allowed, " " var " ")) return "none" + if (index(allowed, " " var " ")) { + # CI may override an allowed build arg, but its declared default must + # not be a Node.js base either (e.g. ARG BUILDER_IMAGE=node:22). + if (!(var in argdef) || argdef[var] == "") return "none" + if (depth > 8) return "error " img " default refers back to itself" + return classify(argdef[var], depth + 1) + } return "error FROM " img " takes its base image from a build arg, which can hide an unpinned Node.js base; use node:${NODE_VERSION}- or a named stage (allowed build args:" allowed ")" } # Any other build arg in the reference (e.g. node${TAG}-slim, ${REG}/node:...) @@ -62,8 +69,17 @@ parse() { # parse [target] print "::error file=" FILENAME ",line=" lineno "::" msg > "/dev/stderr" failed = 1 } - function from(line, lineno, f, n, i, img, name, class) { + function from(line, lineno, f, n, i, img, name, class, eq, v) { n = split(line, f) + if (toupper(f[1]) == "ARG") { # remember ARG defaults for allowed build args + for (i = 2; i <= n; i++) { + eq = index(f[i], "=") + if (eq == 0) continue + v = substr(f[i], eq + 1); gsub(/^"|"$/, "", v) + argdef[substr(f[i], 1, eq - 1)] = v + } + return + } if (toupper(f[1]) != "FROM") return img = ""; name = "" for (i = 2; i <= n; i++) { @@ -72,7 +88,7 @@ parse() { # parse [target] if (toupper(f[i]) == "AS" && i < n) { name = tolower(f[i + 1]); break } } if (img == "") { report(lineno, "FROM without an image reference"); return } - class = classify(img) + class = classify(img, 0) if (class ~ /^error / && !(tolower(img) in stage)) # report each bad base once, not per child stage report(lineno, substr(class, 7)) if (class ~ /^node /) used[substr(class, 6)] = 1 diff --git a/.github/scripts/test_node_base_arg.sh b/.github/scripts/test_node_base_arg.sh index 16276534a..ba7d825ca 100755 --- a/.github/scripts/test_node_base_arg.sh +++ b/.github/scripts/test_node_base_arg.sh @@ -36,6 +36,11 @@ expect registry-pinned 0 NODE22_VERSION '' 'FROM docker.io/library/node:${NODE22 expect digest-on-pin 0 NODE22_VERSION '' 'FROM node:${NODE22_VERSION}-slim@sha256:0000 AS app\n' @ app expect non-node 0 '' '' 'FROM debian:trixie-slim AS base\nFROM base AS app\n' @ app expect allowed-var 0 '' '' 'FROM ${BUILDER_IMAGE} AS builder\nFROM debian AS app\n' @ app +expect allowed-var-stage 0 '' '' 'FROM rust AS local_builder\nARG BUILDER_IMAGE=local_builder\nFROM ${BUILDER_IMAGE} AS builder\n' @ builder +expect allowed-var-node 1 '' 'is not pinned' 'ARG BUILDER_IMAGE=node:22\nFROM ${BUILDER_IMAGE} AS app\n' @ app +expect allowed-var-loop 1 '' 'refers back' 'ARG BUILDER_IMAGE=${BUILDER_IMAGE}\nFROM ${BUILDER_IMAGE} AS app\n' @ app +expect param-expansion 1 '' 'cannot be resolved' 'FROM ${BASE:-node:22-trixie-slim} AS app\n' @ app +expect bare-composite 1 '' 'cannot be resolved' 'FROM $BASE-node AS app\n' @ app # Fail closed on Node.js bases that are not pinned through the ARG expect literal-tag 1 '' 'is not pinned' 'FROM node:22-trixie-slim AS app\n' @ app