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
27 changes: 25 additions & 2 deletions .github/workflows/build-image.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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
7 changes: 7 additions & 0 deletions .github/workflows/ci.pr.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
152 changes: 152 additions & 0 deletions tests/build-image.test.cjs
Original file line number Diff line number Diff line change
@@ -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);
}
});
}
Loading