Skip to content
Merged
21 changes: 15 additions & 6 deletions .github/workflows/__test-workflow-docker-build-images.yml
Original file line number Diff line number Diff line change
Expand Up @@ -153,21 +153,30 @@ jobs:
const taggedVersions = versions.filter(version => version.metadata.container.tags.length > 0);
const untaggedVersions = versions.filter(version => version.metadata.container.tags.length === 0);

// Expected tagged version is 1 for unsigned images (tag) and 2 for signed images (tag, cosing legacy tag sha256-...)
const expectedTaggedVersions = process.env.SIGN === 'true' ? 2 : 1;
const platforms = JSON.parse(process.env.PLATFORMS);
const isSinglePlatform = platforms.length === 1;
const isSigned = process.env.SIGN === 'true';

// Expected untagged versions are 1 by platform for unsigned images and number of platforms + 1 (cosing legacy tag sha256-...) for signed images
const expectedUntaggedVersions = JSON.parse(process.env.PLATFORMS).length + (process.env.SIGN === 'true' ? 1 : 0);
// Expected tagged versions:
// - Always 1 for the main tag
// - Plus 1 for cosign legacy tag (sha256-...) when signed
// Note: ghcr.io doesn't support OCI 1.1 referrers yet, so cosign falls back to legacy attachments
const expectedTaggedVersions = isSigned ? 2 : 1;

// Expected untagged versions:
// - For single platform: 0 (no untagged versions when optimized)
// - For multi platform: number of platforms (one per platform digest-only push)
const expectedUntaggedVersions = isSinglePlatform ? 0 : platforms.length;

assert.equal(
taggedVersions.length,
expectedTaggedVersions,
`Expected ${expectedTaggedVersions} tagged versions, but found ${taggedVersions.length}`
`Expected ${expectedTaggedVersions} tagged versions, but found ${taggedVersions.length}: ${JSON.stringify(taggedVersions, null, 2)}`
);
assert.equal(
untaggedVersions.length,
expectedUntaggedVersions,
`Expected ${expectedUntaggedVersions} untagged versions, but found ${untaggedVersions.length}`
`Expected ${expectedUntaggedVersions} untagged versions, but found ${untaggedVersions.length}: ${JSON.stringify(untaggedVersions, null, 2)}`
);

- name: Verify image manifest and platforms
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/docker-build-images.yml
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,8 @@ jobs:
...image,
platform: platformName,
"runs-on": platformRunsOn,
// For single-platform builds, don't use push-by-digest to avoid untagged versions
"push-by-digest": platforms.length > 1,
};
imagesByPlatform.push(imageByPlatform);
}
Expand Down Expand Up @@ -425,6 +427,7 @@ jobs:
secret-envs: ${{ steps.prepare-secret-envs.outputs.secret-envs }}
secrets: ${{ secrets.build-secrets }}
cache-type: ${{ inputs.cache-type }}
push-by-digest: ${{ matrix.image.push-by-digest }}

# FIXME: Set built images infos in file to be uploaded as artifacts, because github action does not handle job outputs for matrix
# https://github.com/orgs/community/discussions/26639
Expand Down
11 changes: 9 additions & 2 deletions actions/docker/build-image/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,13 @@ inputs:
See https://docs.docker.com/build/cache/backends.
default: "gha"
required: false
push-by-digest:
description: |
Whether to push by digest only (without tags).
Set to false for single-platform builds to avoid creating untagged versions.
Set to true for multi-platform builds (required for manifest creation).
default: "true"
required: false

