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