From f42a2cceffd235fe879d09d3b809a20fcd5f494e Mon Sep 17 00:00:00 2001 From: Stefan Gersmann Date: Fri, 25 Sep 2026 13:36:33 +0200 Subject: [PATCH] fix(build-image): tag ECR runtime manifests as latest --- .github/workflows/build-image.yaml | 27 ++++- .github/workflows/ci.pr.yaml | 7 ++ README.md | 10 ++ tests/build-image.test.cjs | 152 +++++++++++++++++++++++++++++ 4 files changed, 194 insertions(+), 2 deletions(-) create mode 100644 tests/build-image.test.cjs diff --git a/.github/workflows/build-image.yaml b/.github/workflows/build-image.yaml index 88956a2..2ec97ca 100644 --- a/.github/workflows/build-image.yaml +++ b/.github/workflows/build-image.yaml @@ -309,8 +309,9 @@ jobs: ]}' mapfile -t TARGET_CONFIGS < <(jq -c '.[]' <<< "$IMAGE_TARGETS") - BAKE_ARGS=(-f "$BAKE_FILE" --push) + BAKE_ARGS=(-f "$BAKE_FILE" --push --metadata-file "$RUNNER_TEMP/bake-metadata.json") BAKE_TARGETS=() + declare -A IMAGE_REPOSITORIES for target_config in "${TARGET_CONFIGS[@]}"; do bake_target=$(jq -r 'if type == "object" then .target else . end' <<< "$target_config") @@ -335,11 +336,33 @@ jobs: --set "$bake_target.args.VERSION=$VERSION" --set "$bake_target.cache-from=type=registry,ref=$image_repository:cache" --set "$bake_target.cache-to=mode=max,image-manifest=true,oci-mediatypes=true,type=registry,ref=$image_repository:cache" - --set "$bake_target.tags=$image_repository:latest" --set "$bake_target.tags=$image_repository:$VERSION" --set "$bake_target.tags=$image_repository:$GITHUB_SHA" ) BAKE_TARGETS+=("$bake_target") + IMAGE_REPOSITORIES["$bake_target"]="$image_repository" done docker buildx bake "${BAKE_ARGS[@]}" "${BAKE_TARGETS[@]}" + + # Inspector scans image manifests, not the index carrying provenance. + # Keep version/SHA indexes intact and point latest at the runtime image. + for bake_target in "${BAKE_TARGETS[@]}"; do + image_repository="${IMAGE_REPOSITORIES[$bake_target]}" + image_digest=$(jq -er --arg target "$bake_target" '.[$target]["containerimage.digest"]' "$RUNNER_TEMP/bake-metadata.json") + manifest=$(docker buildx imagetools inspect --raw "$image_repository@$image_digest") + manifest_digest=$(jq -er --arg digest "$image_digest" ' + if .mediaType == "application/vnd.oci.image.index.v1+json" or + .mediaType == "application/vnd.docker.distribution.manifest.list.v2+json" then + [.manifests[] | select(.annotations["vnd.docker.reference.type"] != "attestation-manifest")] + | if length != 1 then error("latest requires exactly one runtime image manifest") + elif .[0].mediaType == "application/vnd.oci.image.manifest.v1+json" or + .[0].mediaType == "application/vnd.docker.distribution.manifest.v2+json" then .[0].digest + else error("Unsupported runtime image manifest") end + elif .mediaType == "application/vnd.oci.image.manifest.v1+json" or + .mediaType == "application/vnd.docker.distribution.manifest.v2+json" then $digest + else error("Unsupported image manifest") end + ' <<< "$manifest") + docker buildx imagetools create --prefer-index=false \ + --tag "$image_repository:latest" "$image_repository@$manifest_digest" + done diff --git a/.github/workflows/ci.pr.yaml b/.github/workflows/ci.pr.yaml index 36c8e2f..00c88a1 100644 --- a/.github/workflows/ci.pr.yaml +++ b/.github/workflows/ci.pr.yaml @@ -23,6 +23,13 @@ jobs: run: go install github.com/rhysd/actionlint/cmd/actionlint@v1.6.23 - name: Run actionlint run: /home/runner/go/bin/actionlint + - name: Set up node environment + uses: actions/setup-node@v7 + with: + node-version: lts/* + - run: npm ci + - name: Test image publishing + run: node --test tests/build-image.test.cjs yamllint: # Local copy, not @v9: a PR must validate its own workflow changes. uses: ./.github/workflows/yaml.yaml diff --git a/README.md b/README.md index 56573d4..9909fb1 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,16 @@ good CI/CD practices to the community. While the structure is very much solving parcelLab's unique use cases, the files here could be reused by anybody else as they do not have any business logic attached. +## ECR Image Tags + +Docker Bake builds retain the version and Git SHA tags on their original image indexes, +including build attestations. The `latest` tag points directly to the single runtime +image manifest so Amazon Inspector findings carry that tag. Attestations remain +available through the version/SHA references, not through `latest`. + +Bake targets with multiple runtime platforms fail rather than selecting one for +`latest`. Existing images are not retagged; this takes effect on subsequent builds. + ## Contributing [Contribution guidelines](CONTRIBUTING.md) diff --git a/tests/build-image.test.cjs b/tests/build-image.test.cjs new file mode 100644 index 0000000..2ac5f62 --- /dev/null +++ b/tests/build-image.test.cjs @@ -0,0 +1,152 @@ +const assert = require("node:assert/strict"); +const { spawnSync } = require("node:child_process"); +const { mkdtempSync, readFileSync, rmSync } = require("node:fs"); +const { tmpdir } = require("node:os"); +const { join } = require("node:path"); +const { test } = require("node:test"); +const yaml = require("js-yaml"); + +const workflow = yaml.load( + readFileSync(".github/workflows/build-image.yaml", "utf8"), +); +const script = workflow.jobs["build-ecr-bake"].steps.find( + (step) => step.name === "Build images with Docker Bake", +).run; +const indexType = "application/vnd.oci.image.index.v1+json"; +const imageType = "application/vnd.oci.image.manifest.v1+json"; +const indexDigest = `sha256:${"a".repeat(64)}`; +const imageDigest = `sha256:${"b".repeat(64)}`; +const image = { + mediaType: imageType, + digest: imageDigest, + platform: { os: "linux", architecture: "amd64" }, +}; +const attestation = { + mediaType: imageType, + digest: `sha256:${"c".repeat(64)}`, + annotations: { "vnd.docker.reference.type": "attestation-manifest" }, +}; + +for (const [name, manifest, expectedDigest] of [ + [ + "OCI index with provenance", + { mediaType: indexType, manifests: [attestation, image] }, + imageDigest, + ], + [ + "Docker manifest list", + { + mediaType: + "application/vnd.docker.distribution.manifest.list.v2+json", + manifests: [image], + }, + imageDigest, + ], + ["direct OCI image", { mediaType: imageType }, indexDigest], + [ + "direct Docker image", + { mediaType: "application/vnd.docker.distribution.manifest.v2+json" }, + indexDigest, + ], + [ + "multiple runtime platforms", + { + mediaType: indexType, + manifests: [ + image, + { ...image, platform: { os: "linux", architecture: "arm64" } }, + ], + }, + null, + ], + [ + "attestation only", + { mediaType: indexType, manifests: [attestation] }, + null, + ], + ["unsupported manifest", { mediaType: "unsupported" }, null], +]) { + test(name, (t) => { + const directory = mkdtempSync(join(tmpdir(), "ci-image-tags-")); + t.after(() => rmSync(directory, { recursive: true })); + const result = spawnSync( + "bash", + [ + "-c", + ` + aws() { return 0; } + docker() { + printf '%s\\n' "$*" >> "$RUNNER_TEMP/docker.log" + case "$1 $2 $3" in + "buildx bake -f") printf '%s' "$METADATA_JSON" > "$RUNNER_TEMP/bake-metadata.json" ;; + "buildx imagetools inspect") printf '%s' "$MANIFEST_JSON" ;; + "buildx imagetools create") ;; + *) return 1 ;; + esac + } + ${script} + `, + ], + { + encoding: "utf8", + env: { + ...process.env, + APP_NAME: "app", + BAKE_FILE: "docker-bake.hcl", + ENVIRONMENT: "test", + GITHUB_SHA: "commit-sha", + IMAGE_TARGETS: + '["web", {"target":"worker", "imageTarget":"default"}]', + REGISTRY: "registry.example.invalid", + NPM_GITHUB_TOKEN: "", + VERSION: "v1", + RUNNER_TEMP: directory, + MANIFEST_JSON: JSON.stringify(manifest), + METADATA_JSON: JSON.stringify({ + web: { "containerimage.digest": indexDigest }, + worker: { "containerimage.digest": indexDigest }, + }), + }, + }, + ); + assert.ifError(result.error); + assert.equal( + result.status, + expectedDigest === null ? 5 : 0, + result.stderr, + ); + const commands = readFileSync(join(directory, "docker.log"), "utf8") + .trim() + .split("\n"); + assert.match(commands[0], /--metadata-file /); + assert.doesNotMatch(commands[0], /:latest/); + const creates = commands.filter((command) => + command.startsWith("buildx imagetools create "), + ); + if (expectedDigest === null) { + assert.equal(creates.length, 0); + } else { + for (const [target, repository] of [ + ["web", "app-web"], + ["worker", "app-default"], + ]) { + const ref = `registry.example.invalid/${repository}`; + assert.ok(commands[0].includes(`${target}.tags=${ref}:v1`)); + assert.ok( + commands[0].includes(`${target}.tags=${ref}:commit-sha`), + ); + assert.ok( + commands.includes( + `buildx imagetools inspect --raw ${ref}@${indexDigest}`, + ), + ); + assert.ok( + creates.includes( + `buildx imagetools create --prefer-index=false --tag ${ref}:latest ${ref}@${expectedDigest}`, + ), + ); + } + assert.equal(creates.length, 2); + } + }); +}