diff --git a/.github/workflows/__test-workflow-docker-build-images.yml b/.github/workflows/__test-workflow-docker-build-images.yml index 6c39bb79..dd93bc8d 100644 --- a/.github/workflows/__test-workflow-docker-build-images.yml +++ b/.github/workflows/__test-workflow-docker-build-images.yml @@ -105,6 +105,11 @@ jobs: ) ); + assert.equal(applicationMultiArchImage.platforms.length, 3); + assert(applicationMultiArchImage.platforms.includes("linux/amd64")); + assert(applicationMultiArchImage.platforms.includes("linux/arm64")); + assert(applicationMultiArchImage.platforms.includes("linux/arm/v7")); + const applicationMonoArchImage = builtImages["test-mono-arch"]; assert.equal(applicationMonoArchImage.name, "test-mono-arch"); @@ -121,6 +126,8 @@ jobs: applicationMonoArchImage.images[0], `ghcr.io/hoverkraft-tech/ci-github-container/test-mono-arch:0.1.0@${applicationMonoArchImage.digest}` ); + assert.equal(applicationMonoArchImage.platforms.length, 1); + assert(applicationMonoArchImage.platforms.includes("linux/amd64")); - uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 with: @@ -278,8 +285,8 @@ jobs: secrets: oci-registry-password: ${{ secrets.GITHUB_TOKEN }} build-secrets: | - SECRET_REPOSITORY_OWNER=${{ github.repository_owner }} - SECRET_REPOSITORY=${{ github.repository }} + SECRET_TEST=test-secret + SECRET_ANOTHER_TEST=another-test-secret build-secret-github-app-key: ${{ secrets.CI_BOT_APP_PRIVATE_KEY }} with: cache-type: registry @@ -293,12 +300,12 @@ jobs: "platforms": ["linux/amd64","linux/arm64"], "build-args": { "BUILD_RUN_ID": "${{ github.run_id }}", - "BUILD_REPOSITORY_OWNER": "${{ github.repository_owner }}", - "BUILD_REPOSITORY": "${{ github.repository }}" + "BUILD_ARG_TEST": "test-arg", + "BUILD_ARG_ANOTHER_TEST": "another-test-arg" }, "secret-envs": { - "SECRET_ENV_REPOSITORY_OWNER": "GITHUB_REPOSITORY_OWNER", - "SECRET_ENV_REPOSITORY": "GITHUB_REPOSITORY" + "SECRET_ENV_TEST": "GITHUB_ACTION", + "SECRET_ENV_ANOTHER_TEST": "GITHUB_JOB" } } ] diff --git a/.github/workflows/docker-build-images.yml b/.github/workflows/docker-build-images.yml index 0bfc17af..265c41b3 100644 --- a/.github/workflows/docker-build-images.yml +++ b/.github/workflows/docker-build-images.yml @@ -6,31 +6,6 @@ name: Docker build images on: # yamllint disable-line rule:truthy workflow_call: - outputs: - built-images: - description: | - Built images data. - Example: - ```json - { - "application": { - "name": "application", - "registry": "ghcr.io", - "repository": "my-org/my-repo/application", - "tags": ["pr-63-5222075","pr-63"], - "images": [ - "ghcr.io/my-org/my-repo/application:pr-63-5222075@sha256:d31aa93410434ac9dcfc9179cac2cb1fd4d7c27f11527addc40299c7c675f49d", - "ghcr.io/my-org/my-repo/application:pr-63@sha256:d31aa93410434ac9dcfc9179cac2cb1fd4d7c27f11527addc40299c7c675f49d" - ], - "digest": "sha256:d31aa93410434ac9dcfc9179cac2cb1fd4d7c27f11527addc40299c7c675f49d", - "annotations": { - "org.opencontainers.image.created": "2021-09-30T14:00:00Z", - "org.opencontainers.image.description": "Application image" - } - } - } - ``` - value: ${{ jobs.publish-manifests.outputs.built-images }} inputs: runs-on: description: | @@ -141,6 +116,32 @@ on: # yamllint disable-line rule:truthy GitHub App private key to generate GitHub token to be passed as build secret env. See https://github.com/actions/create-github-app-token. required: false + outputs: + built-images: + description: | + Built images data. + Example: + ```json + { + "application": { + "name": "application", + "registry": "ghcr.io", + "repository": "my-org/my-repo/application", + "tags": ["pr-63-5222075","pr-63"], + "images": [ + "ghcr.io/my-org/my-repo/application:pr-63-5222075@sha256:d31aa93410434ac9dcfc9179cac2cb1fd4d7c27f11527addc40299c7c675f49d", + "ghcr.io/my-org/my-repo/application:pr-63@sha256:d31aa93410434ac9dcfc9179cac2cb1fd4d7c27f11527addc40299c7c675f49d" + ], + "digest": "sha256:d31aa93410434ac9dcfc9179cac2cb1fd4d7c27f11527addc40299c7c675f49d", + "annotations": { + "org.opencontainers.image.created": "2021-09-30T14:00:00Z", + "org.opencontainers.image.description": "Application image" + }, + "platforms": ["linux/amd64", "linux/arm64"] + } + } + ``` + value: ${{ jobs.publish-manifests.outputs.built-images }} permissions: contents: read @@ -474,15 +475,17 @@ jobs: // Group by image name const images = {}; builtImages.forEach(builtImage => { - const { name, image, ...rest } = builtImage; + const { name, image, platform, ...rest } = builtImage; if (!images[name]) { images[name] = { name, images: [image], + platforms: [platform], ...rest, }; } else { images[name].images = [...new Set([...images[name].images, image])]; + images[name].platforms = [...new Set([...images[name].platforms, platform])]; } }); @@ -512,6 +515,7 @@ jobs: built-images: ${{ steps.built-images.outputs.built-images }} - id: get-images-to-sign + if: inputs.sign uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 with: script: | @@ -527,7 +531,7 @@ jobs: const imagesToSign = Object.values(builtImages).map(image => image.images).flat(); core.setOutput('images-to-sign', JSON.stringify(imagesToSign)); - uses: ./self-workflow/actions/docker/sign-images - if: inputs.sign + if: steps.get-images-to-sign.outputs.images-to-sign with: images: ${{ steps.get-images-to-sign.outputs.images-to-sign }} github-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/actions/docker/build-image/action.yml b/actions/docker/build-image/action.yml index e253fda6..b10e4e7e 100644 --- a/actions/docker/build-image/action.yml +++ b/actions/docker/build-image/action.yml @@ -10,30 +10,6 @@ branding: icon: package color: blue -outputs: - built-image: - description: | - Built image data. - Example: - ```json - { - "name": "application", - "registry": "ghcr.io", - "repository": "my-org/my-repo/application", - "digest": "sha256:d31aa93410434ac9dcfc9179cac2cb1fd4d7c27f11527addc40299c7c675f49d", - "image": "ghcr.io/my-org/my-repo/application@sha256:d31aa93410434ac9dcfc9179cac2cb1fd4d7c27f11527addc40299c7c675f49d", - "tags": [ - "pr-63-5222075", - "pr-63" - ], - "annotations": { - "org.opencontainers.image.created": "2021-09-30T14:00:00Z", - "org.opencontainers.image.description": "Application image" - } - } - ``` - value: ${{ steps.get-built-image.outputs.built-image }} - inputs: oci-registry: description: "OCI registry where to pull and push images" @@ -70,7 +46,7 @@ inputs: required: false platform: description: | - Platform to build for. + Platform to build for. Example: `linux/amd64`. See https://github.com/docker/build-push-action#inputs. required: true context: @@ -112,6 +88,31 @@ inputs: default: "gha" required: false +outputs: + built-image: + description: | + Built image data. + Example: + ```json + { + "name": "application", + "registry": "ghcr.io", + "repository": "my-org/my-repo/application", + "digest": "sha256:d31aa93410434ac9dcfc9179cac2cb1fd4d7c27f11527addc40299c7c675f49d", + "image": "ghcr.io/my-org/my-repo/application@sha256:d31aa93410434ac9dcfc9179cac2cb1fd4d7c27f11527addc40299c7c675f49d", + "tags": [ + "pr-63-5222075", + "pr-63" + ], + "annotations": { + "org.opencontainers.image.created": "2021-09-30T14:00:00Z", + "org.opencontainers.image.description": "Application image" + }, + "platform": "linux/amd64" + } + ``` + value: ${{ steps.get-built-image.outputs.built-image }} + runs: using: "composite" steps: @@ -142,6 +143,14 @@ runs: - id: get-docker-config shell: bash run: | + DOCKERFILE_PATH="${{ github.workspace }}/${{ inputs.context }}/${{ inputs.dockerfile }}" + if [ ! -f "$DOCKERFILE_PATH" ]; then + echo "::error::Dockerfile not found at path: $DOCKERFILE_PATH" + exit 1 + fi + DOCKERFILE_PATH=$(realpath "$DOCKERFILE_PATH") + echo "dockerfile-path=$DOCKERFILE_PATH" >> "$GITHUB_OUTPUT" + TAG_SUFFIX="-${{ steps.slugify-platform.outputs.result }}" # Add tag suffix flavor @@ -201,7 +210,24 @@ runs: fi fi - - id: cache + - if: steps.get-docker-config.outputs.docker-exists != 'true' + uses: docker/setup-docker-action@3fb92d6d9c634363128c8cce4bc3b2826526370a # v4.4.0 + + - if: steps.get-docker-config.outputs.platform-exists != 'true' + uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0 + with: + platforms: ${{ inputs.platform }} + + - uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1 + with: + # FIXME: upgrade version when available (https://github.com/docker/buildx/releases) + version: v0.29.1 + # FIXME: upgrade version when available (https://hub.docker.com/r/moby/buildkit) + driver-opts: | + image=moby/buildkit:v0.25.1 + + # Caching setup + - id: cache-arguments uses: int128/docker-build-cache-config-action@fb186e80c08f14a2e56ed9105d4594562bff013f # v1.40.0 with: image: ${{ steps.get-docker-config.outputs.cache-image }} @@ -216,8 +242,8 @@ runs: uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 with: script: | - const cacheFrom = `${{ steps.cache.outputs.cache-from }}`; - const cacheTo = `${{ steps.cache.outputs.cache-to }}`; + const cacheFrom = `${{ steps.cache-arguments.outputs.cache-from }}`; + const cacheTo = `${{ steps.cache-arguments.outputs.cache-to }}`; core.info(`Original cache-from: ${cacheFrom}`); core.info(`Original cache-to: ${cacheTo}`); @@ -234,22 +260,19 @@ runs: core.setOutput('cache-from', transformedCacheFrom); core.setOutput('cache-to', transformedCacheTo); - - if: steps.get-docker-config.outputs.docker-exists != 'true' - uses: docker/setup-docker-action@3fb92d6d9c634363128c8cce4bc3b2826526370a # v4.4.0 - - - if: steps.get-docker-config.outputs.platform-exists != 'true' - uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0 + - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + id: cache with: - platforms: ${{ inputs.platform }} + path: cache-mount + key: cache-mount-${{ hashFiles(steps.get-docker-config.outputs.dockerfile-path) }} - # jscpd:ignore-start - - uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1 + - name: Restore Docker cache mounts + uses: reproducible-containers/buildkit-cache-dance@5b81f4d29dc8397a7d341dba3aeecc7ec54d6361 # v3.3.0 with: - # FIXME: upgrade version when available (https://github.com/docker/buildx/releases) - version: v0.27.0 - # FIXME: upgrade version when available (https://hub.docker.com/r/moby/buildkit) - driver-opts: | - image=moby/buildkit:v0.23.2 + builder: ${{ steps.setup-buildx.outputs.name }} + cache-dir: cache-mount + dockerfile: ${{ steps.get-docker-config.outputs.dockerfile-path }} + skip-extraction: ${{ steps.cache.outputs.cache-hit }} - uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 with: @@ -264,19 +287,22 @@ runs: context: ${{ inputs.context }} build-args: ${{ inputs.build-args }} target: ${{ inputs.target }} - file: ${{ github.workspace }}/${{ inputs.context }}/${{ inputs.dockerfile }} + file: ${{ steps.get-docker-config.outputs.dockerfile-path }} secrets: ${{ inputs.secrets }} secret-envs: ${{ inputs.secret-envs }} platforms: ${{ inputs.platform }} # FIXME: Remove 'inputs.cache-type == 'gha' && steps.transform-cache-gha.outputs.cache-from ||' # when https://github.com/int128/docker-build-cache-config-action/pull/1213 is merged - cache-from: ${{ inputs.cache-type == 'gha' && steps.transform-cache-gha.outputs.cache-from || steps.cache.outputs.cache-from }} + cache-from: ${{ inputs.cache-type == 'gha' && steps.transform-cache-gha.outputs.cache-from || steps.cache-arguments.outputs.cache-from }} # 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.outputs.cache-to }} - outputs: "type=image,push=true,push-by-digest=true,name-canonical=true,name=${{ steps.metadata.outputs.image }}" + 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 labels: ${{ steps.metadata.outputs.labels }} annotations: ${{ steps.metadata.outputs.annotations }} + tags: ${{ steps.metadata.outputs.image }} + provenance: false # Disable provenance to avoid unknown/unknown arch + sbom: false # Disable sbom to avoid unknown/unknown arch - id: get-built-image uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 @@ -288,10 +314,6 @@ runs: return; } - if (builtMetadata["containerimage.digest"] === undefined) { - return core.setFailed('Given "metadata"."containerimage.digest" output is undefined.'); - } - const name = `${{ inputs.image }}`; const image = `${{ steps.metadata.outputs.image }}`; const registryMatch = image.match(/^([^\/]+)\/.*/); @@ -304,6 +326,37 @@ runs: .map(tag => tag.replace(/[^\/]+\/[^:]+:(.+)/,'$1').trim()) .filter(tag => tag !== ""); + let platform; + + const buildxProvenance = builtMetadata?.["buildx.build.provenance"]; + if (buildxProvenance !== undefined) { + platform = buildxProvenance.invocation?.environment?.platform; + if (platform === undefined) { + return core.setFailed('Given "metadata"."buildx.build.provenance"."invocation"."environment"."platform" output is undefined.'); + } + if (typeof platform !== "string") { + return core.setFailed('Given "metadata"."buildx.build.provenance"."invocation"."environment"."platform" is not a string.'); + } + platform = platform.trim(); + if (platform === "") { + return core.setFailed('Given "metadata"."buildx.build.provenance"."invocation"."environment"."platform" is empty.'); + } + } else { + const descriptor = builtMetadata?.["containerimage.descriptor"]; + if (descriptor?.["platform"] === undefined) { + return core.setFailed('Given "metadata"."containerimage.descriptor"."platform" output is undefined.'); + } + const platformData = descriptor["platform"]; + if (typeof platformData !== 'object' || platformData.os === undefined || platformData.architecture === undefined) { + return core.setFailed('Given "metadata"."containerimage.descriptor"."platform" does not contain required "os" and "architecture" fields.'); + } + platform = `${platformData.os}/${platformData.architecture}${platformData.variant ? `/${platformData.variant}` : ''}`; + } + + if (builtMetadata?.["containerimage.digest"] === undefined) { + return core.setFailed('Given "metadata"."containerimage.digest" output is undefined.'); + } + const digests = builtMetadata["containerimage.digest"] .split(",") .map(digest => { @@ -346,7 +399,8 @@ runs: registry, repository, image: imageWithDigest, - digest + digest, + platform }; core.setOutput("built-image", JSON.stringify(builtImage)); diff --git a/actions/docker/create-images-manifests/action.yml b/actions/docker/create-images-manifests/action.yml index 4c3ef09b..3717371b 100644 --- a/actions/docker/create-images-manifests/action.yml +++ b/actions/docker/create-images-manifests/action.yml @@ -45,7 +45,8 @@ inputs: "annotations": { "org.opencontainers.image.created": "2021-09-30T14:00:00Z", "org.opencontainers.image.description": "Application image" - } + }, + "platforms": ["linux/amd64", "linux/arm64"] } } ``` @@ -71,7 +72,8 @@ outputs: "annotations": { "org.opencontainers.image.created": "2021-09-30T14:00:00Z", "org.opencontainers.image.description": "Application image" - } + }, + "platforms": ["linux/amd64", "linux/arm64"] } } ``` @@ -83,10 +85,10 @@ runs: - uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1 with: # FIXME: upgrade version when available (https://github.com/docker/buildx/releases) - version: v0.27.0 + version: v0.29.1 # FIXME: upgrade version when available (https://hub.docker.com/r/moby/buildkit) driver-opts: | - image=moby/buildkit:v0.23.2 + image=moby/buildkit:v0.25.1 - uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 with: @@ -115,6 +117,8 @@ runs: return `${builtImage.registry}/${builtImage.repository}:${tag}`; }); + const platformsOption = builtImage.platforms.map(platform => `--platform ${platform}`).join(" "); + const tagsOption = imagesWithTags.map(image => { return `--tag ${image}`; }).join(" "); @@ -128,7 +132,7 @@ runs: ) .flat().join(" "); - const createManifestCommand = `docker buildx imagetools create ${annotationsOption} ${tagsOption} ${sources}`; + const createManifestCommand = `docker buildx imagetools create ${platformsOption} ${annotationsOption} ${tagsOption} ${sources}`; return new Promise(async (resolve, reject) => { try { diff --git a/actions/docker/sign-images/action.yml b/actions/docker/sign-images/action.yml index c52c67f4..85433ade 100644 --- a/actions/docker/sign-images/action.yml +++ b/actions/docker/sign-images/action.yml @@ -61,20 +61,34 @@ runs: } // Ensure images are in the correct format - const imageRegex = /^[a-zA-Z0-9._-]+(?:\.[a-zA-Z0-9._-]+)*(?::[0-9]+)?\/(?:[a-z0-9._\/-]+):[a-zA-Z0-9._-]+@sha256:[a-f0-9]{64}$/; + const registryPart = String.raw`(?[a-zA-Z0-9._-]+(?:\.[a-zA-Z0-9._-]+)*(?::[0-9]+)?)`; + const repositoryPart = String.raw`(?[a-z0-9._\/-]+)`; + const tagPart = String.raw`(?[a-zA-Z0-9._-]+)`; + const digestPart = String.raw`(?sha256:[a-f0-9]{64})`; + const imageRegex = new RegExp(`^${registryPart}/${repositoryPart}:${tagPart}@${digestPart}$`); + const tags = new Set(); + const imagesToSign = new Set(); for(const image of images) { if (typeof image !== 'string'){ return core.setFailed(`Invalid image format: ${image}. Expected a string.`); } - if (!imageRegex.test(image)) { + const match = image.match(imageRegex); + if (!match || !match.groups) { return core.setFailed(`Invalid image format: ${image}. Expected format: registry/name:tag@digest`); } + + const { registry, repository, tag, digest } = match.groups; + tags.add(tag); + + imagesToSign.add(`${registry}/${repository}@${digest}`); } - // Create manifest for each image - const signImageCommand = `cosign sign --recursive --yes ${images.join(" ")}`; + // 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}`; core.debug(`Signing images with command: "${signImageCommand}"`); await exec.exec(signImageCommand); diff --git a/tests/application/Dockerfile b/tests/application/Dockerfile index 08573ea9..dc6a467e 100644 --- a/tests/application/Dockerfile +++ b/tests/application/Dockerfile @@ -19,20 +19,20 @@ FROM alpine:3 AS test # Create user and group RUN addgroup -S test && adduser -S test -G test -ENV EXPECTED_REPOSITORY_OWNER=hoverkraft-tech -ENV EXPECTED_REPOSITORY=hoverkraft-tech/ci-github-container ARG BUILD_RUN_ID RUN test -n "$BUILD_RUN_ID" || (echo "Error: BUILD_RUN_ID is not set" && exit 1); # Test that the build args are set -ARG BUILD_REPOSITORY_OWNER -RUN test -n "$BUILD_REPOSITORY_OWNER" || (echo "Error: BUILD_REPOSITORY_OWNER is not set" && exit 1); -RUN test "$BUILD_REPOSITORY_OWNER" = "$EXPECTED_REPOSITORY_OWNER" || (echo "Error: BUILD_REPOSITORY_OWNER is not \"$EXPECTED_REPOSITORY_OWNER\"" && exit 1); +ARG BUILD_ARG_TEST +ENV EXPECTED_BUILD_ARG_TEST=test-arg +RUN test -n "$BUILD_ARG_TEST" || (echo "Error: BUILD_ARG_TEST is not set" && exit 1); +RUN test "$BUILD_ARG_TEST" = "$EXPECTED_BUILD_ARG_TEST" || (echo "Error: BUILD_ARG_TEST is not \"$EXPECTED_BUILD_ARG_TEST\"" && exit 1); -ARG BUILD_REPOSITORY -RUN test -n "$BUILD_REPOSITORY" || (echo "Error: BUILD_REPOSITORY is not set" && exit 1); -RUN test "$BUILD_REPOSITORY" = "$EXPECTED_REPOSITORY" || (echo "Error: BUILD_REPOSITORY is not \"$EXPECTED_REPOSITORY\"" && exit 1); +ENV EXPECTED_BUILD_ARG_ANOTHER_TEST=another-test-arg +ARG BUILD_ARG_ANOTHER_TEST +RUN test -n "$BUILD_ARG_ANOTHER_TEST" || (echo "Error: BUILD_ARG_ANOTHER_TEST is not set" && exit 1); +RUN test "$BUILD_ARG_ANOTHER_TEST" = "$EXPECTED_BUILD_ARG_ANOTHER_TEST" || (echo "Error: BUILD_ARG_ANOTHER_TEST is not \"$EXPECTED_BUILD_ARG_ANOTHER_TEST\"" && exit 1); RUN cat >test.sh <