fix(ci): verify registry upload against exported OCI config

This commit is contained in:
Simo committed 2026-09-09 20:33:14 +02:00
1 parent e617ed176a
commit 8dc187483c
4 files changed
+19 -18

No files matched your search

+1 -1
View File
@@ -248,7 +248,7 @@ Publication uses checksum-pinned regctl v0.11.6 with 8 MiB blob requests to
avoid monolithic layer uploads exceeding reverse-proxy limits. Both images are
exported and uploaded sequentially; temporary archives and credentials are removed
on exit. The runner needs curl, sha256sum and temporary disk space for one Docker
image archive. The remote image config digest is checked against the local image
image archive plus its extracted OCI layout. The remote image config digest is checked against the locally normalized archive
after each upload. A proxy must still allow the OCI registry PATCH/PUT endpoints.
For this repository the image base is
+8 -4
View File
@@ -58,12 +58,16 @@ regctl registry set "$registry" --blob-chunk 8388608 --blob-max 8388608
for target in "$image-migrations" "$image"; do
echo "Publishing $target with blob requests up to 8 MiB"
docker image save --output "$context/image.tar" "$target"
regctl image import "$target" "$context/image.tar"
# Import may change compression/manifest representation, but the immutable
# image config digest must still match the exact local image we verified.
expected_config="$(docker image inspect --format '{{.Id}}' "$target")"
# Normalize the saved archive locally before copying it unchanged to Gitea.
# Docker engine IDs and OCI index IDs are not interchangeable with config IDs.
local_ref="ocidir://$context/oci:verified"
regctl image import "$local_ref" "$context/image.tar"
expected_config="$(regctl manifest get "$local_ref" --platform "linux/$arch" --format '{{.GetConfig.Digest}}')"
[[ "$expected_config" =~ ^sha256:[0-9a-f]{64}$ ]] || { echo "Invalid local image config digest" >&2; exit 1; }
regctl image copy "$local_ref" "$target"
remote_config="$(regctl manifest get "$target" --platform "linux/$arch" --format '{{.GetConfig.Digest}}')"
[[ "$remote_config" = "$expected_config" ]] || { echo "Published image config does not match verified local image" >&2; exit 1; }
rm -f -- "$context/image.tar"
rm -rf -- "$context/oci"
done
echo "Published application and migrations: $image"
+8 -11
View File
@@ -57,7 +57,7 @@ describe("verified application image reuse", () => {
expect(calls).not.toContain("docker build --network=host --build-arg");
expect(
calls.indexOf("verify scripts/verify-portable-image.mjs"),
).toBeLessThan(calls.indexOf("regctl image import"));
).toBeLessThan(calls.indexOf("regctl image copy"));
});
it.each(["missing", "mismatch"])(
"builds committed source when verification marker is %s",
@@ -71,16 +71,12 @@ describe("verified application image reuse", () => {
it("uses the token account namespace instead of the repository owner", () => {
const calls = simulate("verified");
expect(calls).toContain(
`regctl image import registry.invalid/simo/cms:${sha}`,
);
expect(calls).toContain(`registry.invalid/simo/cms:${sha}\n`);
expect(calls).not.toContain("registry.invalid/owner/cms");
});
it("supports an explicit organization namespace", () => {
const calls = simulate("verified", "My-Org");
expect(calls).toContain(
`regctl image import registry.invalid/my-org/cms:${sha}`,
);
expect(calls).toContain(`registry.invalid/my-org/cms:${sha}\n`);
});
it("bounds uploads and verifies both published image configs", () => {
@@ -88,15 +84,16 @@ it("bounds uploads and verifies both published image configs", () => {
expect(calls).toContain("--blob-chunk 8388608 --blob-max 8388608");
expect(calls).not.toContain("docker push");
expect(calls.match(/regctl image import/g)).toHaveLength(2);
expect(calls.match(/regctl manifest get/g)).toHaveLength(2);
expect(calls.match(/--platform linux\/amd64/g)).toHaveLength(2);
expect(calls.match(/regctl image copy/g)).toHaveLength(2);
expect(calls.match(/regctl manifest get/g)).toHaveLength(4);
expect(calls.match(/--platform linux\/amd64/g)).toHaveLength(4);
});
it.each(["upload-fails", "wrong-config", "bad-checksum"])(
"stops publication on %s",
(scenario) => {
const calls = simulate(scenario, "", 1);
expect(calls).not.toContain(
`regctl image import registry.invalid/simo/cms:${sha} `,
expect(calls.match(/regctl image copy/g)?.length ?? 0).toBeLessThanOrEqual(
1,
);
},
);
+2 -2
View File
@@ -18,9 +18,9 @@ curl() {
cat > "$output" <<'MOCK'
#!/usr/bin/env bash
echo "regctl $*" >> "$TEST_DIR/calls"
if [[ "$1 $2" = "image import" && "$SCENARIO" = upload-fails ]]; then exit 1; fi
if [[ "$1 $2" = "image copy" && "$SCENARIO" = upload-fails ]]; then exit 1; fi
if [[ "$1 $2" = "manifest get" ]]; then
if [[ "$SCENARIO" = wrong-config ]]; then echo sha256:wrong; else echo sha256:candidate; fi
if [[ "$SCENARIO" = wrong-config && "$3" != ocidir:* ]]; then echo sha256:wrong; else printf "sha256:%064d\n" 0; fi
fi
MOCK
}