Skip to content
Merged
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
70 changes: 69 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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` |
Expand Down
94 changes: 80 additions & 14 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand All @@ -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
Expand All @@ -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 }}
Expand All @@ -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 }}
Expand All @@ -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:
Expand All @@ -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:
Expand Down
13 changes: 12 additions & 1 deletion scripts/lib/common.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
48 changes: 48 additions & 0 deletions scripts/merge-manifests.sh
Original file line number Diff line number Diff line change
@@ -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"
72 changes: 72 additions & 0 deletions scripts/resolve-build-config.sh
Original file line number Diff line number Diff line change
@@ -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"
Loading
Loading