37a15e3d49
* 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>
1411 lines
64 KiB
YAML
1411 lines
64 KiB
YAML
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
|