From dbe82f04a2aa8854616adda648bbe4793e09d273 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 13 Nov 2026 16:40:09 +0000 Subject: [PATCH 1/4] Initial plan From 5c223d7b13f895eecbd99f36c169a57375a6b7fe Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 13 Nov 2026 16:43:58 +0000 Subject: [PATCH 2/4] fix: report current image sizes when baseline manifest is missing Co-authored-by: rjaegers <45816308+rjaegers@users.noreply.github.com> --- .github/actions/container-size-diff/README.md | 8 +- .../container-size-diff.js | 51 +++++- .../test/container-size-diff.test.js | 162 ++++++++++++++++++ docs/reusable-build-system.md | 10 +- 4 files changed, 226 insertions(+), 5 deletions(-) diff --git a/.github/actions/container-size-diff/README.md b/.github/actions/container-size-diff/README.md index 5f100215..d3c3959a 100644 --- a/.github/actions/container-size-diff/README.md +++ b/.github/actions/container-size-diff/README.md @@ -5,7 +5,9 @@ It is meant to be posted as a comment on a pull request, so reviewers see what a The size of an image is the compressed size of its layers. The report has the sizes before and after, the change in size and percentage, and a trend icon per platform. -The two images must be built for the same platforms. +When both images exist, they must be built for the same platforms. +If the registry confirms that the baseline manifest does not exist, for example on the first pull-request build of a new image, the report explains that comparison is unavailable and lists only the current image's platform sizes. +It does not invent previous sizes, changes or percentages. ## Usage @@ -31,7 +33,9 @@ The action installs Node.js and its own dependencies. | `to-container` | input | The image that is compared to the baseline. Required. | | `size-diff-markdown` | output | The markdown report. | -The action fails when an image cannot be inspected, has no platforms, or when the platforms of the images differ. +The action fails when the current image cannot be inspected, either manifest is malformed or has no known platforms, or when two existing images have different platforms. +Only confirmed missing baseline manifests (`no such manifest` for the baseline reference or a `manifest unknown` / `MANIFEST_UNKNOWN` diagnostic) are nonfatal. +Baseline authentication, authorization, network and unexpected registry errors remain fatal. ## Tests diff --git a/.github/actions/container-size-diff/container-size-diff.js b/.github/actions/container-size-diff/container-size-diff.js index 288ad0f8..a7bc5c45 100644 --- a/.github/actions/container-size-diff/container-size-diff.js +++ b/.github/actions/container-size-diff/container-size-diff.js @@ -16,6 +16,28 @@ function inspectManifest(container) { return stdout; } +function isMissingManifest(error, container) { + if ( + error?.exitCode !== 1 || + error.timedOut || + error.isCanceled || + error.killed || + error.signal || + typeof error.stderr !== "string" + ) { + return false; + } + + // Match the entire diagnostic so mixed registry/authentication errors stay fatal. + const diagnostic = error.stderr.trim(); + return ( + diagnostic === `no such manifest: ${container}` || + /^(?:Error response from daemon: )?(?:manifest unknown|MANIFEST_UNKNOWN)(?:: (?:manifest unknown|The named manifest is not known to the registry\.?))?$/.test( + diagnostic, + ) + ); +} + // Returns the compressed size of the layers per platform of a container image, as an // object that maps the platform (e.g. "linux/arm64") to the size in bytes. function getSizes(container, inspect = inspectManifest) { @@ -85,9 +107,36 @@ function containerSizeDiff( toContainer, inspect = inspectManifest, ) { - const fromSizes = getSizes(fromContainer, inspect); + let fromSizes; + try { + fromSizes = getSizes(fromContainer, inspect); + } catch (error) { + if (!isMissingManifest(error, fromContainer)) { + throw error; + } + fromSizes = null; + } const toSizes = getSizes(toContainer, inspect); + if (fromSizes === null) { + const rows = [ + ["OS/Platform", "Current"], + ..._.keys(toSizes) + .sort() + .map((platform) => [platform, filesize(toSizes[platform])]), + ]; + + return `## 📦 Container Size Analysis + +> [!NOTE] +> Comparison unavailable: baseline \`${fromContainer}\` does not exist. +> Current image: \`${toContainer}\` + +### 📦 Current Image Sizes + +${markdownTable(rows, { align: ["l", "c"] })}`; + } + const missingFrom = _.difference(_.keys(toSizes), _.keys(fromSizes)); const missingTo = _.difference(_.keys(fromSizes), _.keys(toSizes)); if (missingFrom.length || missingTo.length) { diff --git a/.github/actions/container-size-diff/test/container-size-diff.test.js b/.github/actions/container-size-diff/test/container-size-diff.test.js index f8ad21c8..3be42167 100644 --- a/.github/actions/container-size-diff/test/container-size-diff.test.js +++ b/.github/actions/container-size-diff/test/container-size-diff.test.js @@ -22,10 +22,24 @@ function fakeInspect(images) { if (!(container in images)) { throw new Error(`fake: unknown image ${container}`); } + if (images[container] instanceof Error) { + throw images[container]; + } + if (typeof images[container] === "string") { + return images[container]; + } return `${JSON.stringify(images[container])}\n`; }; } +function inspectionError(stderr, properties = {}) { + return Object.assign(new Error(`fake inspection failed: ${stderr}`), { + exitCode: 1, + stderr, + ...properties, + }); +} + test("getSizes adds the sizes of the layers per platform", () => { const inspect = fakeInspect({ "fake-image": [ @@ -165,6 +179,10 @@ test("the report shows a growth from an empty image as infinite", () => { .slice(9); assert.match(rows[0], /\| +0 B +\| +1 kB +\| +\+1 kB \(\+∞\) +\| +🔼 +\|/); + assert.doesNotMatch( + containerSizeDiff("fake-from", "fake-to", inspect), + /baseline.*does not exist/, + ); }); test("the report fails when the images are not built for the same platforms", () => { @@ -190,3 +208,147 @@ test("the report fails when one of the images cannot be inspected", () => { /fake: unknown image fake-to/, ); }); + +const missingDiagnostics = [ + "no such manifest: fake-from", + "manifest unknown", + "manifest unknown: manifest unknown", + "manifest unknown: The named manifest is not known to the registry.", + "MANIFEST_UNKNOWN: manifest unknown", + "Error response from daemon: manifest unknown: manifest unknown", +]; + +for (const diagnostic of missingDiagnostics) { + test(`a missing baseline produces current sizes for: ${diagnostic}`, () => { + const inspect = fakeInspect({ + "fake-from": inspectionError(`${diagnostic}\n`), + "fake-to": [ + platformManifest(arm64, 4000), + platformManifest(amd64, 1000, 1000), + ], + }); + + const report = containerSizeDiff("fake-from", "fake-to", inspect); + + assert.match( + report, + /Comparison unavailable: baseline `fake-from` does not exist\./, + ); + assert.match(report, /Current image: `fake-to`/); + assert.match(report, /### 📦 Current Image Sizes/); + const rows = report.split("\n").filter((line) => line.startsWith("|")); + assert.match(rows[0], /^\| OS\/Platform +\| Current \|$/); + assert.match(rows[2], /^\| linux\/amd64 +\| +2 kB +\|$/); + assert.match(rows[3], /^\| linux\/arm64\/v8 +\| +4 kB +\|$/); + assert.equal(rows.length, 4); + assert.doesNotMatch(report, /Previous|Change|Trend|0 B|%|∞|Comparing/); + assert.throws(() => getSizes("fake-from", inspect), /fake inspection failed/); + }); +} + +for (const baselineMissing of [false, true]) { + test(`a missing current image fails with baseline missing=${baselineMissing}`, () => { + const currentError = inspectionError("no such manifest: fake-to"); + const inspect = fakeInspect({ + "fake-from": baselineMissing + ? inspectionError("no such manifest: fake-from") + : [platformManifest(amd64, 1000)], + "fake-to": currentError, + }); + + assert.throws( + () => containerSizeDiff("fake-from", "fake-to", inspect), + (error) => error === currentError, + ); + }); +} + +const fatalDiagnostics = [ + "unauthorized: authentication required", + "denied: requested access to the resource is denied", + "forbidden: access denied", + "dial tcp: lookup registry.example.invalid: no such host", + "context deadline exceeded", + "unexpected status from HEAD request: 500 Internal Server Error", + "unexpected status from HEAD request: 404 Not Found", + "name unknown: repository name not known to registry", + "no such manifest: fake-other", + "manifest unknown: unauthorized", + "manifest unknown: manifest unknown\nunauthorized: authentication required", + "no such manifest: fake-from\ncontext deadline exceeded", +]; + +for (const diagnostic of fatalDiagnostics) { + test(`baseline inspection errors remain fatal: ${diagnostic}`, () => { + const baselineError = inspectionError(diagnostic); + const inspect = fakeInspect({ + "fake-from": baselineError, + "fake-to": [platformManifest(amd64, 1000)], + }); + + assert.throws( + () => containerSizeDiff("fake-from", "fake-to", inspect), + (error) => error === baselineError, + ); + }); +} + +for (const properties of [ + { exitCode: 2 }, + { exitCode: undefined }, + { timedOut: true }, + { isCanceled: true }, + { killed: true }, + { signal: "SIGTERM" }, + { stderr: undefined }, +]) { + test(`unconfirmed inspection failures remain fatal: ${JSON.stringify(properties)}`, () => { + const baselineError = inspectionError( + "no such manifest: fake-from", + properties, + ); + const inspect = fakeInspect({ + "fake-from": baselineError, + "fake-to": [platformManifest(amd64, 1000)], + }); + + assert.throws( + () => containerSizeDiff("fake-from", "fake-to", inspect), + (error) => error === baselineError, + ); + }); +} + +for (const invalidManifest of [ + "not JSON", + [], + [platformManifest({ os: "unknown", architecture: "unknown" }, 10)], +]) { + test(`invalid baseline data remains fatal: ${JSON.stringify(invalidManifest)}`, () => { + const inspect = fakeInspect({ + "fake-from": invalidManifest, + "fake-to": [platformManifest(amd64, 1000)], + }); + + assert.throws( + () => containerSizeDiff("fake-from", "fake-to", inspect), + /Unexpected token|No platform sizes found for fake-from/, + ); + }); + + for (const baselineMissing of [false, true]) { + test(`invalid current data remains fatal with baseline missing=${baselineMissing}: ${JSON.stringify(invalidManifest)}`, () => { + const inspect = fakeInspect({ + "fake-from": baselineMissing + ? inspectionError("no such manifest: fake-from") + : [platformManifest(amd64, 1000)], + "fake-to": invalidManifest, + }); + + assert.throws( + () => containerSizeDiff("fake-from", "fake-to", inspect), + /Unexpected token|No platform sizes found for fake-to/, + ); + }); + } +} diff --git a/docs/reusable-build-system.md b/docs/reusable-build-system.md index 72044f51..d3cd44a3 100644 --- a/docs/reusable-build-system.md +++ b/docs/reusable-build-system.md @@ -18,7 +18,7 @@ This document describes the building blocks, and the path we recommend to follow The actions have a README with their inputs and outputs: -- [container-size-diff](../.github/actions/container-size-diff/README.md): report the size difference between two images +- [container-size-diff](../.github/actions/container-size-diff/README.md): report the size difference between two images, or current sizes when the baseline does not exist - [extract-manifest-digest](../.github/actions/extract-manifest-digest/README.md): get the digest of an image - [generate-release-summary](../.github/actions/generate-release-summary/README.md): let Copilot draft the summary of a release - [generate-tool-inventory](../.github/actions/generate-tool-inventory/README.md): derive the tools of an image from its SBOM @@ -66,6 +66,11 @@ flowchart LR 3. **Document the image**: create an SBOM, a build provenance attestation (which is verified right away), the tool inventory, a container diff, and a comment with the size difference with the `edge` image. 4. **Test the image**, by running Bats integration tests in it with Docker, and optionally Podman, and optionally an acceptance test in a Codespace. +On the first build of a new image, there may be no `edge` baseline yet. +The size comment then explains that comparison is unavailable and reports the current platform sizes; the best-effort semantic diff may not produce an artifact. +The current image is still validated, and the normal SBOM, inventory, attestation and integration-test gates remain in place. +Pull-request builds do not need to publish `edge` to enable this bootstrap. + The result is an image that is signed, can be traced back to its source, and has been tested with the container runtimes it supports. Calling it with several images, in a matrix, gives you a family of containers: build the base image first, and pass it as `base-image` and as a build argument to the images that derive from it, like [build-push-test.yml](../.github/workflows/build-push-test.yml) does. @@ -173,7 +178,8 @@ The permissions are what the jobs in the workflow need, the workflow has a comme The inputs of the workflow are documented in the workflow file. The `base-image` is the image of the `FROM` line. Without it the `devcontainer.metadata` label of your image only has your own metadata, and the VS Code extensions and settings of the base image are lost. -Run the workflow for pull requests, and for the merge queue with `enable-edge-tag` so there is always an `edge` image to compare with. +Run the workflow for pull requests, and for the merge queue with `enable-edge-tag` so subsequent builds have an `edge` image to compare with. +Consumers pinned to an older commit must adopt the verified commit of an `actions/vX.Y.Z` release containing the bootstrap fix; changes on `main` do not update pinned callers. ### 4. Keep the pins up to date From 5b51a48b4a3256781f77755b0ea222643d85bd8e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 13 Nov 2026 16:46:20 +0000 Subject: [PATCH 3/4] fix: validate bootstrap sizes and normalized Docker baseline references Co-authored-by: rjaegers <45816308+rjaegers@users.noreply.github.com> --- .github/actions/container-size-diff/README.md | 2 +- .../container-size-diff.js | 36 +++++++++++++++++-- .../test/container-size-diff.test.js | 33 +++++++++++++++-- 3 files changed, 66 insertions(+), 5 deletions(-) diff --git a/.github/actions/container-size-diff/README.md b/.github/actions/container-size-diff/README.md index d3c3959a..43b35d6f 100644 --- a/.github/actions/container-size-diff/README.md +++ b/.github/actions/container-size-diff/README.md @@ -33,7 +33,7 @@ The action installs Node.js and its own dependencies. | `to-container` | input | The image that is compared to the baseline. Required. | | `size-diff-markdown` | output | The markdown report. | -The action fails when the current image cannot be inspected, either manifest is malformed or has no known platforms, or when two existing images have different platforms. +The action fails when the current image cannot be inspected, either manifest is malformed or has no known platforms or valid layer sizes, or when two existing images have different platforms. Only confirmed missing baseline manifests (`no such manifest` for the baseline reference or a `manifest unknown` / `MANIFEST_UNKNOWN` diagnostic) are nonfatal. Baseline authentication, authorization, network and unexpected registry errors remain fatal. diff --git a/.github/actions/container-size-diff/container-size-diff.js b/.github/actions/container-size-diff/container-size-diff.js index a7bc5c45..524a12a0 100644 --- a/.github/actions/container-size-diff/container-size-diff.js +++ b/.github/actions/container-size-diff/container-size-diff.js @@ -16,6 +16,23 @@ function inspectManifest(container) { return stdout; } +function normalizedReference(container) { + const parts = container.split("/"); + if ( + parts.length === 1 || + (!/[.:]/.test(parts[0]) && parts[0] !== "localhost") + ) { + parts.unshift("docker.io"); + } + if (parts[0] === "index.docker.io") { + parts[0] = "docker.io"; + } + if (parts[0] === "docker.io" && parts.length === 2) { + parts.splice(1, 0, "library"); + } + return parts.join("/"); +} + function isMissingManifest(error, container) { if ( error?.exitCode !== 1 || @@ -30,8 +47,10 @@ function isMissingManifest(error, container) { // Match the entire diagnostic so mixed registry/authentication errors stay fatal. const diagnostic = error.stderr.trim(); + const missing = /^no such manifest: (\S+)$/.exec(diagnostic); return ( - diagnostic === `no such manifest: ${container}` || + (missing && + normalizedReference(missing[1]) === normalizedReference(container)) || /^(?:Error response from daemon: )?(?:manifest unknown|MANIFEST_UNKNOWN)(?:: (?:manifest unknown|The named manifest is not known to the registry\.?))?$/.test( diagnostic, ) @@ -54,8 +73,21 @@ function getSizes(container, inspect = inspectManifest) { ]).join("/"); const layers = - _.get(manifest, "OCIManifest.layers") || _.get(manifest, "layers", []); + _.get(manifest, "OCIManifest.layers") || _.get(manifest, "layers"); + if ( + key && + !key.includes("unknown") && + (!Array.isArray(layers) || + layers.some( + (layer) => !Number.isSafeInteger(layer?.size) || layer.size < 0, + )) + ) { + throw new Error(`Invalid platform sizes found for ${container}`); + } const total = _.sumBy(layers, "size"); + if (key && !key.includes("unknown") && !Number.isSafeInteger(total)) { + throw new Error(`Invalid platform sizes found for ${container}`); + } return [key, total]; }) diff --git a/.github/actions/container-size-diff/test/container-size-diff.test.js b/.github/actions/container-size-diff/test/container-size-diff.test.js index 3be42167..6221d05f 100644 --- a/.github/actions/container-size-diff/test/container-size-diff.test.js +++ b/.github/actions/container-size-diff/test/container-size-diff.test.js @@ -246,6 +246,28 @@ for (const diagnostic of missingDiagnostics) { }); } +for (const baseline of [ + "fake-image:edge", + "docker.io/fake-image:edge", + "index.docker.io/fake-image:edge", + "fake-org/fake-image:edge", +]) { + test(`missing baseline diagnostics accept Docker reference normalization: ${baseline}`, () => { + const normalized = baseline.includes("fake-org/") + ? "docker.io/fake-org/fake-image:edge" + : "docker.io/library/fake-image:edge"; + const inspect = fakeInspect({ + [baseline]: inspectionError(`no such manifest: ${normalized}`), + "fake-to": [platformManifest(amd64, 1000)], + }); + + assert.match( + containerSizeDiff(baseline, "fake-to", inspect), + /Comparison unavailable/, + ); + }); +} + for (const baselineMissing of [false, true]) { test(`a missing current image fails with baseline missing=${baselineMissing}`, () => { const currentError = inspectionError("no such manifest: fake-to"); @@ -323,6 +345,13 @@ for (const invalidManifest of [ "not JSON", [], [platformManifest({ os: "unknown", architecture: "unknown" }, 10)], + [{ Descriptor: { platform: amd64 } }], + [platformManifest(amd64, -1)], + [platformManifest(amd64, "1000")], + [platformManifest(amd64, null)], + [platformManifest(amd64, 1.5)], + [platformManifest(amd64, Number.MAX_SAFE_INTEGER, 1)], + [{ Descriptor: { platform: amd64 }, OCIManifest: { layers: {} } }], ]) { test(`invalid baseline data remains fatal: ${JSON.stringify(invalidManifest)}`, () => { const inspect = fakeInspect({ @@ -332,7 +361,7 @@ for (const invalidManifest of [ assert.throws( () => containerSizeDiff("fake-from", "fake-to", inspect), - /Unexpected token|No platform sizes found for fake-from/, + /Unexpected token|(?:No|Invalid) platform sizes found for fake-from/, ); }); @@ -347,7 +376,7 @@ for (const invalidManifest of [ assert.throws( () => containerSizeDiff("fake-from", "fake-to", inspect), - /Unexpected token|No platform sizes found for fake-to/, + /Unexpected token|(?:No|Invalid) platform sizes found for fake-to/, ); }); } From cad3811d93e592c2ca657f42b5c45fc4aa360d19 Mon Sep 17 00:00:00 2001 From: Ron <45816308+rjaegers@users.noreply.github.com> Date: Sat, 14 Nov 2026 08:02:35 +0000 Subject: [PATCH 4/4] fix: handle implicit tags and schema-v2 manifests Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../container-size-diff.js | 8 +++++- .../test/container-size-diff.test.js | 28 +++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/.github/actions/container-size-diff/container-size-diff.js b/.github/actions/container-size-diff/container-size-diff.js index 524a12a0..f216eae5 100644 --- a/.github/actions/container-size-diff/container-size-diff.js +++ b/.github/actions/container-size-diff/container-size-diff.js @@ -18,6 +18,10 @@ function inspectManifest(container) { function normalizedReference(container) { const parts = container.split("/"); + const last = parts.length - 1; + if (!parts[last].includes(":") && !parts[last].includes("@")) { + parts[last] += ":latest"; + } if ( parts.length === 1 || (!/[.:]/.test(parts[0]) && parts[0] !== "localhost") @@ -73,7 +77,9 @@ function getSizes(container, inspect = inspectManifest) { ]).join("/"); const layers = - _.get(manifest, "OCIManifest.layers") || _.get(manifest, "layers"); + _.get(manifest, "OCIManifest.layers") || + _.get(manifest, "SchemaV2Manifest.layers") || + _.get(manifest, "layers"); if ( key && !key.includes("unknown") && diff --git a/.github/actions/container-size-diff/test/container-size-diff.test.js b/.github/actions/container-size-diff/test/container-size-diff.test.js index 6221d05f..81f9525d 100644 --- a/.github/actions/container-size-diff/test/container-size-diff.test.js +++ b/.github/actions/container-size-diff/test/container-size-diff.test.js @@ -81,6 +81,17 @@ test("getSizes accepts a single manifest, and layers that are not in an OCI mani assert.deepEqual(getSizes("fake-image", inspect), { "linux/amd64": 1500 }); }); +test("getSizes accepts layers from a schema-v2 manifest", () => { + const inspect = fakeInspect({ + "fake-image": { + Descriptor: { platform: amd64 }, + SchemaV2Manifest: { layers: [{ size: 1000 }, { size: 500 }] }, + }, + }); + + assert.deepEqual(getSizes("fake-image", inspect), { "linux/amd64": 1500 }); +}); + test("getSizes skips manifests without a known platform", () => { const inspect = fakeInspect({ "fake-image": [ @@ -268,6 +279,23 @@ for (const baseline of [ }); } +test("missing baseline diagnostics normalize the implicit latest tag", () => { + for (const baseline of ["fake-image", "fake-org/fake-image"]) { + const normalized = baseline.includes("/") + ? `docker.io/${baseline}:latest` + : `docker.io/library/${baseline}:latest`; + const inspect = fakeInspect({ + [baseline]: inspectionError(`no such manifest: ${normalized}`), + "fake-to": [platformManifest(amd64, 1000)], + }); + + assert.match( + containerSizeDiff(baseline, "fake-to", inspect), + /Comparison unavailable/, + ); + } +}); + for (const baselineMissing of [false, true]) { test(`a missing current image fails with baseline missing=${baselineMissing}`, () => { const currentError = inspectionError("no such manifest: fake-to");