diff --git a/.github/actions/container-size-diff/README.md b/.github/actions/container-size-diff/README.md index 5f100215..43b35d6f 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 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. ## 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..f216eae5 100644 --- a/.github/actions/container-size-diff/container-size-diff.js +++ b/.github/actions/container-size-diff/container-size-diff.js @@ -16,6 +16,51 @@ function inspectManifest(container) { return stdout; } +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") + ) { + 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 || + 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(); + const missing = /^no such manifest: (\S+)$/.exec(diagnostic); + return ( + (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, + ) + ); +} + // 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) { @@ -32,8 +77,23 @@ 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") && + (!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]; }) @@ -85,9 +145,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..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 @@ -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": [ @@ -67,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": [ @@ -165,6 +190,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 +219,193 @@ 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 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/, + ); + }); +} + +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"); + 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)], + [{ 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({ + "fake-from": invalidManifest, + "fake-to": [platformManifest(amd64, 1000)], + }); + + assert.throws( + () => containerSizeDiff("fake-from", "fake-to", inspect), + /Unexpected token|(?:No|Invalid) 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|Invalid) 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