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