test(docker): extend digest test to cover all ARG-backed FROM stages

The existing check only validated the first FROM line. The new bun-binary
stage (FROM ${OPENCLAW_BUN_IMAGE}) would have been invisible to it.

Add resolveAllArgBackedFromReferences that walks every FROM line and
resolves ARG-backed image references, so all pinned base stages are
checked — not just the first one.
This commit is contained in:
Federico Kamelhar
2026-04-29 10:15:40 -04:00
committed by sallyom
parent fd27ee4a0e
commit 437f601055

View File

@@ -33,43 +33,67 @@ type DependabotConfig = {
updates?: DependabotUpdate[];
};
function resolveFirstFromReference(dockerfile: string): string | undefined {
function resolveArgDefaults(dockerfile: string): Map<string, string> {
const argDefaults = new Map<string, string>();
for (const line of dockerfile.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed) {
continue;
}
if (trimmed.startsWith("FROM ")) {
break;
}
const argMatch = trimmed.match(/^ARG\s+([A-Z0-9_]+)=(.+)$/);
if (!argMatch) {
continue;
}
const [, name, rawValue] = argMatch;
const value = rawValue.replace(/^["']|["']$/g, "");
argDefaults.set(name, value);
}
const fromLine = dockerfile.split(/\r?\n/).find((line) => line.trimStart().startsWith("FROM "));
if (!fromLine) {
return undefined;
argDefaults.set(name, rawValue.replace(/^["']|["']$/g, ""));
}
return argDefaults;
}
function resolveFromImageRef(fromLine: string, argDefaults: Map<string, string>): string {
const fromMatch = fromLine.trim().match(/^FROM\s+(\S+?)(?:\s+AS\s+\S+)?$/);
if (!fromMatch) {
return undefined;
return fromLine;
}
const imageRef = fromMatch[1];
const argName =
imageRef.match(/^\$\{([A-Z0-9_]+)\}$/)?.[1] ?? imageRef.match(/^\$([A-Z0-9_]+)$/)?.[1];
if (!argName) {
return imageRef;
}
return argDefaults.get(argName);
return argDefaults.get(argName) ?? imageRef;
}
function resolveAllArgBackedFromReferences(
dockerfile: string,
): { stage: string; imageRef: string }[] {
const argDefaults = resolveArgDefaults(dockerfile);
const results: { stage: string; imageRef: string }[] = [];
let stageIndex = 0;
for (const line of dockerfile.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed.startsWith("FROM ")) {
continue;
}
const imageRef = resolveFromImageRef(trimmed, argDefaults);
// Only check FROM lines that use an ARG — literal `FROM scratch` etc. are intentionally unpinned.
const usesArg =
trimmed.match(/FROM\s+\$\{[A-Z0-9_]+\}/) !== null ||
trimmed.match(/FROM\s+\$[A-Z0-9_]+/) !== null;
if (usesArg) {
const stageMatch = trimmed.match(/AS\s+(\S+)/i);
const stageName = stageMatch ? stageMatch[1] : `stage-${stageIndex}`;
results.push({ stage: stageName, imageRef });
}
stageIndex += 1;
}
return results;
}
function resolveFirstFromReference(dockerfile: string): string | undefined {
const argDefaults = resolveArgDefaults(dockerfile);
const fromLine = dockerfile.split(/\r?\n/).find((line) => line.trimStart().startsWith("FROM "));
if (!fromLine) {
return undefined;
}
return resolveFromImageRef(fromLine, argDefaults);
}
describe("docker base image pinning", () => {
@@ -84,6 +108,18 @@ describe("docker base image pinning", () => {
}
});
it("pins all ARG-backed FROM stages in selected Dockerfiles to sha256 digests", async () => {
for (const dockerfilePath of DIGEST_PINNED_DOCKERFILES) {
const dockerfile = await readFile(resolve(repoRoot, dockerfilePath), "utf8");
const stages = resolveAllArgBackedFromReferences(dockerfile);
for (const { stage, imageRef } of stages) {
expect(imageRef, `${dockerfilePath} stage "${stage}" must be digest-pinned`).toMatch(
/^\S+@sha256:[a-f0-9]{64}$/,
);
}
}
});
it("keeps Dependabot Docker updates enabled for root Dockerfiles", async () => {
const raw = await readFile(resolve(repoRoot, ".github/dependabot.yml"), "utf8");
const config = parse(raw) as DependabotConfig;