diff --git a/README.md b/README.md index 1ebff73..033414d 100644 --- a/README.md +++ b/README.md @@ -130,6 +130,71 @@ Enabled by default (`deployment-labels: 'true'`). The same three values are stam > **Note:** labels are baked in at **build time**, so they are only applied on builds. Release (`v*`) and custom-tag runs that **retag** an existing image instead of rebuilding (see [Image tags](#image-tags--flux-image-automation)) do not get fresh labels — the retagged image keeps the labels from the branch build it was promoted from. This feature is independent of the annotations above; enable either, both, or neither. +### Multi-Arch Images + +Our recovery/failover regions have no ARM capacity, so images deployed there must ship both `linux/amd64` and `linux/arm64`. The action never cross-compiles or emulates: each architecture is built natively on its own runner, and the results are combined into one manifest list afterwards. + +This needs a job matrix, which a composite action cannot create itself — so the fan-out lives in your workflow, and the action provides the two halves via `multiarch-mode`: + +- `multiarch-mode: build` — builds the runner's **native** platform (`docker-build-platforms` is ignored), pushes it **by digest only** (no tags), and uploads the digest as an artifact. GitOps, retagging and Upwind are skipped. +- `multiarch-mode: merge` — downloads all digest artifacts, combines them into one multi-arch image, applies the real tags, and then runs the GitOps and Upwind steps exactly as a normal run would. + +```yaml +name: CD + +on: [ push ] + +jobs: + build: + name: Build (${{ matrix.arch }}) + runs-on: ${{ matrix.runs-on }} + strategy: + fail-fast: false + matrix: + include: + - arch: amd64 + runs-on: ubuntu-24.04 + - arch: arm64 + runs-on: ubuntu-24.04-arm + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: GitOps (build and push by digest) + uses: Staffbase/gitops-github-action@v7.1 + with: + multiarch-mode: build + docker-username: ${{ vars.HARBOR_USERNAME }} + docker-password: ${{ secrets.HARBOR_PASSWORD }} + docker-image: private/my-service + + deploy: + name: Merge and Deploy + runs-on: ubuntu-24.04 + needs: build + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: GitOps (merge manifests and deploy) + uses: Staffbase/gitops-github-action@v7.1 + with: + multiarch-mode: merge + docker-username: ${{ vars.HARBOR_USERNAME }} + docker-password: ${{ secrets.HARBOR_PASSWORD }} + docker-image: private/my-service + gitops-token: ${{ secrets.GITOPS_TOKEN }} + gitops-dev: |- + clusters/customization/dev/mothership/my-service/my-service-helm.yaml spec.template.spec.containers.redbook.image +``` + +Pass the same `docker-*` inputs to both jobs — the merge job recomputes the tags, so `docker-custom-tag`, `docker-tag-timestamp` and friends must match. Notes: + +- On branches that do not push (e.g. feature branches), the build jobs just build and validate; the merge job finds nothing to merge and skips straight to the GitOps steps. +- Release (`v*`) and custom-tag runs that retag an existing image do not rebuild, so they promote the existing multi-arch image untouched. Only the merge job performs the retag. +- `docker-build-outputs` cannot be combined with `multiarch-mode: build`; the build already pushes by digest. +- Building more than one image in a single workflow? Give each one a distinct `multiarch-artifact-name`, or the digests get mixed up. + ## Inputs | Name | Description | Default | @@ -147,7 +212,10 @@ Enabled by default (`deployment-labels: 'true'`). The same three values are stam | `docker-build-secrets` | List of secrets to expose to the build (e.g., key=string, GIT_AUTH_TOKEN=mytoken) | | | `docker-build-secret-files` | List of secret files to expose to the build (e.g., key=filename, MY_SECRET=./secret.txt) | | | `docker-build-target` | Sets the target stage to build like: "runtime" | | -| `docker-build-platforms` | Sets the target platforms for build | linux/amd64 | +| `docker-build-platforms` | Sets the target platforms for build. Ignored when `multiarch-mode: build` (the runner's own architecture wins) | linux/amd64 | +| `multiarch-mode` | `build` or `merge` to build a multi-arch image from a job matrix, empty for a normal single-arch build. See [Multi-Arch Images](#multi-arch-images) | | +| `multiarch-artifact-name` | Base name of the artifact carrying the per-architecture digests between the build and merge jobs (the architecture is appended) | `docker-digests` | +| `multiarch-digests-path` | Directory holding the per-architecture digest files | `/tmp/gitops-action-digests` | | `docker-build-provenance` | Generate [provenance](https://docs.docker.com/build/attestations/slsa-provenance/) attestation for the build | `false` | | `docker-disable-retagging` | Disables retagging of existing images and run a new build instead | `false` | | `deployment-annotations` | Stamp deployment-tracking annotations (`deploy.staffbase.com/*`) onto updated GitOps manifests. See [Deployment tracking annotations](#deployment-tracking-annotations) | `true` | diff --git a/action.yml b/action.yml index de93c05..52c30a7 100644 --- a/action.yml +++ b/action.yml @@ -58,6 +58,18 @@ inputs: docker-build-outputs: description: "Custom output destinations (e.g., type=registry,push=true,compression=zstd,force-compression=true). When set, this replaces the default push behavior - include push=true if pushing is desired." required: false + multiarch-mode: + description: "Multi-arch build mode. Empty (default) keeps the single-arch behaviour. 'build' builds only the runner's native platform and pushes it by digest — run it once per architecture in a job matrix. 'merge' combines those digests into one multi-arch image, applies the tags and runs the GitOps/Upwind steps. See README 'Multi-Arch Images'." + required: false + default: '' + multiarch-artifact-name: + description: "Base name of the artifact carrying the per-architecture digests between the build and merge jobs. The architecture is appended (e.g. docker-digests-arm64). Only needs changing when a workflow builds more than one image." + required: false + default: 'docker-digests' + multiarch-digests-path: + description: 'Directory holding the per-architecture digest files.' + required: false + default: '/tmp/gitops-action-digests' docker-disable-retagging: description: 'Disable retagging of existing images' required: false @@ -122,7 +134,7 @@ outputs: value: ${{ steps.preparation.outputs.tag }} docker-digest: description: 'Docker digest' - value: ${{ steps.docker_build.outputs.digest || steps.docker_retag.outputs.digest }} + value: ${{ steps.docker_merge.outputs.digest || steps.docker_build.outputs.digest || steps.docker_retag.outputs.digest }} runs: using: "composite" @@ -146,12 +158,27 @@ runs: INPUT_DEPLOYMENT_DOMAIN: ${{ inputs.deployment-domain }} run: ${{ github.action_path }}/scripts/generate-label-prefix.sh - - name: Verify Architecture Match + - name: Resolve Build Configuration + id: build_config shell: bash - if: steps.preparation.outputs.build == 'true' + if: steps.preparation.outputs.build == 'true' && inputs.multiarch-mode != 'merge' env: RUNNER_ARCH: ${{ runner.arch }} + INPUT_MULTIARCH_MODE: ${{ inputs.multiarch-mode }} INPUT_DOCKER_BUILD_PLATFORMS: ${{ inputs.docker-build-platforms }} + INPUT_DOCKER_BUILD_OUTPUTS: ${{ inputs.docker-build-outputs }} + INPUT_DOCKER_REGISTRY: ${{ inputs.docker-registry }} + INPUT_DOCKER_IMAGE: ${{ inputs.docker-image }} + INPUT_TAG_LIST: ${{ steps.preparation.outputs.tag_list }} + INPUT_PUSH: ${{ steps.preparation.outputs.push }} + run: ${{ github.action_path }}/scripts/resolve-build-config.sh + + - name: Verify Architecture Match + shell: bash + if: steps.preparation.outputs.build == 'true' && inputs.multiarch-mode != 'merge' + env: + RUNNER_ARCH: ${{ runner.arch }} + INPUT_DOCKER_BUILD_PLATFORMS: ${{ steps.build_config.outputs.platforms }} run: ${{ github.action_path }}/scripts/verify-architecture.sh - name: Set up Docker Buildx @@ -168,30 +195,69 @@ runs: - name: Build id: docker_build - if: steps.preparation.outputs.build == 'true' && inputs.docker-username != '' && inputs.docker-password != '' + if: steps.preparation.outputs.build == 'true' && inputs.multiarch-mode != 'merge' && inputs.docker-username != '' && inputs.docker-password != '' uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 with: context: ${{ inputs.working-directory }} - push: ${{ inputs.docker-build-outputs == '' && steps.preparation.outputs.push || 'false' }} + push: ${{ steps.build_config.outputs.build_outputs == '' && steps.preparation.outputs.push || 'false' }} file: ${{ inputs.working-directory }}/${{ inputs.docker-file }} target: ${{ inputs.docker-build-target }} build-args: ${{ inputs.docker-build-args }} - tags: ${{ steps.preparation.outputs.tag_list }} + tags: ${{ steps.build_config.outputs.tags }} labels: | ${{ inputs.deployment-labels == 'true' && format('{0}.repositoryFullName={1}', steps.deployment_labels.outputs.label_prefix, github.repository) || '' }} ${{ inputs.deployment-labels == 'true' && format('{0}.commitSha={1}', steps.deployment_labels.outputs.label_prefix, github.sha) || '' }} ${{ inputs.deployment-labels == 'true' && format('{0}.version={1}', steps.deployment_labels.outputs.label_prefix, steps.preparation.outputs.gitops_tag) || '' }} secrets: ${{ inputs.docker-build-secrets }} secret-files: ${{ inputs.docker-build-secret-files }} - platforms: ${{ inputs.docker-build-platforms }} - cache-from: type=gha - cache-to: type=gha,mode=max + platforms: ${{ steps.build_config.outputs.platforms }} + cache-from: type=gha${{ steps.build_config.outputs.cache_suffix }} + cache-to: type=gha,mode=max${{ steps.build_config.outputs.cache_suffix }} provenance: ${{ inputs.docker-build-provenance }} - outputs: ${{ inputs.docker-build-outputs }} + outputs: ${{ steps.build_config.outputs.build_outputs }} + + - name: Export Digest + if: inputs.multiarch-mode == 'build' && steps.docker_build.outputs.digest != '' + shell: bash + env: + DIGEST: ${{ steps.docker_build.outputs.digest }} + DIGESTS_PATH: ${{ inputs.multiarch-digests-path }} + run: | + set -euo pipefail + mkdir -p "$DIGESTS_PATH" + touch "${DIGESTS_PATH}/${DIGEST#sha256:}" + + - name: Upload Digest + if: inputs.multiarch-mode == 'build' && steps.docker_build.outputs.digest != '' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: ${{ inputs.multiarch-artifact-name }}-${{ steps.build_config.outputs.arch }} + path: ${{ inputs.multiarch-digests-path }}/* + if-no-files-found: error + retention-days: 1 + + - name: Download Digests + if: inputs.multiarch-mode == 'merge' && steps.preparation.outputs.build == 'true' && steps.preparation.outputs.push == 'true' + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + path: ${{ inputs.multiarch-digests-path }} + pattern: ${{ inputs.multiarch-artifact-name }}-* + merge-multiple: true + + - name: Merge Multi-Arch Manifest + id: docker_merge + if: inputs.multiarch-mode == 'merge' && steps.preparation.outputs.build == 'true' && steps.preparation.outputs.push == 'true' + shell: bash + env: + INPUT_DOCKER_REGISTRY: ${{ inputs.docker-registry }} + INPUT_DOCKER_IMAGE: ${{ inputs.docker-image }} + INPUT_TAG_LIST: ${{ steps.preparation.outputs.tag_list }} + INPUT_DIGESTS_PATH: ${{ inputs.multiarch-digests-path }} + run: ${{ github.action_path }}/scripts/merge-manifests.sh - name: Retag Existing Image id: docker_retag - if: steps.preparation.outputs.build == 'false' + if: steps.preparation.outputs.build == 'false' && inputs.multiarch-mode != 'build' shell: bash env: INPUT_DOCKER_USERNAME: ${{ inputs.docker-username }} @@ -203,7 +269,7 @@ runs: run: ${{ github.action_path }}/scripts/retag-image.sh - name: Checkout GitOps Repository - if: inputs.gitops-token != '' + if: inputs.gitops-token != '' && inputs.multiarch-mode != 'build' uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: ${{ inputs.gitops-organization }}/${{ inputs.gitops-repository }} @@ -212,7 +278,7 @@ runs: - name: Update Docker Image in Repository id: update_image - if: inputs.gitops-token != '' + if: inputs.gitops-token != '' && inputs.multiarch-mode != 'build' working-directory: .github/${{ inputs.gitops-repository }} shell: bash env: @@ -235,7 +301,7 @@ runs: - name: Emit Image Build Event to Upwind.io env: UPWIND_CLIENT_SECRET: ${{ inputs.upwind-client-secret }} - if: "${{ inputs.upwind-client-id != '' && env.UPWIND_CLIENT_SECRET != '' && inputs.upwind-organization-id != '' }}" + if: "${{ inputs.upwind-client-id != '' && env.UPWIND_CLIENT_SECRET != '' && inputs.upwind-organization-id != '' && inputs.multiarch-mode != 'build' }}" uses: upwindsecurity/create-image-build-event-action@3099fc1e1e002c6c2d7b7c635699944a708d260d # v3 continue-on-error: true with: diff --git a/scripts/lib/common.sh b/scripts/lib/common.sh index ebfa422..2effb20 100755 --- a/scripts/lib/common.sh +++ b/scripts/lib/common.sh @@ -24,7 +24,18 @@ set_output() { local name="$1" local value="$2" if [[ -n "${GITHUB_OUTPUT:-}" ]]; then - echo "${name}=${value}" >> "$GITHUB_OUTPUT" + if [[ "$value" == *$'\n'* ]]; then + # Multi-line values (e.g. a newline-separated docker-build-outputs list) + # must use the heredoc form; name=value is rejected by the runner. + local delimiter="ghaEOF_${RANDOM}${RANDOM}" + { + echo "${name}<<${delimiter}" + echo "$value" + echo "$delimiter" + } >> "$GITHUB_OUTPUT" + else + echo "${name}=${value}" >> "$GITHUB_OUTPUT" + fi else echo "OUTPUT ${name}=${value}" fi diff --git a/scripts/merge-manifests.sh b/scripts/merge-manifests.sh new file mode 100755 index 0000000..0a3e763 --- /dev/null +++ b/scripts/merge-manifests.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# Combines the per-architecture images pushed by the matrix build jobs into a +# single multi-arch manifest list and applies the real tags to it. +# +# Each build job pushed its image by digest only and uploaded an empty file named +# after that digest. This script turns those digests into one image index. +# +# Required env vars: INPUT_DOCKER_REGISTRY, INPUT_DOCKER_IMAGE, INPUT_TAG_LIST, +# INPUT_DIGESTS_PATH +# +# Outputs (via GITHUB_OUTPUT): digest + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=lib/common.sh +source "${SCRIPT_DIR}/lib/common.sh" + +require_env INPUT_DOCKER_REGISTRY +require_env INPUT_DOCKER_IMAGE +require_env INPUT_TAG_LIST +require_env INPUT_DIGESTS_PATH +require_tool docker +require_tool jq + +IMAGE="${INPUT_DOCKER_REGISTRY}/${INPUT_DOCKER_IMAGE}" + +sources=() +while IFS= read -r digest; do + sources+=("${IMAGE}@sha256:${digest}") +done < <(find "$INPUT_DIGESTS_PATH" -type f -exec basename {} \; | grep -E '^[0-9a-f]{64}$' | sort) + +if [[ ${#sources[@]} -eq 0 ]]; then + log_error "No digests found in '${INPUT_DIGESTS_PATH}'. Did the multiarch build jobs run and upload their digests?" + exit 1 +fi + +tag_args=() +IFS=',' read -ra tags <<< "$INPUT_TAG_LIST" +for tag in "${tags[@]}"; do + [[ -n "$tag" ]] && tag_args+=("--tag" "$tag") +done + +echo "Merging ${#sources[@]} image(s) into ${#tag_args[@]} tag(s)" +docker buildx imagetools create "${tag_args[@]}" "${sources[@]}" + +DIGEST="$(docker buildx imagetools inspect "${tags[0]}" --format '{{json .Manifest}}' | jq -r '.digest')" +echo "Multi-arch image digest: ${DIGEST}" + +set_output "digest" "$DIGEST" diff --git a/scripts/resolve-build-config.sh b/scripts/resolve-build-config.sh new file mode 100755 index 0000000..5827110 --- /dev/null +++ b/scripts/resolve-build-config.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +# Resolves platforms, tags and buildx outputs for the Docker build step. +# +# With multiarch-mode unset this is a pass-through of the caller's inputs, so the +# single-arch behaviour is unchanged. With multiarch-mode="build" the runner's own +# architecture decides the platform (never emulation) and the image is pushed by +# digest only — the tags are applied later by merge-manifests.sh on the merge job. +# +# Required env vars: RUNNER_ARCH +# Optional env vars: INPUT_MULTIARCH_MODE, INPUT_DOCKER_BUILD_PLATFORMS, +# INPUT_DOCKER_BUILD_OUTPUTS, INPUT_TAG_LIST, INPUT_PUSH, +# INPUT_DOCKER_REGISTRY, INPUT_DOCKER_IMAGE +# +# Outputs (via GITHUB_OUTPUT): arch, platforms, tags, build_outputs + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=lib/common.sh +source "${SCRIPT_DIR}/lib/common.sh" + +MODE="${INPUT_MULTIARCH_MODE:-}" +PLATFORMS="${INPUT_DOCKER_BUILD_PLATFORMS:-}" +TAGS="${INPUT_TAG_LIST:-}" +BUILD_OUTPUTS="${INPUT_DOCKER_BUILD_OUTPUTS:-}" + +if [[ "$MODE" != "" && "$MODE" != "build" && "$MODE" != "merge" ]]; then + log_error "Invalid multiarch-mode '${MODE}'. Expected '', 'build' or 'merge'." + exit 1 +fi + +ARCH="" +case "${RUNNER_ARCH:-}" in + X64) ARCH="amd64" ;; + ARM64) ARCH="arm64" ;; +esac + +if [[ "$MODE" == "build" ]]; then + if [[ -z "$ARCH" ]]; then + log_error "Unsupported runner architecture '${RUNNER_ARCH:-}' for multiarch-mode 'build'. Expected X64 or ARM64." + exit 1 + fi + if [[ -n "$BUILD_OUTPUTS" ]]; then + log_error "docker-build-outputs cannot be combined with multiarch-mode 'build' (the build pushes by digest)." + exit 1 + fi + require_env INPUT_DOCKER_REGISTRY + require_env INPUT_DOCKER_IMAGE + + PLATFORMS="linux/${ARCH}" + + # Without a push (e.g. feature branches) there is nothing to merge later, so + # keep the plain build and let the tags apply as usual. + if [[ "${INPUT_PUSH:-}" == "true" ]]; then + TAGS="" + BUILD_OUTPUTS="type=image,name=${INPUT_DOCKER_REGISTRY}/${INPUT_DOCKER_IMAGE},push-by-digest=true,name-canonical=true,push=true" + fi +fi + +echo "Build platforms: ${PLATFORMS}" + +# CACHE_SUFFIX is appended verbatim to the type=gha cache refs. The parallel +# matrix jobs must not share one cache scope, or they keep overwriting each +# other's manifest. Empty outside build mode to keep the default scope. +CACHE_SUFFIX="" +if [[ "$MODE" == "build" ]]; then + CACHE_SUFFIX=",scope=${ARCH}" +fi + +set_output "arch" "$ARCH" +set_output "cache_suffix" "$CACHE_SUFFIX" +set_output "platforms" "$PLATFORMS" +set_output "tags" "$TAGS" +set_output "build_outputs" "$BUILD_OUTPUTS" diff --git a/tests/lib-common.bats b/tests/lib-common.bats index 47e0626..17a14c4 100644 --- a/tests/lib-common.bats +++ b/tests/lib-common.bats @@ -52,6 +52,14 @@ teardown() { assert_output_value "special" "hello=world,foo:bar" } +@test "set_output uses the heredoc form for multi-line values" { + set_output "multiline" $'type=registry,push=true\ntype=local,dest=out' + run cat "$GITHUB_OUTPUT" + assert_line --index 0 --regexp '^multiline< "${TEST_TEMP_DIR}/mocks/docker" << MOCK_EOF +#!/usr/bin/env bash +echo "docker \$*" >> "${TEST_TEMP_DIR}/docker_calls.log" +if [[ "\$*" == *"inspect"* ]]; then + echo '{"digest":"sha256:deadbeef"}' +fi +MOCK_EOF + chmod +x "${TEST_TEMP_DIR}/mocks/docker" +} + +teardown() { + teardown_common +} + +@test "merges all digests under all tags and reports the index digest" { + touch "${INPUT_DIGESTS_PATH}/${AMD_DIGEST}" "${INPUT_DIGESTS_PATH}/${ARM_DIGEST}" + run "$SCRIPT" + assert_success + assert_output_value "digest" "sha256:deadbeef" + + run cat "${TEST_TEMP_DIR}/docker_calls.log" + assert_output --partial "--tag registry.example.com/private/my-service:dev-1 --tag registry.example.com/private/my-service:dev" + assert_output --partial "registry.example.com/private/my-service@sha256:${AMD_DIGEST}" + assert_output --partial "registry.example.com/private/my-service@sha256:${ARM_DIGEST}" +} + +@test "ignores files that are not digests" { + touch "${INPUT_DIGESTS_PATH}/${AMD_DIGEST}" "${INPUT_DIGESTS_PATH}/README.md" + run "$SCRIPT" + assert_success + + run cat "${TEST_TEMP_DIR}/docker_calls.log" + refute_output --partial "README.md" +} + +@test "fails when no digests were uploaded" { + run "$SCRIPT" + assert_failure + assert_output --partial "No digests found" +} + +@test "fails when INPUT_TAG_LIST is missing" { + touch "${INPUT_DIGESTS_PATH}/${AMD_DIGEST}" + unset INPUT_TAG_LIST + run "$SCRIPT" + assert_failure + assert_output --partial "INPUT_TAG_LIST" +} diff --git a/tests/resolve-build-config.bats b/tests/resolve-build-config.bats new file mode 100644 index 0000000..339d0d6 --- /dev/null +++ b/tests/resolve-build-config.bats @@ -0,0 +1,108 @@ +#!/usr/bin/env bats + +load 'test_helper/setup' + +SCRIPT="${BATS_TEST_DIRNAME}/../scripts/resolve-build-config.sh" + +setup() { + setup_common + export RUNNER_ARCH="X64" + export INPUT_DOCKER_REGISTRY="registry.example.com" + export INPUT_DOCKER_IMAGE="private/my-service" + export INPUT_DOCKER_BUILD_PLATFORMS="linux/amd64" + export INPUT_TAG_LIST="registry.example.com/private/my-service:dev-abcdef12" + export INPUT_PUSH="true" +} + +teardown() { + teardown_common +} + +# --- default (single-arch) mode is a pass-through --- + +@test "default mode passes inputs through unchanged" { + run "$SCRIPT" + assert_success + assert_output_value "platforms" "linux/amd64" + assert_output_value "tags" "registry.example.com/private/my-service:dev-abcdef12" + assert_output_value "build_outputs" "" + assert_output_value "cache_suffix" "" +} + +@test "default mode keeps custom docker-build-outputs" { + export INPUT_DOCKER_BUILD_OUTPUTS="type=registry,push=true" + run "$SCRIPT" + assert_success + assert_output_value "build_outputs" "type=registry,push=true" +} + +@test "default mode keeps a multi-platform request (verify-architecture rejects it later)" { + export INPUT_DOCKER_BUILD_PLATFORMS="linux/amd64,linux/arm64" + run "$SCRIPT" + assert_success + assert_output_value "platforms" "linux/amd64,linux/arm64" +} + +# --- build mode --- + +@test "build mode on X64 builds amd64 and pushes by digest" { + export INPUT_MULTIARCH_MODE="build" + run "$SCRIPT" + assert_success + assert_output_value "arch" "amd64" + assert_output_value "platforms" "linux/amd64" + assert_output_value "tags" "" + assert_output_value "build_outputs" "type=image,name=registry.example.com/private/my-service,push-by-digest=true,name-canonical=true,push=true" + assert_output_value "cache_suffix" ",scope=amd64" +} + +@test "build mode on ARM64 builds arm64 regardless of docker-build-platforms" { + export INPUT_MULTIARCH_MODE="build" + export RUNNER_ARCH="ARM64" + run "$SCRIPT" + assert_success + assert_output_value "arch" "arm64" + assert_output_value "platforms" "linux/arm64" +} + +@test "build mode without push keeps tags and does not push by digest" { + export INPUT_MULTIARCH_MODE="build" + export INPUT_PUSH="false" + run "$SCRIPT" + assert_success + assert_output_value "platforms" "linux/amd64" + assert_output_value "tags" "registry.example.com/private/my-service:dev-abcdef12" + assert_output_value "build_outputs" "" +} + +@test "build mode rejects custom docker-build-outputs" { + export INPUT_MULTIARCH_MODE="build" + export INPUT_DOCKER_BUILD_OUTPUTS="type=registry,push=true" + run "$SCRIPT" + assert_failure + assert_output --partial "docker-build-outputs cannot be combined" +} + +@test "build mode fails on an unsupported runner architecture" { + export INPUT_MULTIARCH_MODE="build" + export RUNNER_ARCH="ARM" + run "$SCRIPT" + assert_failure + assert_output --partial "Unsupported runner architecture" +} + +# --- merge mode / validation --- + +@test "merge mode passes inputs through" { + export INPUT_MULTIARCH_MODE="merge" + run "$SCRIPT" + assert_success + assert_output_value "platforms" "linux/amd64" +} + +@test "fails on an invalid multiarch mode" { + export INPUT_MULTIARCH_MODE="yes" + run "$SCRIPT" + assert_failure + assert_output --partial "Invalid multiarch-mode" +}