899c9b3407
picpeak/ml has an empty Hub overview and picpeak/aio has none at all,
while backend and frontend carry hand-written ones — so the two newest
images are the two with nothing on their registry page.
Adds .github/dockerhub/{aio,ml}.md as the source of those pages and a
dockerhub-descriptions job that pushes them on every main merge, so the
page cannot drift from the release it describes. backend/frontend stay
hand-maintained for now: capturing their current Hub text into files is
a prerequisite, not a side effect of this change.
README gains a registry table for all four images (both registries share
digests and tags), the org-move callout lists the full set, and the
feature list finally mentions People in this gallery, which shipped in
#1074 without a README line.
1484 lines
68 KiB
YAML
1484 lines
68 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 }}
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Docker Hub repository pages
|
||
# ---------------------------------------------------------------------------
|
||
# The Hub overview for an image is repository metadata, not part of the
|
||
# manifest, so pushing tags never updates it. Keep the copy for the two
|
||
# newest images in-repo and push it from CI, so a Hub visitor is never
|
||
# reading a page that describes a release from six months ago.
|
||
#
|
||
# Scope: aio and ml only. picpeak/{backend,frontend} still have their pages
|
||
# maintained by hand in the Hub UI — bring them under this job by adding
|
||
# .github/dockerhub/{backend,frontend}.md with the current text first,
|
||
# otherwise this would overwrite them with a near-copy.
|
||
#
|
||
# Only on `main` pushes for the canonical org repo: descriptions are
|
||
# per-repository, not per-tag, so once per merge is exactly enough.
|
||
dockerhub-descriptions:
|
||
needs: [merge-aio, merge-ml]
|
||
if: always() && github.repository == 'PicPeak/picpeak' && github.ref == 'refs/heads/main' && needs.merge-aio.result == 'success'
|
||
runs-on: ubuntu-latest
|
||
permissions:
|
||
contents: read
|
||
|
||
steps:
|
||
- name: Checkout code
|
||
uses: actions/checkout@v4
|
||
|
||
- name: Update picpeak/aio description
|
||
uses: peter-evans/dockerhub-description@v4
|
||
with:
|
||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||
repository: picpeak/aio
|
||
short-description: "picpeak all-in-one — self-hosted photo sharing + CRM in a single container (SQLite)."
|
||
readme-filepath: .github/dockerhub/aio.md
|
||
|
||
# Skipped whenever the sidecar itself was skipped (FACENET_ONNX_URL unset),
|
||
# since the Hub repository only exists once something has been pushed to it.
|
||
- name: Update picpeak/ml description
|
||
if: needs.merge-ml.result == 'success'
|
||
uses: peter-evans/dockerhub-description@v4
|
||
with:
|
||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||
repository: picpeak/ml
|
||
short-description: "picpeak face-detection sidecar — optional and stateless. Pairs with picpeak/backend."
|
||
readme-filepath: .github/dockerhub/ml.md
|
||
|
||
summary:
|
||
needs: [build-backend, merge-backend, build-frontend, merge-frontend, build-aio, merge-aio, smoke-aio, build-ml, merge-ml]
|
||
if: always()
|
||
runs-on: ubuntu-latest
|
||
permissions:
|
||
contents: read
|
||
|
||
steps:
|
||
- name: Compute image names (lowercase for GHCR)
|
||
run: |
|
||
repo_lc="${GITHUB_REPOSITORY,,}"
|
||
echo "BACKEND_IMAGE_NAME=${repo_lc}/backend" >> "$GITHUB_ENV"
|
||
echo "FRONTEND_IMAGE_NAME=${repo_lc}/frontend" >> "$GITHUB_ENV"
|
||
echo "ML_IMAGE_NAME=${repo_lc}/ml" >> "$GITHUB_ENV"
|
||
echo "AIO_IMAGE_NAME=${repo_lc}/aio" >> "$GITHUB_ENV"
|
||
# Mirror manifests to Docker Hub (picpeak/{backend,frontend}) only on the
|
||
# canonical org repo, where the DOCKERHUB_* secrets live. Forks (and any
|
||
# other owner) fall back to GHCR-only — the Docker Hub image line and login
|
||
# are gated on this flag so their builds keep working unchanged.
|
||
if [[ "$GITHUB_REPOSITORY" == "PicPeak/picpeak" ]]; then
|
||
echo "DOCKERHUB_ENABLED=true" >> "$GITHUB_ENV"
|
||
else
|
||
echo "DOCKERHUB_ENABLED=false" >> "$GITHUB_ENV"
|
||
fi
|
||
|
||
- name: Build Summary
|
||
run: |
|
||
echo "## 🐳 Docker Build Summary" >> $GITHUB_STEP_SUMMARY
|
||
echo "" >> $GITHUB_STEP_SUMMARY
|
||
|
||
if [[ "${{ needs.build-backend.result }}" == "success" ]]; then
|
||
echo "✅ **Backend build (per-arch)**: Successfully built" >> $GITHUB_STEP_SUMMARY
|
||
else
|
||
echo "❌ **Backend build (per-arch)**: ${{ needs.build-backend.result }}" >> $GITHUB_STEP_SUMMARY
|
||
fi
|
||
|
||
if [[ "${{ needs.merge-backend.result }}" == "success" ]]; then
|
||
echo "✅ **Backend manifest merge**: Successfully published" >> $GITHUB_STEP_SUMMARY
|
||
elif [[ "${{ needs.merge-backend.result }}" == "skipped" ]]; then
|
||
echo "ℹ️ **Backend manifest merge**: Skipped (verify-only build)" >> $GITHUB_STEP_SUMMARY
|
||
else
|
||
echo "❌ **Backend manifest merge**: ${{ needs.merge-backend.result }}" >> $GITHUB_STEP_SUMMARY
|
||
fi
|
||
|
||
if [[ "${{ needs.build-frontend.result }}" == "success" ]]; then
|
||
echo "✅ **Frontend build (per-arch)**: Successfully built" >> $GITHUB_STEP_SUMMARY
|
||
else
|
||
echo "❌ **Frontend build (per-arch)**: ${{ needs.build-frontend.result }}" >> $GITHUB_STEP_SUMMARY
|
||
fi
|
||
|
||
if [[ "${{ needs.merge-frontend.result }}" == "success" ]]; then
|
||
echo "✅ **Frontend manifest merge**: Successfully published" >> $GITHUB_STEP_SUMMARY
|
||
elif [[ "${{ needs.merge-frontend.result }}" == "skipped" ]]; then
|
||
echo "ℹ️ **Frontend manifest merge**: Skipped (verify-only build)" >> $GITHUB_STEP_SUMMARY
|
||
else
|
||
echo "❌ **Frontend manifest merge**: ${{ needs.merge-frontend.result }}" >> $GITHUB_STEP_SUMMARY
|
||
fi
|
||
|
||
if [[ "${{ needs.build-aio.result }}" == "success" ]]; then
|
||
echo "✅ **AIO build (per-arch)**: Successfully built" >> $GITHUB_STEP_SUMMARY
|
||
else
|
||
echo "❌ **AIO build (per-arch)**: ${{ needs.build-aio.result }}" >> $GITHUB_STEP_SUMMARY
|
||
fi
|
||
|
||
if [[ "${{ needs.merge-aio.result }}" == "success" ]]; then
|
||
echo "✅ **AIO manifest merge**: Successfully published" >> $GITHUB_STEP_SUMMARY
|
||
elif [[ "${{ needs.merge-aio.result }}" == "skipped" ]]; then
|
||
echo "ℹ️ **AIO manifest merge**: Skipped (verify-only build)" >> $GITHUB_STEP_SUMMARY
|
||
else
|
||
echo "❌ **AIO manifest merge**: ${{ needs.merge-aio.result }}" >> $GITHUB_STEP_SUMMARY
|
||
fi
|
||
|
||
if [[ "${{ needs.smoke-aio.result }}" == "success" ]]; then
|
||
echo "✅ **AIO boot smoke**: SQLite boot + SPA + caching verified" >> $GITHUB_STEP_SUMMARY
|
||
else
|
||
echo "❌ **AIO boot smoke**: ${{ needs.smoke-aio.result }}" >> $GITHUB_STEP_SUMMARY
|
||
fi
|
||
|
||
# The ML sidecar (#1074) is optional and only builds once the
|
||
# FACENET_ONNX_URL repository variable is set — "skipped" is the
|
||
# expected state, not a failure, so report it as such.
|
||
if [[ "${{ needs.build-ml.result }}" == "success" ]]; then
|
||
echo "✅ **ML sidecar build (per-arch)**: Successfully built" >> $GITHUB_STEP_SUMMARY
|
||
elif [[ "${{ needs.build-ml.result }}" == "skipped" ]]; then
|
||
echo "ℹ️ **ML sidecar build**: Skipped (FACENET_ONNX_URL repository variable not set — see ml/README.md)" >> $GITHUB_STEP_SUMMARY
|
||
else
|
||
echo "❌ **ML sidecar build (per-arch)**: ${{ needs.build-ml.result }}" >> $GITHUB_STEP_SUMMARY
|
||
fi
|
||
|
||
if [[ "${{ needs.merge-ml.result }}" == "success" ]]; then
|
||
echo "✅ **ML sidecar manifest merge**: Successfully published" >> $GITHUB_STEP_SUMMARY
|
||
elif [[ "${{ needs.merge-ml.result }}" == "skipped" ]]; then
|
||
echo "ℹ️ **ML sidecar manifest merge**: Skipped" >> $GITHUB_STEP_SUMMARY
|
||
else
|
||
echo "❌ **ML sidecar manifest merge**: ${{ needs.merge-ml.result }}" >> $GITHUB_STEP_SUMMARY
|
||
fi
|
||
|
||
echo "" >> $GITHUB_STEP_SUMMARY
|
||
echo "### 📦 Images" >> $GITHUB_STEP_SUMMARY
|
||
echo "- Backend: \`${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}\`" >> $GITHUB_STEP_SUMMARY
|
||
echo "- Frontend: \`${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE_NAME }}\`" >> $GITHUB_STEP_SUMMARY
|
||
if [[ "${{ needs.merge-ml.result }}" == "success" ]]; then
|
||
echo "- ML sidecar (optional): \`${{ env.REGISTRY }}/${{ env.ML_IMAGE_NAME }}\`" >> $GITHUB_STEP_SUMMARY
|
||
fi
|
||
echo "- All-in-one: \`${{ env.REGISTRY }}/${{ env.AIO_IMAGE_NAME }}\`" >> $GITHUB_STEP_SUMMARY
|
||
if [[ "$DOCKERHUB_ENABLED" == "true" ]]; then
|
||
echo "- Backend (Docker Hub): \`docker.io/picpeak/backend\`" >> $GITHUB_STEP_SUMMARY
|
||
echo "- Frontend (Docker Hub): \`docker.io/picpeak/frontend\`" >> $GITHUB_STEP_SUMMARY
|
||
echo "- All-in-one (Docker Hub): \`docker.io/picpeak/aio\`" >> $GITHUB_STEP_SUMMARY
|
||
if [[ "${{ needs.merge-ml.result }}" == "success" ]]; then
|
||
echo "- ML sidecar (Docker Hub): \`docker.io/picpeak/ml\`" >> $GITHUB_STEP_SUMMARY
|
||
fi
|
||
fi
|
||
|
||
echo "" >> $GITHUB_STEP_SUMMARY
|
||
echo "### 🏗️ Architectures" >> $GITHUB_STEP_SUMMARY
|
||
echo "Published manifests include both \`linux/amd64\` and \`linux/arm64\` (built natively, no QEMU)." >> $GITHUB_STEP_SUMMARY
|
||
|
||
echo "" >> $GITHUB_STEP_SUMMARY
|
||
echo "### 🏷️ Tags" >> $GITHUB_STEP_SUMMARY
|
||
echo "Images are tagged based on:" >> $GITHUB_STEP_SUMMARY
|
||
echo "- Branch name (for branch pushes)" >> $GITHUB_STEP_SUMMARY
|
||
echo "- PR number (for pull requests, when push is enabled)" >> $GITHUB_STEP_SUMMARY
|
||
echo "- Version tags (for releases)" >> $GITHUB_STEP_SUMMARY
|
||
echo "- Short SHA" >> $GITHUB_STEP_SUMMARY
|
||
echo "- \`latest\` (for main branch)" >> $GITHUB_STEP_SUMMARY
|
||
echo "- \`stable\` (for main branch and stable releases)" >> $GITHUB_STEP_SUMMARY
|
||
echo "- \`beta\` (for beta branch and pre-releases)" >> $GITHUB_STEP_SUMMARY
|