name: Docker Build (Native Multi-Arch) + Deploy Fleet (Compose) on: workflow_call: inputs: control_runner: description: Runner label used for metadata + manifest jobs (must have docker/buildx). type: string default: devsg-atlantic docker_registry: description: Registry host (docker.io, ghcr.io, registry.example.com) type: string default: docker.io docker_namespace: description: Docker namespace / org / username type: string required: true image_repo: description: Docker repository name under namespace type: string required: true context: type: string default: . dockerfile: type: string default: Dockerfile tag: description: "Optional tag to publish (default: sha-)" type: string default: "" build_matrix_json: description: JSON array of { runner, platform } entries type: string required: true deploy_runners_json: description: JSON array of runner labels type: string required: true compose_workdir: type: string default: . compose_args: type: string default: up -d --pull always --remove-orphans secrets: DOCKER_HUB_USERNAME: { required: true } DOCKER_HUB_ACCESS_TOKEN: { required: true } outputs: image_repo: value: ${{ jobs.meta.outputs.image_repo }} image_tag: value: ${{ jobs.meta.outputs.image_tag }} image_ref: value: ${{ jobs.manifest.outputs.image_ref }} concurrency: group: ${{ github.repository }}-${{ github.ref_name }} cancel-in-progress: true jobs: meta: name: Compute Image Metadata runs-on: ${{ inputs.control_runner }} outputs: image_repo: ${{ steps.meta.outputs.image_repo }} image_tag: ${{ steps.meta.outputs.image_tag }} image: ${{ steps.meta.outputs.image }} branch_tag: ${{ steps.meta.outputs.branch_tag }} steps: - name: Compute tags id: meta shell: bash run: | set -euo pipefail IMAGE="${{ inputs.docker_registry }}/${{ inputs.docker_namespace }}/${{ inputs.image_repo }}" SHORT_SHA="$(echo "${{ github.sha }}" | cut -c1-12)" TAG="${{ inputs.tag }}" if [[ -z "${TAG}" ]]; then TAG="sha-${SHORT_SHA}" fi # Branch names with '/' are not valid docker tags. BRANCH_TAG="${{ github.ref_name }}" BRANCH_TAG="${BRANCH_TAG//\//-}" echo "image=${IMAGE}" >> "${GITHUB_OUTPUT}" echo "image_repo=${IMAGE}" >> "${GITHUB_OUTPUT}" echo "image_tag=${TAG}" >> "${GITHUB_OUTPUT}" echo "branch_tag=${BRANCH_TAG}" >> "${GITHUB_OUTPUT}" build: name: Build + Push (${{ matrix.platform }}) needs: meta strategy: fail-fast: false matrix: include: ${{ fromJSON(inputs.build_matrix_json) }} runs-on: ${{ matrix.runner }} steps: - uses: actions/checkout@v4 with: fetch-depth: 1 - uses: docker/setup-buildx-action@v3 - name: Login Registry (push) uses: docker/login-action@v3 with: registry: ${{ inputs.docker_registry }} username: ${{ secrets.DOCKER_HUB_USERNAME }} password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} - name: Compute arch tag + cache key id: tags shell: bash run: | set -euo pipefail arch="${{ matrix.platform }}" arch="${arch##*/}" echo "arch=${arch}" >> "${GITHUB_OUTPUT}" echo "arch_tag=${{ needs.meta.outputs.image_tag }}-${arch}" >> "${GITHUB_OUTPUT}" echo "cache_tag=buildcache-${arch}" >> "${GITHUB_OUTPUT}" - name: Build + Push (single platform) uses: docker/build-push-action@v6 with: context: ${{ inputs.context }} file: ${{ inputs.dockerfile }} platforms: ${{ matrix.platform }} push: true tags: | ${{ needs.meta.outputs.image }}:${{ steps.tags.outputs.arch_tag }} cache-from: type=registry,ref=${{ needs.meta.outputs.image }}:${{ steps.tags.outputs.cache_tag }} cache-to: type=registry,ref=${{ needs.meta.outputs.image }}:${{ steps.tags.outputs.cache_tag }},mode=max manifest: name: Create Multi-Arch Manifest needs: [meta, build] runs-on: ${{ inputs.control_runner }} outputs: image_ref: ${{ steps.out.outputs.image_ref }} steps: - uses: docker/setup-buildx-action@v3 - name: Login Registry (manifest) uses: docker/login-action@v3 with: registry: ${{ inputs.docker_registry }} username: ${{ secrets.DOCKER_HUB_USERNAME }} password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} - name: Create manifest tags (sha/branch/latest) shell: bash env: BUILD_MATRIX_JSON: ${{ inputs.build_matrix_json }} run: | set -euo pipefail IMAGE="${{ needs.meta.outputs.image }}" SHA_TAG="${{ needs.meta.outputs.image_tag }}" BRANCH_TAG="${{ needs.meta.outputs.branch_tag }}" # Derive source tags from build_matrix_json using Node (available in Actions runners). # Each source is "${IMAGE}:${SHA_TAG}-" where from platform suffix. mapfile -t sources < <(IMAGE="${IMAGE}" SHA_TAG="${SHA_TAG}" node -e ' const m = JSON.parse(process.env.BUILD_MATRIX_JSON); for (const it of m) { const arch = String(it.platform || "").split("/").pop(); if (!arch) process.exit(2); console.log(`${process.env.IMAGE}:${process.env.SHA_TAG}-${arch}`); } ') if [[ "${#sources[@]}" -eq 0 ]]; then echo "No build sources resolved from build_matrix_json" >&2 exit 1 fi docker buildx imagetools create \ -t "${IMAGE}:${SHA_TAG}" \ -t "${IMAGE}:${BRANCH_TAG}" \ -t "${IMAGE}:latest" \ "${sources[@]}" - name: Resolve immutable digest (image_ref) id: out shell: bash run: | set -euo pipefail IMAGE="${{ needs.meta.outputs.image }}" SHA_TAG="${{ needs.meta.outputs.image_tag }}" DIGEST="$( docker buildx imagetools inspect "${IMAGE}:${SHA_TAG}" \ | awk -F'Digest:[[:space:]]*' '/^Digest:/ { print $2; exit }' \ | tr -d '[:space:]' )" if [[ -z "${DIGEST}" ]]; then echo "Failed to resolve digest for ${IMAGE}:${SHA_TAG}" >&2 docker buildx imagetools inspect "${IMAGE}:${SHA_TAG}" || true exit 1 fi echo "image_ref=${IMAGE}@${DIGEST}" >> "${GITHUB_OUTPUT}" deploy: name: Deploy Fleet needs: manifest strategy: fail-fast: false matrix: runner: ${{ fromJSON(inputs.deploy_runners_json) }} runs-on: ${{ matrix.runner }} steps: - uses: actions/checkout@v4 with: fetch-depth: 1 - name: Login Registry (pull) uses: docker/login-action@v3 with: registry: ${{ inputs.docker_registry }} username: ${{ secrets.DOCKER_HUB_USERNAME }} password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} - name: Show deploy image if: runner.os != 'Windows' shell: bash run: | echo "Deploying: ${{ needs.manifest.outputs.image_ref }}" - name: Show deploy image (Windows) if: runner.os == 'Windows' shell: pwsh run: | Write-Output "Deploying: ${{ needs.manifest.outputs.image_ref }}" - name: Compose Up (Unix) if: runner.os != 'Windows' shell: bash working-directory: ${{ inputs.compose_workdir }} env: DOCKER_IMAGE: ${{ needs.manifest.outputs.image_ref }} run: | set -euo pipefail docker compose ${{ inputs.compose_args }} - name: Compose Up (Windows) if: runner.os == 'Windows' shell: pwsh working-directory: ${{ inputs.compose_workdir }} env: DOCKER_IMAGE: ${{ needs.manifest.outputs.image_ref }} run: | docker compose ${{ inputs.compose_args }}