diff --git a/README.md b/README.md index 79bcd14851..6dc431e873 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/scripts/publish-container.sh b/scripts/publish-container.sh index 54cf64c60f..f8f6ff124e 100644 --- a/scripts/publish-container.sh +++ b/scripts/publish-container.sh @@ -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" diff --git a/src/lib/publish-container.test.ts b/src/lib/publish-container.test.ts index 40fbbfd5eb..e03cce876a 100644 --- a/src/lib/publish-container.test.ts +++ b/src/lib/publish-container.test.ts @@ -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, ); }, ); diff --git a/src/test/publish-container-harness.sh b/src/test/publish-container-harness.sh index df4f935804..dc773485b4 100644 --- a/src/test/publish-container-harness.sh +++ b/src/test/publish-container-harness.sh @@ -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 }