From 479a86de219f79b5a4d6729267c9c0656943f274 Mon Sep 17 00:00:00 2001 From: Tien Ngo Date: Tue, 10 Feb 2026 11:37:32 +0700 Subject: [PATCH] feat(ci): add reusable multi-arch build + fleet deploy workflow --- .../workflows/docker-build-deploy-fleet.yml | 269 ++++++++++++++++++ 1 file changed, 269 insertions(+) create mode 100644 .gitea/workflows/docker-build-deploy-fleet.yml diff --git a/.gitea/workflows/docker-build-deploy-fleet.yml b/.gitea/workflows/docker-build-deploy-fleet.yml new file mode 100644 index 0000000..24e5a02 --- /dev/null +++ b/.gitea/workflows/docker-build-deploy-fleet.yml @@ -0,0 +1,269 @@ +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 < <(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}`); + } + ' IMAGE="${IMAGE}" SHA_TAG="${SHA_TAG}") + + 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 }} +