Files
picpeak/.github/workflows/docker-build.yml
T
Luca 7223118b89 feat(deploy): make the all-in-one image installable without a shell (#1124)
The all-in-one image could not be installed from a GUI at all — the deployment it
exists for. validateEnv treats a missing JWT_SECRET as critical and exits, and the
documented run command supplies it with `openssl rand`, a shell command a Synology
Container Manager or QNAP Container Station form cannot run.

wait-for-db.sh now generates one on first start and persists it next to the database,
extending the existing /run/secrets hydration rather than adding a second mechanism.
Explicit env still wins, then /run/secrets, then the generated file. The write is
load-bearing: JWT_SECRET is exported only when the file actually persisted, because an
unpersisted secret would mint a new one every restart and sign every session out.

Creation writes to a private temp file and hard-links it into place — atomic, fails with
EEXIST when another container won, and the loser adopts the winner's value. Non-regular
paths are rejected before the link, since POSIX ln links INTO a directory rather than
failing, which would make a mistyped -v target unrecoverable.

Also repairs the onboarding paths a new install actually walks: the installer no longer
rotates the secrets of a running install on re-run, deprecates the dead scripts/install.sh
in place, corrects the CONTRIBUTING dev loop, and fixes the vite proxy target that had
been pointing at a stray local port since 0da45e69.

Reviewed over three rounds. Co-authored by @Luca-Timo.
2026-08-22 18:37:12 +02:00

1541 lines
71 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"
# Mirror manifests to Docker Hub (picpeak/aio) 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-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
- 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 }}
# Same per-version tag scheme as backend/frontend/ml: every Release Please
# version publishes a matching aio image, mirrored to Docker Hub
# (docker.io/picpeak/aio) on the canonical org repo (#1042).
- name: Extract metadata for AIO
id: meta-aio
uses: docker/metadata-action@v5
with:
images: |
${{ env.REGISTRY }}/${{ env.AIO_IMAGE_NAME }}
${{ env.DOCKERHUB_ENABLED == 'true' && 'docker.io/picpeak/aio' || '' }}
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 }}
- name: Inspect manifest (Docker Hub)
if: env.DOCKERHUB_ENABLED == 'true'
run: |
docker buildx imagetools inspect docker.io/picpeak/aio:${{ 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 a no-JWT_SECRET install boots and keeps its generated secret
run: |
# The boot above passes JWT_SECRET explicitly, so it cannot catch the case
# this image is actually deployed in: a NAS Container Manager / Container
# Station form has no shell to run `openssl rand`, and validateEnv treats a
# missing JWT_SECRET as critical and exits. wait-for-db.sh generates one
# into the data volume when neither the environment nor /run/secrets
# supplies it (#705).
docker volume create aio-nojwt > /dev/null
docker run -d --name aio-nojwt -p 3100:3000 -v aio-nojwt:/data picpeak-aio:smoke > /dev/null
for i in $(seq 1 60); do
curl -fsS http://localhost:3100/health > /dev/null 2>&1 && break
sleep 2
done
curl -fsS http://localhost:3100/health > /dev/null 2>&1 \
|| { echo "::error::container with no JWT_SECRET never became healthy"; docker logs aio-nojwt | tail -50; exit 1; }
mode=$(docker exec aio-nojwt stat -c '%a' /data/db/jwt.secret 2>/dev/null || echo missing)
[ "$mode" = "600" ] \
|| { echo "::error::/data/db/jwt.secret missing or not 0600 (got: $mode)"; exit 1; }
# The secret has to survive a restart. If it did not, every admin session
# and gallery link would be signed out on each container recreate — which
# is worse than failing to boot, because it looks like it works.
first=$(docker exec aio-nojwt cat /data/db/jwt.secret)
[ -n "$first" ] || { echo "::error::generated jwt.secret is empty"; exit 1; }
docker restart aio-nojwt > /dev/null
for i in $(seq 1 60); do
curl -fsS http://localhost:3100/health > /dev/null 2>&1 && break
sleep 2
done
# Assert recovery rather than falling through to `docker exec`, which
# would report a confusing cat failure for a container that never came
# back up.
curl -fsS http://localhost:3100/health > /dev/null 2>&1 \
|| { echo "::error::container did not become healthy again after restart"; docker logs aio-nojwt | tail -50; exit 1; }
second=$(docker exec aio-nojwt cat /data/db/jwt.secret)
[ "$first" = "$second" ] \
|| { echo "::error::jwt.secret changed across a restart — sessions would not survive"; exit 1; }
# An explicit secret must still win over the generated one. The $ is
# escaped so the comparison happens in the INNERMOST shell, after
# wait-for-db.sh has run. Unescaped it is the CONTAINER's outer `sh -c`
# that expands it — the runner's single quotes do protect it, but the
# container shell then substitutes the value while parsing the
# double-quoted region, before the script runs at all. The test reduces
# to `[ x = x ]` and passes even against a script that clobbers the
# variable: an assertion that cannot fail.
docker run --rm -v aio-nojwt:/data -e JWT_SECRET=explicit-secret-at-least-32-characters-long \
--entrypoint sh picpeak-aio:smoke -c \
'./wait-for-db.sh sh -c "[ \"\$JWT_SECRET\" = explicit-secret-at-least-32-characters-long ]"' \
|| { echo "::error::an explicit JWT_SECRET did not override the generated file"; exit 1; }
docker rm -f aio-nojwt > /dev/null
# -f alone can return before the volume is released
for i in $(seq 1 10); do docker volume rm aio-nojwt > /dev/null 2>&1 && break; sleep 1; done
- 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 }}
# ---------------------------------------------------------------------------
# Docker Hub repository pages
# ---------------------------------------------------------------------------
# The Hub overview for an image is repository metadata, not part of the
# manifest, so pushing tags never updates it. Keep the copy for the two
# newest images in-repo and push it from CI, so a Hub visitor is never
# reading a page that describes a release from six months ago.
#
# Scope: aio and ml only. picpeak/{backend,frontend} still have their pages
# maintained by hand in the Hub UI — bring them under this job by adding
# .github/dockerhub/{backend,frontend}.md with the current text first,
# otherwise this would overwrite them with a near-copy.
#
# Only on `main` pushes for the canonical org repo: descriptions are
# per-repository, not per-tag, so once per merge is exactly enough.
dockerhub-descriptions:
needs: [merge-aio, merge-ml]
if: always() && github.repository == 'PicPeak/picpeak' && github.ref == 'refs/heads/main' && needs.merge-aio.result == 'success'
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Update picpeak/aio description
uses: peter-evans/dockerhub-description@v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
repository: picpeak/aio
short-description: "picpeak all-in-one — self-hosted photo sharing + CRM in a single container (SQLite)."
readme-filepath: .github/dockerhub/aio.md
# Skipped whenever the sidecar itself was skipped (FACENET_ONNX_URL unset),
# since the Hub repository only exists once something has been pushed to it.
- name: Update picpeak/ml description
if: needs.merge-ml.result == 'success'
uses: peter-evans/dockerhub-description@v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
repository: picpeak/ml
short-description: "picpeak face-detection sidecar — optional and stateless. Pairs with picpeak/backend."
readme-filepath: .github/dockerhub/ml.md
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 }}\`" >> $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
echo "- All-in-one (Docker Hub): \`docker.io/picpeak/aio\`" >> $GITHUB_STEP_SUMMARY
if [[ "${{ needs.merge-ml.result }}" == "success" ]]; then
echo "- ML sidecar (Docker Hub): \`docker.io/picpeak/ml\`" >> $GITHUB_STEP_SUMMARY
fi
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