Files
picpeak/.github/workflows/docker-build.yml
T
Paul Nothaft 37a15e3d49 fix(faces): restore the :beta image tag and surface sidecar health (#1087)
* fix(faces): restore the :beta image tag and surface sidecar health

Both halves of what a user hit on discussions/1069: the People card sat
at "Scanning… 0 of 227" for 30 minutes with no explanation, because the
sidecar container could never have started.

docker-build.yml — republish `:beta`. It used to come for free via
`type=ref,event=branch` when the active development branch was literally
named `beta`; the rename to `main` silently retired it. backend:beta has
been frozen at 2026-06-29 (448da950) ever since while :main moved on, so
PICPEAK_CHANNEL=beta has been serving a seven-week-old build across every
image. The ml sidecar was added after the rename and so never had a
`:beta` at all, which left docker-compose.production.yml:158 unable to
resolve ghcr.io/picpeak/picpeak/ml for any documented channel — the
image simply does not exist as :beta or :stable, only as :main and
pinned versions. Tag added to all four merge jobs, gated on main.

`:stable` stays absent for ml on purpose: it is gated on refs/heads/stable
and the sidecar does not exist there. stable's docker-compose.production.yml
carries no picpeak-ml service, so nothing can reference the missing tag.

FaceRecognitionCard — show when the sidecar is unreachable. An
unreachable sidecar is not an error by design: faceQueue.js:132-136
releases the photo back to `pending` and retries forever so a restart
does not burn the queue. The cost was that a stopped container looked
exactly like a slow scan, indefinitely, and the only signal was a
backend log line rate-limited to once per five minutes.
/admin/events/faces/health already existed and nothing in the frontend
called it. It is now polled while a scan is in progress, and a failing
check replaces the spinner with the sidecar URL, the underlying error
(which distinguishes a stopped container from a token mismatch) and the
command to start it.

Health is only polled while a scan is running — an idle card has no
reason to care whether the sidecar is up.

* fix(faces): tell the three sidecar failure modes apart

Follow-up to the health surface in this branch, from an external review
pass. The original warning was right about "the sidecar is not working"
and wrong about almost everything after that.

faceClient.checkHealth now returns a `reason` rather than only a message,
because the caller has to know whether photos survive:
  - 'unauthorized' (401) and 'rejected' (any other 4xx) both become
    SidecarRejectedError in classify(), which workerLoop does NOT retry —
    every claimed photo is marked 'failed'. Telling the admin the scan
    resumes on its own was simply untrue there; both now say to fix the
    cause and Re-scan.
  - 'unreachable' (refused/DNS/timeout/5xx) is the retryable one.

The card also no longer cries wolf. /faces runs inference synchronously
inside an `async def`, so one slow photo blocks the event loop and stalls
/info past its 5s timeout — a healthy sidecar can fail a probe. Verified
with an isolated uvicorn repro: a blocking call in an async handler
stalled the sync /info endpoint to 5.01s. The warning now needs three
consecutive failures AND no drop in `pending`. Three because a single
/faces call may legitimately run to FACE_ML_TIMEOUT_MS (30s) and two
probes 15s apart both fit inside that window; `pending` rather than
`scanned` because scanned counts only 'done', so a run producing
skipped/failed photos is progress that counter misses.

A 4xx burns the queue with no backoff, so it can empty before anyone
opens the card — in_progress goes false and only "227 failed" is left.
The probe therefore also runs when a finished scan has failures, and the
notice renders under the counts instead of replacing them. It is worded
as present-tense service state, not as a claim about those specific
failures: a live probe cannot know whether they came from this
misconfiguration or from corrupt images earlier. Attributing them exactly
needs stored face_error rows, which is a bigger change than this.

Also adds the missing-token case to the unreachable text: FACE_ML_TOKEN
has no default and the container refuses to start without it, so the most
likely first run fails as a plain connection refusal that "just start it"
does not fix.

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-19 16:49:47 +02:00

1411 lines
64 KiB
YAML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
name: Build and Push Docker Images
# This workflow is triggered by:
# - Push to main/stable branches (main → ':main' rolling tag for active-dev
# builds; stable → ':stable' + ':latest' for the curated channel)
# - Version tags from Release Please (e.g., v1.2.0 -> builds versioned images)
# - GitHub Releases (created by Release Please)
# - Pull requests (build verification only, no push by default)
# - Manual workflow dispatch
#
# Multi-arch strategy:
# Each image (backend, frontend) is built once per architecture on a
# native runner — linux/amd64 on ubuntu-latest, linux/arm64 on
# ubuntu-24.04-arm. Each leg pushes by digest to GHCR. A follow-up
# merge job combines the digests into a multi-arch manifest and applies
# the human-readable tags. This is the pattern documented at
# https://docs.docker.com/build/ci/github-actions/multi-platform/
#
# Native runners are used instead of QEMU because npm install under
# QEMU was previously too slow/unreliable for regular branch builds.
on:
push:
branches: [ main, stable ]
tags: [ 'v*.*.*', 'v*.*.*-beta.*' ] # Triggered by Release Please tags (stable and beta)
pull_request:
branches: [ main, stable ]
release:
types: [ published ] # Triggered when Release Please creates a release
workflow_dispatch:
inputs:
push:
description: 'Push images to registry'
required: false
default: 'false'
type: choice
options:
- 'true'
- 'false'
# Once release-please authors releases with a PAT (#719), a new version fires
# BOTH the tag-push and the release-published triggers (GITHUB_TOKEN used to
# suppress them). They build the same immutable version, so collapse them into a
# single run by grouping on the ref. Branch and PR builds use different refs and
# still run independently; a superseding push cancels an in-flight run for the
# same ref (only the newest build per ref is kept).
concurrency:
group: docker-build-${{ github.ref }}
cancel-in-progress: true
env:
REGISTRY: ghcr.io
# BACKEND_IMAGE_NAME and FRONTEND_IMAGE_NAME are computed per job in the
# "Compute image names" step. GHCR requires all-lowercase repository names,
# but ${{ github.repository }} preserves the original case (e.g. "Luca-Timo/...").
# Computing them with bash parameter expansion (${VAR,,}) keeps the workflow
# working on forks regardless of the owner's name casing.
# Default GITHUB_TOKEN to read-only at the workflow level. Each job that
# needs to publish to GHCR sets `packages: write` explicitly. This keeps
# the rest of the workflow (and any future steps) from inheriting unneeded
# privileges (CKV2_GHA_1).
permissions:
contents: read
jobs:
# -----------------------------------------------------------------------------
# Backend: per-arch build, then merge into a multi-arch manifest
# -----------------------------------------------------------------------------
build-backend:
strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
runner: ubuntu-latest
- platform: linux/arm64
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
permissions:
contents: read
packages: write
# Trivy uploads its SARIF to the Security tab from this job — see
# the "Run Trivy" step below. Scanning per-arch by digest (#476)
# is reliable; scanning the multi-arch index by tag from the
# merge-* job was not.
security-events: write
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Compute image names (lowercase for GHCR)
run: |
repo_lc="${GITHUB_REPOSITORY,,}"
echo "BACKEND_IMAGE_NAME=${repo_lc}/backend" >> "$GITHUB_ENV"
echo "FRONTEND_IMAGE_NAME=${repo_lc}/frontend" >> "$GITHUB_ENV"
# Mirror manifests to Docker Hub (picpeak/{backend,frontend}) only on the
# canonical org repo, where the DOCKERHUB_* secrets live. Forks (and any
# other owner) fall back to GHCR-only — the Docker Hub image line and login
# are gated on this flag so their builds keep working unchanged.
if [[ "$GITHUB_REPOSITORY" == "PicPeak/picpeak" ]]; then
echo "DOCKERHUB_ENABLED=true" >> "$GITHUB_ENV"
else
echo "DOCKERHUB_ENABLED=false" >> "$GITHUB_ENV"
fi
- name: Prepare platform pair
run: |
platform="${{ matrix.platform }}"
echo "PLATFORM_PAIR=${platform//\//-}" >> "$GITHUB_ENV"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Container Registry
if: github.event_name != 'pull_request' || github.event.inputs.push == 'true'
id: login-ghcr
continue-on-error: true
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Determine if pushing
id: push-decision
run: |
if [[ "${{ github.event_name }}" == "pull_request" && "${{ github.event.inputs.push }}" != "true" ]]; then
echo "push=false" >> "$GITHUB_OUTPUT"
elif [[ "${{ steps.login-ghcr.outcome }}" != "success" ]]; then
echo "push=false" >> "$GITHUB_OUTPUT"
else
echo "push=true" >> "$GITHUB_OUTPUT"
fi
- name: Extract metadata for Backend (labels only)
id: meta-backend
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}
labels: |
org.opencontainers.image.title=PicPeak Backend
org.opencontainers.image.description=PicPeak photo sharing platform backend service
org.opencontainers.image.vendor=PicPeak
maintainer=${{ github.repository_owner }}
- name: Build Backend image (push by digest)
id: build
uses: docker/build-push-action@v5
with:
context: ./backend
file: ./backend/Dockerfile
platforms: ${{ matrix.platform }}
labels: ${{ steps.meta-backend.outputs.labels }}
cache-from: type=gha,scope=backend-${{ env.PLATFORM_PAIR }}
# ignore-error: a flaky GitHub Actions cache write ("error writing
# layer blob: not_found") must not fail an otherwise-successful build
# that already pushed the image.
cache-to: type=gha,mode=max,scope=backend-${{ env.PLATFORM_PAIR }},ignore-error=true
outputs: ${{ steps.push-decision.outputs.push == 'true' && format('type=image,name={0}/{1},push-by-digest=true,name-canonical=true,push=true', env.REGISTRY, env.BACKEND_IMAGE_NAME) || 'type=cacheonly' }}
build-args: |
CACHEBUST=${{ github.run_number }}
BUILD_DATE=${{ github.event.head_commit.timestamp }}
VCS_REF=${{ github.sha }}
VERSION=${{ steps.meta-backend.outputs.version }}
- name: Export digest
if: steps.push-decision.outputs.push == 'true'
run: |
mkdir -p /tmp/digests
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest artifact
if: steps.push-decision.outputs.push == 'true'
uses: actions/upload-artifact@v4
with:
name: digests-backend-${{ env.PLATFORM_PAIR }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
# Per-arch vulnerability scan (#476). Scanning the multi-arch
# manifest from the merge-* job by tag is unreliable — Trivy's
# remote resolver crashes intermittently with "no child with
# platform linux/amd64 in index". The fix is to scan each leg
# by its single-platform digest right here, where it just landed
# in GHCR. Tag pinned (was @master) so the action + bundled
# Trivy binary don't float between runs.
#
# exit-code is left unset (=0) for now: Trivy reports findings
# to the Security tab but doesn't fail the build. Flipping that
# to '1' to actually gate CI is a deliberate follow-up — needs an
# audit pass first so the next beta build doesn't surprise red.
- name: Run Trivy vulnerability scanner (per-arch, by digest)
if: steps.push-decision.outputs.push == 'true'
uses: aquasecurity/trivy-action@v0.36.0
env:
# docker/build-push-action wraps every push in an OCI index
# (carries the SLSA provenance attestation alongside the
# actual image). Trivy's remote backend defaults to
# linux/amd64 regardless of host arch when resolving an
# index, which makes the arm64 leg crash with "no child
# with platform linux/amd64". Telling Trivy which child to
# scan keeps the provenance attestation intact and fixes
# the resolver crash. Pin to matrix.platform so each leg
# scans its own arch.
TRIVY_PLATFORM: ${{ matrix.platform }}
with:
image-ref: ${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}@${{ steps.build.outputs.digest }}
format: 'sarif'
output: 'trivy-backend-${{ env.PLATFORM_PAIR }}.sarif'
severity: 'CRITICAL,HIGH'
# Base-image CVEs with no released fix are not actionable: the
# Dockerfiles already run `apt-get upgrade -y` behind a CACHEBUST,
# so a fix lands in the next build automatically. Reporting them
# buries the findings someone can actually do something about --
# the ML image alone contributed 123 unfixable alerts. Dropping
# them is also the precondition for ever setting exit-code: 1,
# which build-backend's comment flags as a deliberate follow-up.
ignore-unfixed: true
timeout: '10m'
- name: Upload Trivy scan results to GitHub Security tab
if: steps.push-decision.outputs.push == 'true'
uses: github/codeql-action/upload-sarif@v4
with:
sarif_file: 'trivy-backend-${{ env.PLATFORM_PAIR }}.sarif'
# Distinct category per arch so the Security tab surfaces
# per-platform findings independently — an amd64-only CVE in
# a base layer doesn't get masked by the arm64 scan.
category: 'backend-vulnerabilities-${{ env.PLATFORM_PAIR }}'
merge-backend:
needs: build-backend
runs-on: ubuntu-latest
# No security-events permission here — vulnerability scanning moved
# to per-arch build-backend jobs (#476). This job's only job is to
# combine the per-arch digests into a multi-arch manifest.
permissions:
contents: read
packages: write
# Only run when at least one digest was pushed (i.e. not on PRs without push intent).
if: github.event_name != 'pull_request' || github.event.inputs.push == 'true'
steps:
- name: Compute image names (lowercase for GHCR)
run: |
repo_lc="${GITHUB_REPOSITORY,,}"
echo "BACKEND_IMAGE_NAME=${repo_lc}/backend" >> "$GITHUB_ENV"
echo "FRONTEND_IMAGE_NAME=${repo_lc}/frontend" >> "$GITHUB_ENV"
# Mirror manifests to Docker Hub (picpeak/{backend,frontend}) only on the
# canonical org repo, where the DOCKERHUB_* secrets live. Forks (and any
# other owner) fall back to GHCR-only — the Docker Hub image line and login
# are gated on this flag so their builds keep working unchanged.
if [[ "$GITHUB_REPOSITORY" == "PicPeak/picpeak" ]]; then
echo "DOCKERHUB_ENABLED=true" >> "$GITHUB_ENV"
else
echo "DOCKERHUB_ENABLED=false" >> "$GITHUB_ENV"
fi
- name: Download digest artifacts
uses: actions/download-artifact@v4
with:
path: /tmp/digests
pattern: digests-backend-*
merge-multiple: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Container Registry
id: login-ghcr
continue-on-error: true
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Determine build context
id: context
run: |
if [[ "${{ github.ref }}" == refs/tags/v*-beta* ]] || [[ "${{ github.ref }}" == refs/heads/main ]]; then
# Active-dev branch (`main`, renamed from `beta` per #669) produces
# prereleases; the `-beta.N` version-suffix scheme is unchanged.
echo "channel=beta" >> $GITHUB_OUTPUT
echo "is_prerelease=true" >> $GITHUB_OUTPUT
else
echo "channel=stable" >> $GITHUB_OUTPUT
echo "is_prerelease=false" >> $GITHUB_OUTPUT
fi
- name: Log in to Docker Hub
if: env.DOCKERHUB_ENABLED == 'true'
uses: docker/login-action@v3
with:
registry: docker.io
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Extract metadata for Backend
id: meta-backend
uses: docker/metadata-action@v5
with:
# GHCR always; Docker Hub (picpeak/backend) added on the canonical repo so
# the same tag scheme is mirrored to both registries. metadata-action drops
# the blank second line on forks → GHCR-only there.
images: |
${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}
${{ env.DOCKERHUB_ENABLED == 'true' && 'docker.io/picpeak/backend' || '' }}
labels: |
org.opencontainers.image.title=PicPeak Backend
org.opencontainers.image.description=PicPeak photo sharing platform backend service
org.opencontainers.image.vendor=PicPeak
maintainer=${{ github.repository_owner }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}},enable=${{ steps.context.outputs.is_prerelease == 'false' }}
type=semver,pattern={{major}},enable=${{ steps.context.outputs.is_prerelease == 'false' }}
# #668/#783: publish the git-tag name verbatim (v3.45.0 / v3.84.0-beta.0)
# so users can pin the same string as the GitHub release. metadata-action's
# `pattern=v{{version}}` silently dropped it on prereleases, so use type=ref.
type=ref,event=tag
type=sha,format=short
# `:latest` + `:stable` follow the stable channel (the `stable` branch +
# stable release tags). The default branch is now `main` (active dev),
# so `is_default_branch` no longer maps to "stable" — be explicit.
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/stable' || (startsWith(github.ref, 'refs/tags/v') && steps.context.outputs.is_prerelease == 'false') }}
type=raw,value=stable,enable=${{ github.ref == 'refs/heads/stable' || (startsWith(github.ref, 'refs/tags/v') && steps.context.outputs.is_prerelease == 'false') }}
# `:beta` follows the active development branch. This used to happen
# for free via `type=ref,event=branch` back when that branch was
# literally named `beta`; the rename to `main` silently retired the
# tag, so `PICPEAK_CHANNEL=beta` has been pinned to the last pre-rename
# build (backend:beta sat at 2026-06-29 / 448da950) while :main moved
# on. The ml sidecar was added after the rename and so never had a
# `:beta` at all, which left docker-compose.production.yml unable to
# resolve the image for any documented channel.
type=raw,value=beta,enable=${{ github.ref == 'refs/heads/main' }}
# `:beta` is RETIRED post-rename (Option B / #669). Active-dev pulls
# are `:main` (auto via type=ref,event=branch). The pre-rename `:beta`
# tag remains frozen at its last build — operators should update.
- name: Create and push multi-arch manifest
working-directory: /tmp/digests
run: |
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf "${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}@sha256:%s " *)
- name: Inspect manifest (GHCR)
run: |
docker buildx imagetools inspect ${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}:${{ steps.meta-backend.outputs.version }}
- name: Inspect manifest (Docker Hub)
if: env.DOCKERHUB_ENABLED == 'true'
run: |
docker buildx imagetools inspect docker.io/picpeak/backend:${{ steps.meta-backend.outputs.version }}
# -----------------------------------------------------------------------------
# Frontend: per-arch build, then merge into a multi-arch manifest
# -----------------------------------------------------------------------------
build-frontend:
strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
runner: ubuntu-latest
- platform: linux/arm64
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
permissions:
contents: read
packages: write
# See build-backend for the rationale (#476). Same pattern: per-arch
# vulnerability scan by digest, SARIF uploaded to the Security tab.
security-events: write
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Compute image names (lowercase for GHCR)
run: |
repo_lc="${GITHUB_REPOSITORY,,}"
echo "BACKEND_IMAGE_NAME=${repo_lc}/backend" >> "$GITHUB_ENV"
echo "FRONTEND_IMAGE_NAME=${repo_lc}/frontend" >> "$GITHUB_ENV"
# Mirror manifests to Docker Hub (picpeak/{backend,frontend}) only on the
# canonical org repo, where the DOCKERHUB_* secrets live. Forks (and any
# other owner) fall back to GHCR-only — the Docker Hub image line and login
# are gated on this flag so their builds keep working unchanged.
if [[ "$GITHUB_REPOSITORY" == "PicPeak/picpeak" ]]; then
echo "DOCKERHUB_ENABLED=true" >> "$GITHUB_ENV"
else
echo "DOCKERHUB_ENABLED=false" >> "$GITHUB_ENV"
fi
- name: Prepare platform pair
run: |
platform="${{ matrix.platform }}"
echo "PLATFORM_PAIR=${platform//\//-}" >> "$GITHUB_ENV"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Container Registry
if: github.event_name != 'pull_request' || github.event.inputs.push == 'true'
id: login-ghcr
continue-on-error: true
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Determine if pushing
id: push-decision
run: |
if [[ "${{ github.event_name }}" == "pull_request" && "${{ github.event.inputs.push }}" != "true" ]]; then
echo "push=false" >> "$GITHUB_OUTPUT"
elif [[ "${{ steps.login-ghcr.outcome }}" != "success" ]]; then
echo "push=false" >> "$GITHUB_OUTPUT"
else
echo "push=true" >> "$GITHUB_OUTPUT"
fi
- name: Extract metadata for Frontend (labels only)
id: meta-frontend
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE_NAME }}
labels: |
org.opencontainers.image.title=PicPeak Frontend
org.opencontainers.image.description=PicPeak photo sharing platform frontend application
org.opencontainers.image.vendor=PicPeak
maintainer=${{ github.repository_owner }}
- name: Build Frontend image (push by digest)
id: build
uses: docker/build-push-action@v5
with:
context: ./frontend
file: ./frontend/Dockerfile
platforms: ${{ matrix.platform }}
labels: ${{ steps.meta-frontend.outputs.labels }}
cache-from: type=gha,scope=frontend-${{ env.PLATFORM_PAIR }}
# ignore-error: a flaky GitHub Actions cache write ("error writing
# layer blob: not_found") must not fail an otherwise-successful build
# that already pushed the image.
cache-to: type=gha,mode=max,scope=frontend-${{ env.PLATFORM_PAIR }},ignore-error=true
outputs: ${{ steps.push-decision.outputs.push == 'true' && format('type=image,name={0}/{1},push-by-digest=true,name-canonical=true,push=true', env.REGISTRY, env.FRONTEND_IMAGE_NAME) || 'type=cacheonly' }}
build-args: |
CACHEBUST=${{ github.run_number }}
BUILD_DATE=${{ github.event.head_commit.timestamp }}
VCS_REF=${{ github.sha }}
VERSION=${{ steps.meta-frontend.outputs.version }}
- name: Export digest
if: steps.push-decision.outputs.push == 'true'
run: |
mkdir -p /tmp/digests
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest artifact
if: steps.push-decision.outputs.push == 'true'
uses: actions/upload-artifact@v4
with:
name: digests-frontend-${{ env.PLATFORM_PAIR }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
# Per-arch vulnerability scan (#476). See build-backend for the
# full rationale; identical pattern here, only the image-ref +
# SARIF filename + category change.
- name: Run Trivy vulnerability scanner (per-arch, by digest)
if: steps.push-decision.outputs.push == 'true'
uses: aquasecurity/trivy-action@v0.36.0
env:
# See build-backend for the rationale — pin Trivy's platform
# to the matrix arch so its remote-index resolver picks the
# right child instead of defaulting to linux/amd64 and
# crashing on the arm64 leg.
TRIVY_PLATFORM: ${{ matrix.platform }}
with:
image-ref: ${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE_NAME }}@${{ steps.build.outputs.digest }}
format: 'sarif'
output: 'trivy-frontend-${{ env.PLATFORM_PAIR }}.sarif'
severity: 'CRITICAL,HIGH'
# Base-image CVEs with no released fix are not actionable: the
# Dockerfiles already run `apt-get upgrade -y` behind a CACHEBUST,
# so a fix lands in the next build automatically. Reporting them
# buries the findings someone can actually do something about --
# the ML image alone contributed 123 unfixable alerts. Dropping
# them is also the precondition for ever setting exit-code: 1,
# which build-backend's comment flags as a deliberate follow-up.
ignore-unfixed: true
timeout: '10m'
- name: Upload Trivy scan results to GitHub Security tab
if: steps.push-decision.outputs.push == 'true'
uses: github/codeql-action/upload-sarif@v4
with:
sarif_file: 'trivy-frontend-${{ env.PLATFORM_PAIR }}.sarif'
category: 'frontend-vulnerabilities-${{ env.PLATFORM_PAIR }}'
merge-frontend:
needs: build-frontend
runs-on: ubuntu-latest
# See merge-backend — vulnerability scanning moved to the per-arch
# build-frontend matrix (#476). This job only publishes the manifest.
permissions:
contents: read
packages: write
if: github.event_name != 'pull_request' || github.event.inputs.push == 'true'
steps:
- name: Compute image names (lowercase for GHCR)
run: |
repo_lc="${GITHUB_REPOSITORY,,}"
echo "BACKEND_IMAGE_NAME=${repo_lc}/backend" >> "$GITHUB_ENV"
echo "FRONTEND_IMAGE_NAME=${repo_lc}/frontend" >> "$GITHUB_ENV"
# Mirror manifests to Docker Hub (picpeak/{backend,frontend}) only on the
# canonical org repo, where the DOCKERHUB_* secrets live. Forks (and any
# other owner) fall back to GHCR-only — the Docker Hub image line and login
# are gated on this flag so their builds keep working unchanged.
if [[ "$GITHUB_REPOSITORY" == "PicPeak/picpeak" ]]; then
echo "DOCKERHUB_ENABLED=true" >> "$GITHUB_ENV"
else
echo "DOCKERHUB_ENABLED=false" >> "$GITHUB_ENV"
fi
- name: Download digest artifacts
uses: actions/download-artifact@v4
with:
path: /tmp/digests
pattern: digests-frontend-*
merge-multiple: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Container Registry
id: login-ghcr
continue-on-error: true
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Determine build context
id: context
run: |
if [[ "${{ github.ref }}" == refs/tags/v*-beta* ]] || [[ "${{ github.ref }}" == refs/heads/main ]]; then
# Active-dev branch (`main`, renamed from `beta` per #669) produces
# prereleases; the `-beta.N` version-suffix scheme is unchanged.
echo "channel=beta" >> $GITHUB_OUTPUT
echo "is_prerelease=true" >> $GITHUB_OUTPUT
else
echo "channel=stable" >> $GITHUB_OUTPUT
echo "is_prerelease=false" >> $GITHUB_OUTPUT
fi
- name: Log in to Docker Hub
if: env.DOCKERHUB_ENABLED == 'true'
uses: docker/login-action@v3
with:
registry: docker.io
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Extract metadata for Frontend
id: meta-frontend
uses: docker/metadata-action@v5
with:
# GHCR always; Docker Hub (picpeak/frontend) added on the canonical repo so
# the same tag scheme is mirrored to both registries. metadata-action drops
# the blank second line on forks → GHCR-only there.
images: |
${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE_NAME }}
${{ env.DOCKERHUB_ENABLED == 'true' && 'docker.io/picpeak/frontend' || '' }}
labels: |
org.opencontainers.image.title=PicPeak Frontend
org.opencontainers.image.description=PicPeak photo sharing platform frontend application
org.opencontainers.image.vendor=PicPeak
maintainer=${{ github.repository_owner }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}},enable=${{ steps.context.outputs.is_prerelease == 'false' }}
type=semver,pattern={{major}},enable=${{ steps.context.outputs.is_prerelease == 'false' }}
# #668/#783: publish the git-tag name verbatim (v3.45.0 / v3.84.0-beta.0)
# so users can pin the same string as the GitHub release. metadata-action's
# `pattern=v{{version}}` silently dropped it on prereleases, so use type=ref.
type=ref,event=tag
type=sha,format=short
# `:latest` + `:stable` follow the stable channel (the `stable` branch +
# stable release tags). The default branch is now `main` (active dev),
# so `is_default_branch` no longer maps to "stable" — be explicit.
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/stable' || (startsWith(github.ref, 'refs/tags/v') && steps.context.outputs.is_prerelease == 'false') }}
type=raw,value=stable,enable=${{ github.ref == 'refs/heads/stable' || (startsWith(github.ref, 'refs/tags/v') && steps.context.outputs.is_prerelease == 'false') }}
# `:beta` follows the active development branch. This used to happen
# for free via `type=ref,event=branch` back when that branch was
# literally named `beta`; the rename to `main` silently retired the
# tag, so `PICPEAK_CHANNEL=beta` has been pinned to the last pre-rename
# build (backend:beta sat at 2026-06-29 / 448da950) while :main moved
# on. The ml sidecar was added after the rename and so never had a
# `:beta` at all, which left docker-compose.production.yml unable to
# resolve the image for any documented channel.
type=raw,value=beta,enable=${{ github.ref == 'refs/heads/main' }}
# `:beta` is RETIRED post-rename (Option B / #669). Active-dev pulls
# are `:main` (auto via type=ref,event=branch). The pre-rename `:beta`
# tag remains frozen at its last build — operators should update.
- name: Create and push multi-arch manifest
working-directory: /tmp/digests
run: |
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf "${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE_NAME }}@sha256:%s " *)
- name: Inspect manifest (GHCR)
run: |
docker buildx imagetools inspect ${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE_NAME }}:${{ steps.meta-frontend.outputs.version }}
- name: Inspect manifest (Docker Hub)
if: env.DOCKERHUB_ENABLED == 'true'
run: |
docker buildx imagetools inspect docker.io/picpeak/frontend:${{ steps.meta-frontend.outputs.version }}
# -----------------------------------------------------------------------------
# All-in-one (#1042): backend + built frontend in one container, SQLite default.
# Same per-arch build → digest merge pattern as backend/frontend. Context is
# the repo root (Dockerfile.aio needs backend/ AND frontend/).
# -----------------------------------------------------------------------------
build-aio:
strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
runner: ubuntu-latest
- platform: linux/arm64
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
permissions:
contents: read
packages: write
# Per-arch Trivy scan by digest, same rationale as build-backend (#476).
security-events: write
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Compute image name (lowercase for GHCR)
run: |
repo_lc="${GITHUB_REPOSITORY,,}"
echo "AIO_IMAGE_NAME=${repo_lc}/aio" >> "$GITHUB_ENV"
- name: Prepare platform pair
run: |
platform="${{ matrix.platform }}"
echo "PLATFORM_PAIR=${platform//\//-}" >> "$GITHUB_ENV"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Container Registry
if: github.event_name != 'pull_request' || github.event.inputs.push == 'true'
id: login-ghcr
continue-on-error: true
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Determine if pushing
id: push-decision
run: |
if [[ "${{ github.event_name }}" == "pull_request" && "${{ github.event.inputs.push }}" != "true" ]]; then
echo "push=false" >> "$GITHUB_OUTPUT"
elif [[ "${{ steps.login-ghcr.outcome }}" != "success" ]]; then
echo "push=false" >> "$GITHUB_OUTPUT"
else
echo "push=true" >> "$GITHUB_OUTPUT"
fi
- name: Extract metadata for AIO (labels only)
id: meta-aio
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.AIO_IMAGE_NAME }}
labels: |
org.opencontainers.image.title=PicPeak All-in-one
org.opencontainers.image.description=PicPeak backend + frontend in a single container (SQLite default)
org.opencontainers.image.vendor=PicPeak
maintainer=${{ github.repository_owner }}
- name: Build AIO image (push by digest)
id: build
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile.aio
platforms: ${{ matrix.platform }}
labels: ${{ steps.meta-aio.outputs.labels }}
cache-from: type=gha,scope=aio-${{ env.PLATFORM_PAIR }}
# ignore-error: a flaky GitHub Actions cache write must not fail an
# otherwise-successful build that already pushed the image.
cache-to: type=gha,mode=max,scope=aio-${{ env.PLATFORM_PAIR }},ignore-error=true
outputs: ${{ steps.push-decision.outputs.push == 'true' && format('type=image,name={0}/{1},push-by-digest=true,name-canonical=true,push=true', env.REGISTRY, env.AIO_IMAGE_NAME) || 'type=cacheonly' }}
build-args: |
CACHEBUST=${{ github.run_number }}
BUILD_DATE=${{ github.event.head_commit.timestamp }}
VCS_REF=${{ github.sha }}
VERSION=${{ steps.meta-aio.outputs.version }}
- name: Export digest
if: steps.push-decision.outputs.push == 'true'
run: |
mkdir -p /tmp/digests
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest artifact
if: steps.push-decision.outputs.push == 'true'
uses: actions/upload-artifact@v4
with:
name: digests-aio-${{ env.PLATFORM_PAIR }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
- name: Run Trivy vulnerability scanner (per-arch, by digest)
if: steps.push-decision.outputs.push == 'true'
uses: aquasecurity/trivy-action@v0.36.0
env:
# See build-backend — pin Trivy's platform to the matrix arch so its
# remote-index resolver picks the right child.
TRIVY_PLATFORM: ${{ matrix.platform }}
with:
image-ref: ${{ env.REGISTRY }}/${{ env.AIO_IMAGE_NAME }}@${{ steps.build.outputs.digest }}
format: 'sarif'
output: 'trivy-aio-${{ env.PLATFORM_PAIR }}.sarif'
severity: 'CRITICAL,HIGH'
# Base-image CVEs with no released fix are not actionable: the
# Dockerfiles already run `apt-get upgrade -y` behind a CACHEBUST,
# so a fix lands in the next build automatically. Reporting them
# buries the findings someone can actually do something about --
# the ML image alone contributed 123 unfixable alerts. Dropping
# them is also the precondition for ever setting exit-code: 1,
# which build-backend's comment flags as a deliberate follow-up.
ignore-unfixed: true
timeout: '10m'
- name: Upload Trivy scan results to GitHub Security tab
if: steps.push-decision.outputs.push == 'true'
uses: github/codeql-action/upload-sarif@v4
with:
sarif_file: 'trivy-aio-${{ env.PLATFORM_PAIR }}.sarif'
category: 'aio-vulnerabilities-${{ env.PLATFORM_PAIR }}'
merge-aio:
needs: build-aio
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
if: github.event_name != 'pull_request' || github.event.inputs.push == 'true'
steps:
- name: Compute image name (lowercase for GHCR)
run: |
repo_lc="${GITHUB_REPOSITORY,,}"
echo "AIO_IMAGE_NAME=${repo_lc}/aio" >> "$GITHUB_ENV"
- name: Download digest artifacts
uses: actions/download-artifact@v4
with:
path: /tmp/digests
pattern: digests-aio-*
merge-multiple: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Container Registry
id: login-ghcr
continue-on-error: true
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Determine build context
id: context
run: |
if [[ "${{ github.ref }}" == refs/tags/v*-beta* ]] || [[ "${{ github.ref }}" == refs/heads/main ]]; then
echo "channel=beta" >> $GITHUB_OUTPUT
echo "is_prerelease=true" >> $GITHUB_OUTPUT
else
echo "channel=stable" >> $GITHUB_OUTPUT
echo "is_prerelease=false" >> $GITHUB_OUTPUT
fi
# Same per-version tag scheme as backend/frontend: every Release Please
# version publishes a matching aio image. GHCR-only for now — the Docker
# Hub mirror (docker.io/picpeak/aio) is wired later once the Hub repo
# exists: add the images line + Docker Hub login exactly like
# merge-backend (#1042).
- name: Extract metadata for AIO
id: meta-aio
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.AIO_IMAGE_NAME }}
labels: |
org.opencontainers.image.title=PicPeak All-in-one
org.opencontainers.image.description=PicPeak backend + frontend in a single container (SQLite default)
org.opencontainers.image.vendor=PicPeak
maintainer=${{ github.repository_owner }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}},enable=${{ steps.context.outputs.is_prerelease == 'false' }}
type=semver,pattern={{major}},enable=${{ steps.context.outputs.is_prerelease == 'false' }}
# #668/#783: publish the git-tag name verbatim, same as backend/frontend.
type=ref,event=tag
type=sha,format=short
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/stable' || (startsWith(github.ref, 'refs/tags/v') && steps.context.outputs.is_prerelease == 'false') }}
type=raw,value=stable,enable=${{ github.ref == 'refs/heads/stable' || (startsWith(github.ref, 'refs/tags/v') && steps.context.outputs.is_prerelease == 'false') }}
# `:beta` follows the active development branch. This used to happen
# for free via `type=ref,event=branch` back when that branch was
# literally named `beta`; the rename to `main` silently retired the
# tag, so `PICPEAK_CHANNEL=beta` has been pinned to the last pre-rename
# build (backend:beta sat at 2026-06-29 / 448da950) while :main moved
# on. The ml sidecar was added after the rename and so never had a
# `:beta` at all, which left docker-compose.production.yml unable to
# resolve the image for any documented channel.
type=raw,value=beta,enable=${{ github.ref == 'refs/heads/main' }}
- name: Create and push multi-arch manifest
working-directory: /tmp/digests
run: |
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf "${{ env.REGISTRY }}/${{ env.AIO_IMAGE_NAME }}@sha256:%s " *)
- name: Inspect manifest (GHCR)
run: |
docker buildx imagetools inspect ${{ env.REGISTRY }}/${{ env.AIO_IMAGE_NAME }}:${{ steps.meta-aio.outputs.version }}
# Boot-level verification of the AIO image on every PR: build for the
# runner's arch, run it with no DB env (SQLite default), and assert the
# things nginx used to guarantee — SPA shell with the brand title rendered,
# immutable asset caching, /health green, and the resolver landing on
# SQLite. Mirrors the install-smoke workflow's build pattern.
smoke-aio:
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build AIO image (single arch)
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile.aio
load: true
tags: picpeak-aio:smoke
cache-from: type=gha,scope=aio-linux-amd64
cache-to: type=gha,mode=max,scope=aio-linux-amd64,ignore-error=true
- name: Boot container (SQLite default, no volumes)
run: |
docker run -d --name aio -p 3000:3000 \
-e JWT_SECRET=smoke-test-secret-at-least-32-characters-long \
-e BRAND_TITLE="AIO Smoke" \
picpeak-aio:smoke
- name: Wait for /health
run: |
for i in $(seq 1 60); do
if curl -fsS http://localhost:3000/health > /dev/null 2>&1; then
echo "healthy after ~$((i*2))s"; exit 0
fi
sleep 2
done
echo "::error::/health never came up"; docker logs aio | tail -100; exit 1
- name: Assert engine resolved to SQLite
run: |
docker exec aio ls -la /data/db/picpeak.db
docker logs aio 2>&1 | grep -i "sqlite" | head -5
- name: Assert SPA shell served with rendered brand title
run: |
body=$(curl -fsS http://localhost:3000/admin)
echo "$body" | grep -q '<div id="root">' || { echo "::error::/admin did not serve the SPA shell"; exit 1; }
echo "$body" | grep -q '<title>AIO Smoke</title>' || { echo "::error::BRAND_TITLE was not rendered into index.html"; exit 1; }
# -F on the literal token: index.html's explanatory comment mentions
# BRAND_TITLE in prose and Vite keeps that comment in the built shell,
# so a bare `grep BRAND_TITLE` always matches. Only an unsubstituted
# ${BRAND_TITLE}/${BRAND_DESCRIPTION} is a real leak.
for tok in '${BRAND_TITLE}' '${BRAND_DESCRIPTION}'; do
echo "$body" | grep -qF "$tok" && { echo "::error::unrendered placeholder $tok leaked"; exit 1; } || true
done
- name: Assert hashed assets are cached immutably
run: |
asset=$(curl -fsS http://localhost:3000/admin | grep -oE '/assets/[^"]+\.js' | head -1)
test -n "$asset" || { echo "::error::no asset reference found in SPA shell"; exit 1; }
headers=$(curl -fsSI "http://localhost:3000${asset}")
echo "$headers" | grep -qi 'cache-control:.*immutable' || { echo "::error::asset served without immutable cache header"; echo "$headers"; exit 1; }
- name: Assert API and root respond
run: |
curl -fsS http://localhost:3000/api/public/settings > /dev/null
# `/` goes to handlePublicSiteRequest, which 302s to /admin/login while
# the public landing site is disabled — the state of a fresh install,
# which is exactly what this container is. Assert the redirect target
# rather than a 200, so the check still proves express.static's index
# option isn't shadowing the handler.
code=$(curl -s -o /dev/null -w '%{http_code}' http://localhost:3000/)
loc=$(curl -s -o /dev/null -w '%{redirect_url}' http://localhost:3000/)
[[ "$code" == "302" && "$loc" == *"/admin/login" ]] \
|| { echo "::error::/ returned $code (Location: ${loc:-none}); expected 302 -> /admin/login"; exit 1; }
- name: Assert every client route survives a direct hit
run: |
# nginx did `try_files $uri $uri/ /index.html`, so behind compose these
# always worked and nothing caught their absence here. /setup is the
# first URL a new install visits.
for r in /setup /customer /impressum /datenschutz /payment-check \
/quote/x /contract/x /invite/x /transfer/x /transfer-upload/x; do
code=$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:3000${r}")
[[ "$code" == "200" ]] || { echo "::error::${r} returned $code, expected the SPA shell"; exit 1; }
done
- name: Assert the catch-all did not swallow the API or the short-URL resolver
run: |
# The SPA catch-all is registered after the /api 404 handler, so an
# unknown API route must still answer JSON rather than the HTML shell.
body=$(curl -s "http://localhost:3000/api/nope")
grep -q '<div id="root">' <<< "$body" && { echo "::error::unknown /api route served the SPA shell"; exit 1; } || true
grep -q '"error"' <<< "$body" || { echo "::error::unknown /api route did not answer JSON: $body"; exit 1; }
# A typo'd short URL must still 404 rather than render the shell (#699).
code=$(curl -s -o /dev/null -w '%{http_code}' http://localhost:3000/s/nonexistent)
[[ "$code" == "404" ]] || { echo "::error::/s/<unknown> returned $code, expected 404"; exit 1; }
- name: Assert the SPA bundle is gzipped
run: |
asset=$(curl -fsS http://localhost:3000/admin | grep -oE '/assets/[^"]+\.js' | head -1)
# GET, not HEAD: the compression middleware skips bodyless responses,
# so a HEAD probe reports no Content-Encoding even when gzip is active.
enc=$(curl -s -o /dev/null -D - -H 'Accept-Encoding: gzip' "http://localhost:3000${asset}" | grep -i '^content-encoding:')
grep -qi gzip <<< "$enc" || { echo "::error::asset served uncompressed (compression middleware inactive?)"; exit 1; }
- name: Assert the one-volume layout and backup destinations
run: |
# #1042 asks for a single mountable root. Everything that must survive a
# container replacement lives under /data, and /backup — where migrations
# 029/030 seed the backup destinations — symlinks into it rather than
# dangling inside the container.
docker exec aio sh -c 'test -L /backup' || { echo "::error::/backup is not a symlink into the volume"; exit 1; }
for d in /data/db /data/storage /data/logs /data/backup/picpeak /data/backup/database; do
docker exec aio sh -c "test -d $d" || { echo "::error::$d missing from the volume layout"; exit 1; }
done
docker exec aio sh -c 'touch /backup/database/.w && rm /backup/database/.w' \
|| { echo "::error::/backup/database is not writable by the app user"; exit 1; }
- name: Assert the sqlite3 CLI the backup service shells out to
run: |
# DatabaseBackupService spawns `sqlite3` for .backup and integrity_check;
# the npm module does not ship the binary.
docker exec aio sqlite3 --version > /dev/null \
|| { echo "::error::sqlite3 CLI missing — database backups would fail with ENOENT"; exit 1; }
- name: Assert logs land on the volume
run: |
docker exec aio sh -c 'ls /data/logs/*.log > /dev/null 2>&1' \
|| { echo "::error::logs are not being written under /data (LOG_DIR ignored?)"; exit 1; }
- name: Assert backend static routes still 404 instead of the SPA shell
run: |
# /photos, /thumbnails, /uploads and /fonts are backend-owned mounts whose
# middleware calls next() on a miss. nginx gave them their own location
# blocks so try_files never applied; without an explicit exclusion the
# catch-all answers 200 text/html under an image or font URL.
for r in /photos/missing.jpg /thumbnails/missing.jpg /fonts/missing.woff2; do
code=$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:3000${r}")
[[ "$code" != "200" ]] || { echo "::error::${r} returned 200 — the SPA catch-all swallowed a backend 404"; exit 1; }
done
- name: Assert the image carries no runtime data from the build context
run: |
# Dockerfile.aio builds from the repo root; a checkout used to run
# PicPeak must never bake its database, photos, logs or secrets into a
# layer. /app/storage is deliberately a symlink into the volume, so it
# is checked by shape rather than by listing it — following the link
# would only find the empty tree the image creates at /data/storage.
for leak in '/app/data/*.db' '/app/logs/*' '/app/.env' '/app/*.db' '/app/*.sqlite*'; do
if docker exec aio sh -c "ls $leak > /dev/null 2>&1"; then
echo "::error::build context leaked $leak into the image"; exit 1
fi
done
docker exec aio sh -c 'test -L /app/storage' \
|| { echo "::error::/app/storage is a real directory — the build context leaked it in"; exit 1; }
test "$(docker exec aio sh -c 'readlink /app/storage')" = /data/storage \
|| { echo "::error::/app/storage does not point into the mounted volume"; exit 1; }
# The volume's photo tree must start empty on a fresh install.
found=$(docker exec aio sh -c 'find /data/storage/events -type f | head -1')
test -z "$found" || { echo "::error::build context leaked photos into /data/storage/events: $found"; exit 1; }
- name: Dump logs on failure
if: failure()
run: docker logs aio 2>&1 | tail -200
# -----------------------------------------------------------------------------
# ML sidecar (#1074): per-arch build, then merge into a multi-arch manifest
# -----------------------------------------------------------------------------
# Gated on the FACENET_ONNX_URL repository *variable* (Settings → Variables,
# not Secrets — it's a public release-asset URL). While it is unset, both ML
# jobs skip and the workflow behaves exactly as it did before this feature.
#
# Why a gate at all: deepface distributes FaceNet-512 as Keras .h5 only, so
# the ONNX has to be produced once by ml/tools/convert_facenet.py and
# published as a release asset before anything can build. Converting inside
# this workflow would drag TensorFlow (~600MB) through BOTH architecture legs
# of EVERY build to produce a file that is byte-identical each time.
#
# To activate, set two repository variables:
# FACENET_ONNX_URL https://github.com/PicPeak/picpeak/releases/download/<tag>/facenet512.onnx
# FACENET_ONNX_SHA256 <sha256 of that file>
# See ml/README.md for producing them.
build-ml:
if: vars.FACENET_ONNX_URL != ''
strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
runner: ubuntu-latest
- platform: linux/arm64
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
permissions:
contents: read
packages: write
security-events: write
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Compute image names (lowercase for GHCR)
run: |
repo_lc="${GITHUB_REPOSITORY,,}"
echo "ML_IMAGE_NAME=${repo_lc}/ml" >> "$GITHUB_ENV"
- name: Prepare platform pair
run: |
platform="${{ matrix.platform }}"
echo "PLATFORM_PAIR=${platform//\//-}" >> "$GITHUB_ENV"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Container Registry
if: github.event_name != 'pull_request' || github.event.inputs.push == 'true'
id: login-ghcr
continue-on-error: true
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Determine if pushing
id: push-decision
run: |
if [[ "${{ github.event_name }}" == "pull_request" && "${{ github.event.inputs.push }}" != "true" ]]; then
echo "push=false" >> "$GITHUB_OUTPUT"
elif [[ "${{ steps.login-ghcr.outcome }}" != "success" ]]; then
echo "push=false" >> "$GITHUB_OUTPUT"
else
echo "push=true" >> "$GITHUB_OUTPUT"
fi
- name: Extract metadata for ML (labels only)
id: meta-ml
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.ML_IMAGE_NAME }}
labels: |
org.opencontainers.image.title=PicPeak ML
org.opencontainers.image.description=PicPeak face detection and embedding sidecar
org.opencontainers.image.vendor=PicPeak
maintainer=${{ github.repository_owner }}
- name: Build ML image (push by digest)
id: build
uses: docker/build-push-action@v5
with:
context: ./ml
file: ./ml/Dockerfile
platforms: ${{ matrix.platform }}
labels: ${{ steps.meta-ml.outputs.labels }}
cache-from: type=gha,scope=ml-${{ env.PLATFORM_PAIR }}
cache-to: type=gha,mode=max,scope=ml-${{ env.PLATFORM_PAIR }},ignore-error=true
outputs: ${{ steps.push-decision.outputs.push == 'true' && format('type=image,name={0}/{1},push-by-digest=true,name-canonical=true,push=true', env.REGISTRY, env.ML_IMAGE_NAME) || 'type=cacheonly' }}
build-args: |
CACHEBUST=${{ github.run_number }}
BUILD_DATE=${{ github.event.head_commit.timestamp }}
VCS_REF=${{ github.sha }}
VERSION=${{ steps.meta-ml.outputs.version }}
FACENET_ONNX_URL=${{ vars.FACENET_ONNX_URL }}
FACENET_ONNX_SHA256=${{ vars.FACENET_ONNX_SHA256 }}
- name: Export digest
if: steps.push-decision.outputs.push == 'true'
run: |
mkdir -p /tmp/digests
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest artifact
if: steps.push-decision.outputs.push == 'true'
uses: actions/upload-artifact@v4
with:
name: digests-ml-${{ env.PLATFORM_PAIR }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
# Per-arch scan by digest, same reasoning as the backend leg (#476).
# This image carries a Python/Debian dependency surface the other two
# don't, so it gets its own Security-tab category.
- name: Run Trivy vulnerability scanner (per-arch, by digest)
if: steps.push-decision.outputs.push == 'true'
uses: aquasecurity/trivy-action@v0.36.0
env:
TRIVY_PLATFORM: ${{ matrix.platform }}
with:
image-ref: ${{ env.REGISTRY }}/${{ env.ML_IMAGE_NAME }}@${{ steps.build.outputs.digest }}
format: 'sarif'
output: 'trivy-ml-${{ env.PLATFORM_PAIR }}.sarif'
severity: 'CRITICAL,HIGH'
# Base-image CVEs with no released fix are not actionable: the
# Dockerfiles already run `apt-get upgrade -y` behind a CACHEBUST,
# so a fix lands in the next build automatically. Reporting them
# buries the findings someone can actually do something about --
# the ML image alone contributed 123 unfixable alerts. Dropping
# them is also the precondition for ever setting exit-code: 1,
# which build-backend's comment flags as a deliberate follow-up.
ignore-unfixed: true
timeout: '10m'
- name: Upload Trivy scan results to GitHub Security tab
if: steps.push-decision.outputs.push == 'true'
uses: github/codeql-action/upload-sarif@v4
with:
sarif_file: 'trivy-ml-${{ env.PLATFORM_PAIR }}.sarif'
category: 'ml-vulnerabilities-${{ env.PLATFORM_PAIR }}'
merge-ml:
needs: build-ml
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
if: vars.FACENET_ONNX_URL != '' && (github.event_name != 'pull_request' || github.event.inputs.push == 'true')
steps:
- name: Compute image names (lowercase for GHCR)
run: |
repo_lc="${GITHUB_REPOSITORY,,}"
echo "ML_IMAGE_NAME=${repo_lc}/ml" >> "$GITHUB_ENV"
if [[ "$GITHUB_REPOSITORY" == "PicPeak/picpeak" ]]; then
echo "DOCKERHUB_ENABLED=true" >> "$GITHUB_ENV"
else
echo "DOCKERHUB_ENABLED=false" >> "$GITHUB_ENV"
fi
- name: Download digest artifacts
uses: actions/download-artifact@v4
with:
path: /tmp/digests
pattern: digests-ml-*
merge-multiple: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Container Registry
id: login-ghcr
continue-on-error: true
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Determine build context
id: context
run: |
if [[ "${{ github.ref }}" == refs/tags/v*-beta* ]] || [[ "${{ github.ref }}" == refs/heads/main ]]; then
echo "channel=beta" >> $GITHUB_OUTPUT
echo "is_prerelease=true" >> $GITHUB_OUTPUT
else
echo "channel=stable" >> $GITHUB_OUTPUT
echo "is_prerelease=false" >> $GITHUB_OUTPUT
fi
- name: Log in to Docker Hub
if: env.DOCKERHUB_ENABLED == 'true'
uses: docker/login-action@v3
with:
registry: docker.io
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Extract metadata for ML
id: meta-ml
uses: docker/metadata-action@v5
with:
images: |
${{ env.REGISTRY }}/${{ env.ML_IMAGE_NAME }}
${{ env.DOCKERHUB_ENABLED == 'true' && 'docker.io/picpeak/ml' || '' }}
labels: |
org.opencontainers.image.title=PicPeak ML
org.opencontainers.image.description=PicPeak face detection and embedding sidecar
org.opencontainers.image.vendor=PicPeak
maintainer=${{ github.repository_owner }}
# Identical tag scheme to backend/frontend: the sidecar's API contract
# is versioned with the backend that calls it, so PICPEAK_CHANNEL
# resolves the same string across all three images.
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}},enable=${{ steps.context.outputs.is_prerelease == 'false' }}
type=semver,pattern={{major}},enable=${{ steps.context.outputs.is_prerelease == 'false' }}
type=ref,event=tag
type=sha,format=short
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/stable' || (startsWith(github.ref, 'refs/tags/v') && steps.context.outputs.is_prerelease == 'false') }}
type=raw,value=stable,enable=${{ github.ref == 'refs/heads/stable' || (startsWith(github.ref, 'refs/tags/v') && steps.context.outputs.is_prerelease == 'false') }}
# `:beta` follows the active development branch. This used to happen
# for free via `type=ref,event=branch` back when that branch was
# literally named `beta`; the rename to `main` silently retired the
# tag, so `PICPEAK_CHANNEL=beta` has been pinned to the last pre-rename
# build (backend:beta sat at 2026-06-29 / 448da950) while :main moved
# on. The ml sidecar was added after the rename and so never had a
# `:beta` at all, which left docker-compose.production.yml unable to
# resolve the image for any documented channel.
type=raw,value=beta,enable=${{ github.ref == 'refs/heads/main' }}
- name: Create and push multi-arch manifest
working-directory: /tmp/digests
run: |
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf "${{ env.REGISTRY }}/${{ env.ML_IMAGE_NAME }}@sha256:%s " *)
- name: Inspect manifest (GHCR)
run: |
docker buildx imagetools inspect ${{ env.REGISTRY }}/${{ env.ML_IMAGE_NAME }}:${{ steps.meta-ml.outputs.version }}
- name: Inspect manifest (Docker Hub)
if: env.DOCKERHUB_ENABLED == 'true'
run: |
docker buildx imagetools inspect docker.io/picpeak/ml:${{ steps.meta-ml.outputs.version }}
summary:
needs: [build-backend, merge-backend, build-frontend, merge-frontend, build-aio, merge-aio, smoke-aio, build-ml, merge-ml]
if: always()
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Compute image names (lowercase for GHCR)
run: |
repo_lc="${GITHUB_REPOSITORY,,}"
echo "BACKEND_IMAGE_NAME=${repo_lc}/backend" >> "$GITHUB_ENV"
echo "FRONTEND_IMAGE_NAME=${repo_lc}/frontend" >> "$GITHUB_ENV"
echo "ML_IMAGE_NAME=${repo_lc}/ml" >> "$GITHUB_ENV"
echo "AIO_IMAGE_NAME=${repo_lc}/aio" >> "$GITHUB_ENV"
# Mirror manifests to Docker Hub (picpeak/{backend,frontend}) only on the
# canonical org repo, where the DOCKERHUB_* secrets live. Forks (and any
# other owner) fall back to GHCR-only — the Docker Hub image line and login
# are gated on this flag so their builds keep working unchanged.
if [[ "$GITHUB_REPOSITORY" == "PicPeak/picpeak" ]]; then
echo "DOCKERHUB_ENABLED=true" >> "$GITHUB_ENV"
else
echo "DOCKERHUB_ENABLED=false" >> "$GITHUB_ENV"
fi
- name: Build Summary
run: |
echo "## 🐳 Docker Build Summary" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
if [[ "${{ needs.build-backend.result }}" == "success" ]]; then
echo "✅ **Backend build (per-arch)**: Successfully built" >> $GITHUB_STEP_SUMMARY
else
echo "❌ **Backend build (per-arch)**: ${{ needs.build-backend.result }}" >> $GITHUB_STEP_SUMMARY
fi
if [[ "${{ needs.merge-backend.result }}" == "success" ]]; then
echo "✅ **Backend manifest merge**: Successfully published" >> $GITHUB_STEP_SUMMARY
elif [[ "${{ needs.merge-backend.result }}" == "skipped" ]]; then
echo "️ **Backend manifest merge**: Skipped (verify-only build)" >> $GITHUB_STEP_SUMMARY
else
echo "❌ **Backend manifest merge**: ${{ needs.merge-backend.result }}" >> $GITHUB_STEP_SUMMARY
fi
if [[ "${{ needs.build-frontend.result }}" == "success" ]]; then
echo "✅ **Frontend build (per-arch)**: Successfully built" >> $GITHUB_STEP_SUMMARY
else
echo "❌ **Frontend build (per-arch)**: ${{ needs.build-frontend.result }}" >> $GITHUB_STEP_SUMMARY
fi
if [[ "${{ needs.merge-frontend.result }}" == "success" ]]; then
echo "✅ **Frontend manifest merge**: Successfully published" >> $GITHUB_STEP_SUMMARY
elif [[ "${{ needs.merge-frontend.result }}" == "skipped" ]]; then
echo "️ **Frontend manifest merge**: Skipped (verify-only build)" >> $GITHUB_STEP_SUMMARY
else
echo "❌ **Frontend manifest merge**: ${{ needs.merge-frontend.result }}" >> $GITHUB_STEP_SUMMARY
fi
if [[ "${{ needs.build-aio.result }}" == "success" ]]; then
echo "✅ **AIO build (per-arch)**: Successfully built" >> $GITHUB_STEP_SUMMARY
else
echo "❌ **AIO build (per-arch)**: ${{ needs.build-aio.result }}" >> $GITHUB_STEP_SUMMARY
fi
if [[ "${{ needs.merge-aio.result }}" == "success" ]]; then
echo "✅ **AIO manifest merge**: Successfully published" >> $GITHUB_STEP_SUMMARY
elif [[ "${{ needs.merge-aio.result }}" == "skipped" ]]; then
echo "️ **AIO manifest merge**: Skipped (verify-only build)" >> $GITHUB_STEP_SUMMARY
else
echo "❌ **AIO manifest merge**: ${{ needs.merge-aio.result }}" >> $GITHUB_STEP_SUMMARY
fi
if [[ "${{ needs.smoke-aio.result }}" == "success" ]]; then
echo "✅ **AIO boot smoke**: SQLite boot + SPA + caching verified" >> $GITHUB_STEP_SUMMARY
else
echo "❌ **AIO boot smoke**: ${{ needs.smoke-aio.result }}" >> $GITHUB_STEP_SUMMARY
fi
# The ML sidecar (#1074) is optional and only builds once the
# FACENET_ONNX_URL repository variable is set — "skipped" is the
# expected state, not a failure, so report it as such.
if [[ "${{ needs.build-ml.result }}" == "success" ]]; then
echo "✅ **ML sidecar build (per-arch)**: Successfully built" >> $GITHUB_STEP_SUMMARY
elif [[ "${{ needs.build-ml.result }}" == "skipped" ]]; then
echo "️ **ML sidecar build**: Skipped (FACENET_ONNX_URL repository variable not set — see ml/README.md)" >> $GITHUB_STEP_SUMMARY
else
echo "❌ **ML sidecar build (per-arch)**: ${{ needs.build-ml.result }}" >> $GITHUB_STEP_SUMMARY
fi
if [[ "${{ needs.merge-ml.result }}" == "success" ]]; then
echo "✅ **ML sidecar manifest merge**: Successfully published" >> $GITHUB_STEP_SUMMARY
elif [[ "${{ needs.merge-ml.result }}" == "skipped" ]]; then
echo "️ **ML sidecar manifest merge**: Skipped" >> $GITHUB_STEP_SUMMARY
else
echo "❌ **ML sidecar manifest merge**: ${{ needs.merge-ml.result }}" >> $GITHUB_STEP_SUMMARY
fi
echo "" >> $GITHUB_STEP_SUMMARY
echo "### 📦 Images" >> $GITHUB_STEP_SUMMARY
echo "- Backend: \`${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}\`" >> $GITHUB_STEP_SUMMARY
echo "- Frontend: \`${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE_NAME }}\`" >> $GITHUB_STEP_SUMMARY
if [[ "${{ needs.merge-ml.result }}" == "success" ]]; then
echo "- ML sidecar (optional): \`${{ env.REGISTRY }}/${{ env.ML_IMAGE_NAME }}\`" >> $GITHUB_STEP_SUMMARY
fi
echo "- All-in-one: \`${{ env.REGISTRY }}/${{ env.AIO_IMAGE_NAME }}\` (GHCR only — Docker Hub mirror pending)" >> $GITHUB_STEP_SUMMARY
if [[ "$DOCKERHUB_ENABLED" == "true" ]]; then
echo "- Backend (Docker Hub): \`docker.io/picpeak/backend\`" >> $GITHUB_STEP_SUMMARY
echo "- Frontend (Docker Hub): \`docker.io/picpeak/frontend\`" >> $GITHUB_STEP_SUMMARY
fi
echo "" >> $GITHUB_STEP_SUMMARY
echo "### 🏗️ Architectures" >> $GITHUB_STEP_SUMMARY
echo "Published manifests include both \`linux/amd64\` and \`linux/arm64\` (built natively, no QEMU)." >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### 🏷️ Tags" >> $GITHUB_STEP_SUMMARY
echo "Images are tagged based on:" >> $GITHUB_STEP_SUMMARY
echo "- Branch name (for branch pushes)" >> $GITHUB_STEP_SUMMARY
echo "- PR number (for pull requests, when push is enabled)" >> $GITHUB_STEP_SUMMARY
echo "- Version tags (for releases)" >> $GITHUB_STEP_SUMMARY
echo "- Short SHA" >> $GITHUB_STEP_SUMMARY
echo "- \`latest\` (for main branch)" >> $GITHUB_STEP_SUMMARY
echo "- \`stable\` (for main branch and stable releases)" >> $GITHUB_STEP_SUMMARY
echo "- \`beta\` (for beta branch and pre-releases)" >> $GITHUB_STEP_SUMMARY