outputs:
built-image:
Expand Down Expand Up @@ -297,10 +304,10 @@ runs:
# FIXME: Remove 'inputs.cache-type == 'gha' && steps.transform-cache-gha.outputs.cache-to ||'
# when https://github.com/int128/docker-build-cache-config-action/pull/1213 is merged
cache-to: ${{ inputs.cache-type == 'gha' && steps.transform-cache-gha.outputs.cache-to || steps.cache-arguments.outputs.cache-to }}
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
outputs: ${{ inputs.push-by-digest == 'true' && 'type=image,push-by-digest=true,name-canonical=true,push=true' || 'type=image,push=true' }}
labels: ${{ steps.metadata.outputs.labels }}
annotations: ${{ steps.metadata.outputs.annotations }}
tags: ${{ steps.metadata.outputs.image }}
tags: ${{ inputs.push-by-digest == 'true' && steps.metadata.outputs.image || steps.metadata.outputs.tags }}
provenance: false # Disable provenance to avoid unknown/unknown arch
sbom: false # Disable sbom to avoid unknown/unknown arch

Expand Down
89 changes: 68 additions & 21 deletions actions/docker/create-images-manifests/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -109,41 +109,88 @@ runs:
throw new Error(`"built-images" input is not a valid JSON: ${error}`);
}

