From ba424cb1be7ec0629f6082182de7ff51af8c4ad0 Mon Sep 17 00:00:00 2001 From: JockLee <5155291+webees@users.noreply.github.com> Date: Mon, 21 Sep 2026 19:02:08 +0700 Subject: [PATCH] ci: auto build multi-arch image to GHCR with cleanup Add Docker workflow: on push to main / v* tags build linux/amd64 and linux/arm64 images on native runners, merge them into a multi-arch manifest and push to GHCR. PRs build without pushing. Speed up builds with cached npm/go layers and per-arch GHA cache scopes, and keep Actions runs and GHCR package versions trimmed to the latest after every run. Also build frontend assets on BUILDPLATFORM and use npm ci in the Dockerfile so arm64 builds no longer hit QEMU SIGILL. --- .github/workflows/docker.yml | 148 +++++++++++++++++++++++++++++++++++ Dockerfile | 25 +++++- 2 files changed, 169 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/docker.yml diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 0000000..a9820fb --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,148 @@ +name: Docker + +on: + push: + branches: [main] + tags: ['v*'] + paths-ignore: ['**.md', 'docs/**'] + pull_request: + branches: [main] + workflow_dispatch: + inputs: + keep-images: + description: "Untagged image versions to keep" + default: "2" + keep-runs: + description: "Workflow runs to keep" + default: "1" + +# Cancel superseded runs on the same ref to save minutes. +concurrency: + group: docker-${{ github.ref }} + cancel-in-progress: true + +env: + IMAGE: ghcr.io/${{ github.repository }} + KEEP_IMAGES: ${{ github.event.inputs.keep-images || 2 }} + KEEP_RUNS: ${{ github.event.inputs.keep-runs || 1 }} + +jobs: + # Build each arch on its native runner (no QEMU emulation). + build: + strategy: + fail-fast: false + matrix: + include: + - platform: linux/amd64 + runner: ubuntu-latest + suffix: amd64 + - platform: linux/arm64 + runner: ubuntu-24.04-arm + suffix: arm64 + runs-on: ${{ matrix.runner }} + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v4 + - uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + if: github.event_name != 'pull_request' + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build image + id: build + uses: docker/build-push-action@v6 + with: + context: . + platforms: ${{ matrix.platform }} + outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=${{ github.event_name != 'pull_request' }} + build-args: | + VERSION=${{ github.ref_type == 'tag' && github.ref_name || github.sha }} + COMMIT=${{ github.sha }} + cache-from: type=gha,scope=build-${{ matrix.suffix }} + cache-to: type=gha,mode=max,scope=build-${{ matrix.suffix }} + + - name: Export digest + if: github.event_name != 'pull_request' + env: + DIGEST: ${{ steps.build.outputs.digest }} + run: | + mkdir -p "${RUNNER_TEMP}/digests" + test -n "${DIGEST}" + touch "${RUNNER_TEMP}/digests/${DIGEST#sha256:}" + + - uses: actions/upload-artifact@v4 + if: github.event_name != 'pull_request' + with: + name: digests-${{ matrix.suffix }} + path: ${{ runner.temp }}/digests/* + if-no-files-found: error + retention-days: 1 + + # Merge per-arch images into one manifest list, then prune old versions/runs. + merge: + if: github.event_name != 'pull_request' + needs: build + runs-on: ubuntu-latest + permissions: + actions: write + contents: read + packages: write + steps: + - uses: actions/download-artifact@v4 + with: + path: ${{ runner.temp }}/digests + pattern: digests-* + merge-multiple: true + + - uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.IMAGE }} + tags: | + type=raw,value=latest,enable={{is_default_branch}} + type=ref,event=branch + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=sha,prefix=,suffix=,format=short + + - name: Create manifest list and push + working-directory: ${{ runner.temp }}/digests + env: + META_JSON: ${{ steps.meta.outputs.json }} + run: | + # shellcheck disable=SC2046 # unquoted on purpose: expand each digest into a separate arg + jq -j '.tags[] | "-t", "\u0000", ., "\u0000"' <<< "${META_JSON}" \ + | xargs -0 docker buildx imagetools create $(printf "${IMAGE}@sha256:%s " *) + + # Keeps the latest multi-arch image plus its per-arch manifests. + # Pinned: actions/delete-package-versions@v5 (official, v5.0.0). + - uses: actions/delete-package-versions@e5bc658cc4c965c472efe991f8beea3981499c55 + with: + package-name: fenfa + package-type: container + min-versions-to-keep: ${{ env.KEEP_IMAGES }} + delete-only-untagged-versions: "true" + + # Keeps the latest run (this one, still in progress) and older runs only. + # Pinned: Mattraks/delete-workflow-runs@v2 (v2 -> v2.1.0, latest commit 2026-06-05). + - uses: Mattraks/delete-workflow-runs@0cf693bc9909e5e867ee8f27b813224ba862939c + with: + retain_days: 0 + keep_minimum_runs: ${{ env.KEEP_RUNS }} diff --git a/Dockerfile b/Dockerfile index b263165..a110253 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,24 +1,41 @@ +# syntax=docker/dockerfile:1 # Multi-stage, multi-arch build for fenfa # Frontend build (Node) -FROM node:20-alpine AS node-builder +# NOTE: frontend output is arch-independent static files, so always build it +# on the native runner arch ($BUILDPLATFORM) to avoid QEMU SIGILL (exit 132) +# caused by vite/rollup native binaries under arm64 emulation. +FROM --platform=$BUILDPLATFORM node:20-alpine AS node-builder WORKDIR /src +# Copy manifests first so npm install layers stay cached across code changes +COPY web/front/package.json web/front/package-lock.json ./web/front/ +COPY web/admin/package.json web/admin/package-lock.json ./web/admin/ +RUN --mount=type=cache,target=/root/.npm \ + cd web/front && npm ci --no-audit --no-fund \ + && cd ../admin && npm ci --no-audit --no-fund COPY . . # Build front and admin (vite) -RUN cd web/front && npm install && npm run build -RUN cd web/admin && npm install && npm run build +RUN cd web/front && npm run build +RUN cd web/admin && npm run build # Go build (needs CGO + sqlite headers) FROM golang:1.25-alpine AS go-builder RUN apk add --no-cache build-base sqlite-dev ENV CGO_ENABLED=1 WORKDIR /src +# Copy go manifests first so module download stays cached across code changes +COPY go.mod go.sum ./ +RUN --mount=type=cache,target=/go/pkg/mod \ + --mount=type=cache,target=/root/.cache/go-build \ + go mod download COPY . . # Bring in the built frontend artifacts from node-builder stage COPY --from=node-builder /src/internal/web/dist /src/internal/web/dist # Build server binary ARG VERSION=dev ARG COMMIT=none -RUN go build -ldflags="-s -w -X main.version=${VERSION} -X main.commit=${COMMIT}" -o /out/fenfa ./cmd/server +RUN --mount=type=cache,target=/go/pkg/mod \ + --mount=type=cache,target=/root/.cache/go-build \ + go build -ldflags="-s -w -X main.version=${VERSION} -X main.commit=${COMMIT}" -o /out/fenfa ./cmd/server # Runtime image FROM alpine:3.22.2