Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
142 changes: 142 additions & 0 deletions .github/scripts/node_base_arg.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
#!/usr/bin/env bash
# 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<major>_VERSION}-<suffix>.
#
# Usage:
# node_base_arg.sh <dockerfile> [target]
# Print the NODE<n>_VERSION ARG that <target> (default: the last stage)
# is ultimately built FROM, or nothing if it is not Node-based.
# node_base_arg.sh --check <dockerfile>...
# Validate every stage of every file and print "<file> NODE<n>_VERSION"
# 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 (any registry or namespace whose repository is
# "node", e.g. node:, node@, library/node, mirror.gcr.io/library/node)
# not written as node:${NODE<n>_VERSION}-<suffix>; or
# - takes its base from a build arg (FROM ${VAR}) other than an allowlisted one,
# or uses any build arg other than ${NODE<n>_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 <target> is not a stage in the file.
# Stage names are matched case-insensitively, as Docker does.
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 <mode> <dockerfile> [target]
awk -v mode="$1" -v target="${3:-}" -v allowed=" $ALLOWED_VAR_BASES " '
# Classify one FROM image reference. Returns "node <ARG>", "none", or
# "error <message>". Stage references inherit the referenced stage.
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 " ")) {
# 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<major>_VERSION}-<suffix> 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<major>_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[}]-./))
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<major>_VERSION}-<suffix> (e.g. -trixie-slim)"
if (tolower(ref) ~ /^node([:@]|$)/)
return "error " img " is not pinned; use node:${NODE<major>_VERSION}-<suffix>"
return "none"
}
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, 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++) {
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, 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
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
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)
}
' "$2"
}

if [ "${1:-}" = "--check" ]; then
shift
[ "$#" -gt 0 ] || { echo "usage: node_base_arg.sh --check <dockerfile>..." >&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 <dockerfile> [target]" >&2; exit 2; }
parse resolve "$1" "${2:-}"
93 changes: 93 additions & 0 deletions .github/scripts/test_node_base_arg.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
#!/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 <name> <want_rc> <want_stdout> <want_stderr_substring> <dockerfile_body> [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
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
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 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
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 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.
# repo_expect checks the exit status too, so a crash cannot pass as "not Node-based".
repo_expect() { # repo_expect <dockerfile> <target> <want_stdout>
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
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"

[ "$failures" -eq 0 ] || { echo "$failures test(s) failed"; exit 1; }
echo "all node_base_arg.sh tests passed"
16 changes: 16 additions & 0 deletions .github/workflows/docker-smoke-test-unified.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down Expand Up @@ -85,6 +86,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
shell: bash
run: |
set -euo pipefail
# 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)
[ "$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: |
Expand Down
35 changes: 35 additions & 0 deletions .github/workflows/docker-smoke-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,14 @@ 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.*'
- '.github/scripts/node_base_arg.sh'
- '.github/scripts/test_node_base_arg.sh'

concurrency:
group: smoke-test-${{ github.event.pull_request.number }}
Expand Down Expand Up @@ -54,6 +59,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
shell: bash
run: |
set -euo pipefail
# 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)
[ "$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: |
Expand Down Expand Up @@ -169,6 +189,21 @@ jobs:
}
}

# 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<n>_VERSION}-<suffix> (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
Expand Down
Loading
Loading