diff --git a/.github/actions/push-container-image/action.yml b/.github/actions/push-container-image/action.yml index ccd21226..2846fde4 100644 --- a/.github/actions/push-container-image/action.yml +++ b/.github/actions/push-container-image/action.yml @@ -328,23 +328,6 @@ runs: name: ${{ steps.globals.outputs.bom-artifact-name }} path: ${{ inputs.PATH }} - - name: Enable buildkit cache - uses: actions/cache@v5.0.5 - if: inputs.enable-buildkit-cache == 'true' - with: - path: /tmp/buildkit-cache/buildkit-state.tar - key: ${{ runner.os }}-buildkit-${{ github.sha }} - restore-keys: | - ${{ runner.os }}-buildkit- - - - name: Load buildkit state from cache - uses: dashevo/gh-action-cache-buildkit-state@v1.0.3 - if: inputs.enable-buildkit-cache == 'true' - with: - builder: buildx_buildkit_${{ steps.buildx.outputs.name }}0 - cache-path: /tmp/buildkit-cache - cache-max-size: 3g - - name: Configure AWS credentials from main account id: aws-login # Only run if dry run is explicitly set to 'false' @@ -382,6 +365,25 @@ runs: id: buildx uses: docker/setup-buildx-action@v4.0.0 + - name: Enable buildkit cache + # Must run after buildx setup: the loader below needs the builder container to exist + # and resolves its name from the buildx step's outputs + uses: actions/cache@v5.0.5 + if: inputs.enable-buildkit-cache == 'true' + with: + path: /tmp/buildkit-cache/buildkit-state.tar + key: ${{ runner.os }}-buildkit-${{ github.sha }} + restore-keys: | + ${{ runner.os }}-buildkit- + + - name: Load buildkit state from cache + uses: dashevo/gh-action-cache-buildkit-state@v1.0.3 + if: inputs.enable-buildkit-cache == 'true' + with: + builder: buildx_buildkit_${{ steps.buildx.outputs.name }}0 + cache-path: /tmp/buildkit-cache + cache-max-size: 3g + - name: Prepare Docker image names and tags id: meta uses: docker/metadata-action@v6.0.0 @@ -419,9 +421,12 @@ runs: env: DOCKER_METADATA: ${{ steps.meta.outputs.json}} - - name: Build, tag, and push container image - # Only run if dry run is explicitly set to 'false' - id: build-images + - name: Build container image + # Only run if dry run is explicitly set to 'false'. + # Builds the multi-platform image WITHOUT pushing it, so that the image scans below can + # gate the push. The 'Push container image' step re-runs this build as a pure cache hit + # (same builder, same inputs), so nothing is rebuilt when the image is eventually pushed. + id: build-image uses: docker/build-push-action@v7.1.0 if: inputs.dry-run == 'false' env: @@ -429,32 +434,30 @@ runs: with: builder: ${{ steps.buildx.outputs.name }} platforms: linux/amd64,linux/arm64 - # Dependabot triggered PRs don't have access to Quay credentials to push images - push: ${{ ! (inputs.registry == 'quay' && github.actor == 'dependabot[bot]') }} + push: false cache-from: type=gha cache-to: type=gha,mode=max context: ${{ inputs.path }} file: ${{ inputs.path }}/${{ inputs.dockerfile }} - provenance: mode=max tags: ${{ steps.meta.outputs.tags }} target: ${{ inputs.target }} - sbom: true secrets: | ${{ inputs.package-pat && inputs.package-pat || '' }} ${{ inputs.font-awesome-key && inputs.font-awesome-key || '' }} build-args: | ${{ inputs.build-args }} - - name: Build container image tarball (dry run) - # Only run if dry run is set to anything other than 'false', indicating that this is a dry run - id: build-images-dry-run + - name: Build container image tarball for scanning + # Only run if this is a dry run or at least one image scan is enabled. + # The default image store in Docker Engine doesn't support loading multi-platform images, + # so a single (native platform) tarball is exported for the local scans. This is a pure + # cache hit against the build above. + id: build-image-tarball uses: docker/build-push-action@v7.1.0 - if: inputs.dry-run != 'false' + if: inputs.dry-run != 'false' || inputs.trivy-scan-image == 'true' || inputs.grype-scan-image == 'true' env: DOCKER_BUILDKIT: 1 with: - # The default image store in Docker Engine doesn't support loading multi-platform images. - # Because of this, the dry run excludes the 'platforms' input. builder: ${{ steps.buildx.outputs.name }} push: false cache-from: type=gha @@ -468,40 +471,17 @@ runs: ${{ inputs.font-awesome-key && inputs.font-awesome-key || '' }} build-args: | ${{ inputs.build-args }} - # Filename must match the `scan-name` the Trivy/Grype steps derive below, since they download the - # artifact and `docker load` it by that exact name. Include `-` to keep them aligned. outputs: type=docker,dest=/tmp/${{ inputs.app-name-prefix }}${{ inputs.app-name }}${{ inputs.image-suffix }}-${{ inputs.dockerfile }}.tar - - name: Install CoSign - # Only run if dry run is explicitly set to 'false' and the image is being pushed to 'quay'. - if: inputs.dry-run == 'false' && inputs.registry == 'quay' - uses: sigstore/cosign-installer@v3 - - name: Sign charts on Quay.io - # Only run if dry run is explicitly set to 'false' and the image is being pushed to 'quay'. - if: inputs.dry-run == 'false' && inputs.registry == 'quay' - shell: bash - env: - DIGEST: ${{ steps.build-images.outputs.digest }} - TAGS: ${{ steps.meta.outputs.tags }} - run: | - images="" - for tag in ${TAGS}; do - images+="${tag}@${DIGEST} " - done - cosign sign --yes ${images} - - name: Upload container image tarball artefact - # Only run if dry run is set to anything other than 'false', indicating that this is a dry run + # The artefact name must match '.tar' as the scan actions download it by that name id: upload-image-artefact uses: actions/upload-artifact@v7 - if: inputs.dry-run != 'false' + if: inputs.dry-run != 'false' || inputs.trivy-scan-image == 'true' || inputs.grype-scan-image == 'true' with: - # Name and path must match the dry-run build dest above and the `scan-name` used by the Trivy/Grype - # steps. These previously used `APP_NAME_PREFIX`-style refs (underscores), which do not resolve to the - # hyphenated inputs and silently evaluated to empty, so nothing was uploaded and the scans failed to - # download the tarball. name: ${{ inputs.app-name-prefix }}${{ inputs.app-name }}${{ inputs.image-suffix }}-${{ inputs.dockerfile }}.tar path: /tmp/${{ inputs.app-name-prefix }}${{ inputs.app-name }}${{ inputs.image-suffix }}-${{ inputs.dockerfile }}.tar + if-no-files-found: error - name: Capture built container image tags id: capture-tags @@ -525,13 +505,13 @@ runs: scan-name: ${{ inputs.app-name-prefix }}${{ inputs.app-name }}${{ inputs.image-suffix }}-${{ inputs.dockerfile }} - name: Trivy image scan - # Only run if Trivy image scan is explicitly set to 'true' AND (is a dry run OR (is not a dry run AND user is not dependabot)) - if: inputs.trivy-scan-image == 'true' && (inputs.dry-run != 'false' || (inputs.dry-run == 'false' && github.actor != 'dependabot[bot]')) + # Only run if Trivy image scan is explicitly set to 'true' + if: inputs.trivy-scan-image == 'true' uses: telicent-oss/shared-workflows/.github/actions/trivy-image-scan@main with: github-token: ${{ inputs.github-token }} - # Indicates that the image should be imported from a tarball artefact and scanned locally - image-from-artefact: ${{ inputs.dry-run != 'false' }} + # The image is always scanned locally from the tarball artefact, before it is pushed + image-from-artefact: 'true' image-ref: ${{ steps.capture-tags.outputs.image }} registry: ${{ inputs.registry }} quay-username: ${{ inputs.quay-username }} @@ -543,13 +523,13 @@ runs: uses-java: ${{ inputs.uses-maven }} - name: Grype image scan - # Only run if Grype image scan is explicitly set to 'true' AND (is a dry run OR (is not a dry run AND user is not dependabot)) - if: inputs.grype-scan-image == 'true' && (inputs.dry-run != 'false' || (inputs.dry-run == 'false' && github.actor != 'dependabot[bot]')) + # Only run if Grype image scan is explicitly set to 'true' + if: inputs.grype-scan-image == 'true' uses: telicent-oss/shared-workflows/.github/actions/grype-image-scan@main with: github-token: ${{ inputs.github-token }} - # Indicates that the image should be imported from a tarball artefact and scanned locally - image-from-artefact: ${{ inputs.dry-run != 'false' }} + # The image is always scanned locally from the tarball artefact, before it is pushed + image-from-artefact: 'true' image-ref: ${{ steps.capture-tags.outputs.image }} registry: ${{ inputs.registry }} quay-username: ${{ inputs.quay-username }} @@ -559,6 +539,51 @@ runs: fail-on-unfixed: ${{ inputs.fail-on-unfixed }} remote-vex: ${{ inputs.remote-vex }} + - name: Push container image + # Only run if dry run is explicitly set to 'false', and only once the image scans above + # have passed. Re-runs the 'Build container image' build as a pure cache hit, so this + # step only generates the provenance/SBOM attestations and pushes the already built image. + id: build-images + uses: docker/build-push-action@v7.1.0 + if: inputs.dry-run == 'false' + env: + DOCKER_BUILDKIT: 1 + with: + builder: ${{ steps.buildx.outputs.name }} + platforms: linux/amd64,linux/arm64 + # Dependabot triggered PRs don't have access to Quay credentials to push images + push: ${{ ! (inputs.registry == 'quay' && github.actor == 'dependabot[bot]') }} + cache-from: type=gha + cache-to: type=gha,mode=max + context: ${{ inputs.path }} + file: ${{ inputs.path }}/${{ inputs.dockerfile }} + provenance: mode=max + tags: ${{ steps.meta.outputs.tags }} + target: ${{ inputs.target }} + sbom: true + secrets: | + ${{ inputs.package-pat && inputs.package-pat || '' }} + ${{ inputs.font-awesome-key && inputs.font-awesome-key || '' }} + build-args: | + ${{ inputs.build-args }} + + - name: Install CoSign + # Only run if dry run is explicitly set to 'false' and the image is being pushed to 'quay'. + if: inputs.dry-run == 'false' && inputs.registry == 'quay' + uses: sigstore/cosign-installer@v3 + - name: Sign charts on Quay.io + # Only run if dry run is explicitly set to 'false' and the image is being pushed to 'quay'. + if: inputs.dry-run == 'false' && inputs.registry == 'quay' + shell: bash + env: + DIGEST: ${{ steps.build-images.outputs.digest }} + TAGS: ${{ steps.meta.outputs.tags }} + run: | + images="" + for tag in ${TAGS}; do + images+="${tag}@${DIGEST} " + done + cosign sign --yes ${images} - name: SonarQube code health report # Only run when actually pushing to Quay, and only when the SonarQube token and report destination are # supplied. Dry runs are skipped so that they remain free of side effects.