// Create manifest for each image
const commands = Object.keys(builtImages).map(imageName => {
const builtImage = builtImages[imageName];
// Helper function to build image tags from registry, repository and tag list
function buildImageTags(builtImage) {
return builtImage.tags.map(tag =>
`${builtImage.registry}/${builtImage.repository}:${tag}`
);
}

const imagesWithTags = builtImage.tags.map(tag => {
return `${builtImage.registry}/${builtImage.repository}:${tag}`;
});
// Helper function to validate image data
function validateImage(builtImage) {
if (!builtImage.images || builtImage.images.length === 0) {
throw new Error(`No images found for "${builtImage.name}"`);
}
}

const platformsOption = builtImage.platforms.map(platform => `--platform ${platform}`).join(" ");
// Helper function to tag single-platform image
async function tagSinglePlatformImage(builtImage, imagesWithTags) {
core.info(`Tagging single-platform image "${builtImage.name}" (skipping multiarch manifest creation)`);

const tagsOption = imagesWithTags.map(image => {
return `--tag ${image}`;
}).join(" ");
validateImage(builtImage);

const sources = builtImage.images.join(" ");
const sourceImage = builtImage.images[0];
for (const targetImage of imagesWithTags) {
const tagCommand = `docker buildx imagetools create --tag ${targetImage} ${sourceImage}`;
await exec.exec(tagCommand);
core.debug(`Tagged single-platform image "${builtImage.name}"`);
}

builtImage.images = imagesWithTags;
}

// Helper function to build annotations options
function buildAnnotationsOption(annotations) {
const annotationLevels = ["index"];
const annotationsOption = Object.keys(builtImage.annotations)
return Object.keys(annotations)
.map(annotation => annotationLevels
.map(annotationLevel => `--annotation "${annotationLevel}:${annotation}=${builtImage.annotations[annotation]}"`)
.map(annotationLevel => `--annotation "${annotationLevel}:${annotation}=${annotations[annotation]}"`)
)
.flat().join(" ");
.flat()
.join(" ");
}

// Helper function to create multiarch manifest
async function createMultiarchManifest(builtImage, imagesWithTags) {
const platformsOption = builtImage.platforms.map(platform => `--platform ${platform}`).join(" ");
const tagsOption = imagesWithTags.map(image => `--tag ${image}`).join(" ");
const sources = builtImage.images.join(" ");
const annotationsOption = buildAnnotationsOption(builtImage.annotations);

const createManifestCommand = `docker buildx imagetools create ${platformsOption} ${annotationsOption} ${tagsOption} ${sources}`;

return new Promise(async (resolve, reject) => {
try {
await exec.exec(createManifestCommand);
core.debug(`Create manifest for "${builtImage.name}" ("${createManifestCommand}") executed`);
await exec.exec(createManifestCommand);
core.debug(`Create manifest for "${builtImage.name}" ("${createManifestCommand}") executed`);

// Update builtImage with the images with tags
builtImage.images = imagesWithTags;
builtImage.images = imagesWithTags;
}

// Process each image
const commands = Object.keys(builtImages).map(imageName => {
const builtImage = builtImages[imageName];
const imagesWithTags = buildImageTags(builtImage);

return new Promise(async (resolve, reject) => {
try {
if (builtImage.platforms.length <= 1) {
// For single-platform builds pushed with tags (not by digest),
// the image reference already includes the tag, so we just need to update the images array
const sourceImage = builtImage.images[0];
const isPushedByDigest = sourceImage.match(/@sha256:[a-f0-9]{64}$/) && !sourceImage.match(/:[^:@]+@sha256:/);

if (isPushedByDigest) {
// Image was pushed by digest only, need to tag it
await tagSinglePlatformImage(builtImage, imagesWithTags);
} else {
// Image was already pushed with tags, just ensure images array is updated
core.info(`Single-platform image "${builtImage.name}" was already pushed with tags`);
builtImage.images = imagesWithTags;
}
} else {
await createMultiarchManifest(builtImage, imagesWithTags);
}
resolve();
} catch(error){
} catch (error) {
reject(error);
}
});
Comment thread
neilime marked this conversation as resolved.
Expand Down
2 changes: 1 addition & 1 deletion actions/docker/get-image-metadata/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ runs:
script: |
const tagInput = `${{ inputs.tag }}`
if (tagInput.length) {
core.setOutput('tags',`type=semver,pattern={{raw}},value=${tagInput}`);
core.setOutput('tags',`type=raw,value=${tagInput}`);
core.setOutput('flavor', 'latest=false');
return;
}
Expand Down
7 changes: 6 additions & 1 deletion actions/docker/sign-images/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ runs:

- name: Sign the images with GitHub OIDC Token
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
env:
COSIGN_EXPERIMENTAL: "1"
with:
github-token: ${{ inputs.github-token }}
script: |
Expand Down Expand Up @@ -88,7 +90,10 @@ runs:
// Sign the images
const annotationsArgs = tags.size > 0 ? `-a tag=${Array.from(tags).at(-1)}` : "";
const imagesArgs = Array.from(imagesToSign).join(" ");
const signImageCommand = `cosign sign ${annotationsArgs} --yes ${imagesArgs}`;
// Use OCI 1.1 referrers mode to avoid creating legacy sha256-... tags
// Note: If the registry doesn't support OCI 1.1 referrers (like ghcr.io currently),
// cosign will fall back to legacy attachments and create a sha256-... tag
const signImageCommand = `cosign sign ${annotationsArgs} --registry-referrers-mode=oci-1-1 --yes ${imagesArgs}`;

core.debug(`Signing images with command: "${signImageCommand}"`);
await exec.exec(signImageCommand);
Expand Down
6 changes: 3 additions & 3 deletions tests/charts/application/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@ A Helm chart for Kubernetes

## Requirements

| Repository | Name | Version |
| ---------------------------------- | ----- | ------- |
| https://charts.bitnami.com/bitnami | mysql | 14.0.3 |
| Repository | Name | Version |
| ------------------------------------ | ----- | ------- |
| <https://charts.bitnami.com/bitnami> | MySQL | 14.0.3 |

## Values

Expand Down
8 changes: 4 additions & 4 deletions tests/charts/umbrella-application/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,10 @@ An umbrella Helm chart for Kubernetes

## Requirements

| Repository | Name | Version |
| ---------------------------------- | --------------- | ------- |
| file://./charts/app | app | 0.0.0 |
| https://charts.bitnami.com/bitnami | database(mysql) | 14.0.3 |
| Repository | Name | Version |
| ------------------------------------ | --------------- | ------- |
| file://./charts/app | app | 0.0.0 |
| <https://charts.bitnami.com/bitnami> | database(MySQL) | 14.0.3 |

## Values

Expand Down
Loading