2df455784c
The aio image (#1042) shipped GHCR-only with a TODO to wire the Docker Hub mirror once the Hub repo existed. backend, frontend and the ml sidecar all publish to docker.io/picpeak/*; aio was the only image a Docker Hub user could not pull. merge-aio now follows merge-backend/merge-ml verbatim: DOCKERHUB_ENABLED computed from the repository slug (so forks stay GHCR-only), a gated Docker Hub login, docker.io/picpeak/aio added to the metadata images list, and a Docker Hub manifest inspect. Tag scheme is untouched — the same beta/main/stable/latest/semver tags land in both registries. The build summary drops the "Docker Hub mirror pending" note and lists the aio (and ml) Hub images when the mirror is active.
1437 lines
65 KiB
YAML
1437 lines
65 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"
|
||
# 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 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 }}\`" >> $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
|