Skip to content
Open
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
8 changes: 6 additions & 2 deletions .github/actions/container-size-diff/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand Down
85 changes: 83 additions & 2 deletions .github/actions/container-size-diff/container-size-diff.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,47 @@
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 ||
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) {
Expand All @@ -32,8 +73,21 @@
]).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];
})
Expand Down Expand Up @@ -85,9 +139,36 @@
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)

Check warning on line 156 in .github/actions/container-size-diff/container-size-diff.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Consider Object.keys() instead of keys() from lodash; check that the behavior is equivalent because the library handles nullish values differently from the native API.

See more on https://sonarcloud.io/project/issues?id=philips-software_amp-devcontainer&issues=AaHVy_Yf40ubcmESONkD&open=AaHVy_Yf40ubcmESONkD&pullRequest=1577
.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) {
Expand Down
191 changes: 191 additions & 0 deletions .github/actions/container-size-diff/test/container-size-diff.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down Expand Up @@ -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", () => {
Expand All @@ -190,3 +208,176 @@ 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/,
);
});
}

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/,
);
});
}
}
10 changes: 8 additions & 2 deletions docs/reusable-build-system.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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

Expand Down
Loading