0874a30ac9
* feat(docker): add all-in-one image — backend + frontend in one container (#1042) One container, one Node process, SQLite by default: `docker run` with no compose file, no nginx, no supervisor, no bundled Postgres/Redis. - Dockerfile.aio (repo-root context): frontend build stage + backend deps stage + a runtime stage mirroring backend/Dockerfile's production stage, with the built SPA copied to /app/frontend/dist and SERVE_FRONTEND=true. DATABASE_CLIENT=sqlite3 and STORAGE_PATH=/app/storage are pinned explicitly — the storage fallback resolves to container-root /storage, which EACCESes after the su-exec drop. - server.js: the SERVE_FRONTEND block now does what the nginx image did — renders ${BRAND_TITLE}/${BRAND_DESCRIPTION} into index.html once at boot, serves that rendered shell on /index.html and every SPA route, caches hashed /assets/* immutably while the shell revalidates, and gzips the bundle via compression() mounted after all /api routers. express.static now runs with index:false so `/` keeps flowing to handlePublicSiteRequest — its default index option was shadowing the landing page on native installs. - wait-for-db.sh: skip the Postgres readiness wait when DATABASE_CLIENT is sqlite3. The engine resolver still runs, still logs, and still refuses the populated-both conflict (#1038). - .dockerignore: **/node_modules, so the root-context build can't pick up host deps from backend/ or frontend/. - docker-build.yml: build-aio / merge-aio follow the same per-arch build → digest-merge → per-version tag scheme as backend/frontend (GHCR only for now; the Docker Hub mirror is wired once the Hub repo exists), plus a smoke-aio job that boots the image on every PR and asserts /health, the SPA shell, the rendered brand title, immutable asset caching and the SQLite engine resolution. Pointing DB_HOST/DB_USER/DB_PASSWORD + DATABASE_CLIENT=pg at an external Postgres works exactly like the backend image. * fix(ci): correct three smoke-aio assertions that would fail a green image (#1042) Found by running the smoke job locally against a real build — the image passed every behavioral check, but three assertions were wrong: - `/` asserts 200, but handlePublicSiteRequest 302s to /admin/login while the public landing site is disabled, which is the state of the fresh install the smoke container always is. Assert the redirect target instead — that still proves express.static's index option is not shadowing the handler, which is the thing the check exists for. - The placeholder-leak grep matched index.html's explanatory comment, which mentions BRAND_TITLE in prose and survives into the built shell. Match the literal ${BRAND_TITLE}/${BRAND_DESCRIPTION} tokens with -F, and cover the description token too. - Add a gzip assertion, probing with GET: the compression middleware skips bodyless responses, so a HEAD probe reports no Content-Encoding even when compression is active. Verified locally on linux/arm64: image builds clean, boots to healthy in ~8s on the SQLite default, and 25/25 checks pass (SPA shell, rendered brand title, immutable+gzipped assets, no-store shell, SPA fallbacks, npm removed, su-exec drop to nodejs, no errors in the boot log). The DATABASE_CLIENT=pg override was exercised against a real Postgres too — the readiness wait still runs and the engine resolves to postgres. * fix(server): serve the SPA for every client route, not just /admin and /gallery (#1042) nginx did `try_files $uri $uri/ /index.html`, so behind compose every client-side route survived a direct hit or a refresh and the short `['/admin', '/admin/*', '/gallery/*']` list was never exercised. Without nginx that list is the whole contract, and everything outside it 404'd: /setup /customer /impressum /datenschutz /payment-check /quote/:token /contract/:token /invite/:token /transfer/:token /transfer-upload/:token /setup is the first URL a new install visits, so the all-in-one image was unusable from a cold start. The catch-all is registered after `app.use('/api', notFoundHandler)`, so an unknown /api route still answers JSON instead of being handed the HTML shell, and after the /s/:shortSlug resolver, so a typo'd short URL still 404s (#699). It is GET-only — a stray POST keeps 404ing rather than getting a 200 page back. The handler is hoisted out of the SERVE_FRONTEND block via `spaCatchAll` because that block runs before the API 404 handler is registered. Verified on the built image: all ten routes above now 200, /api/nope still returns JSON 404, /s/nonexistent still returns 404, / still 302s to /admin/login, and the smoke suite is 25/25. Both boundaries are now asserted in the smoke-aio job. * docs(readme): document the single-container install (#1042) The README had no mention of the all-in-one image, so the only way to discover it was reading the workflow file. Adds a Quick Start subsection with the one-line `docker run` and the `docker exec … cat SETUP_TOKEN` step, plus a row in the documentation table. Deliberately does not sell it as the default: the note says the compose stack is still the right choice for anything busier, gives the reason (SQLite takes one writer at a time), and points at the `.picpeak` restore as the way out, so nobody picks it and then finds themselves stuck. Full details live at docs.picpeak.app/deployment/single-container (PicPeak/docs#8). * feat(docker): fold #1067's items into the all-in-one image (#1042) Consolidating the two parallel AIO branches into this one. This PR's approach is kept wherever the two differed on design — in particular the in-process brand render, `index: false` (which fixes express.static shadowing handlePublicSiteRequest, a bug #1067 had), the compression middleware, and the smoke-aio job. What follows is what #1067 had that this branch did not. Layout — the issue asks for a single mountable root, and this moves to one: /data/db picpeak.db (+ -wal/-shm) and SETUP_TOKEN /data/storage originals, thumbnails, archives /data/logs application logs /data/backup built-in backup output; /backup symlinks here `-v picpeak:/data` and nothing else to remember. README and the smoke job's database-path assertion follow the new layout. Correctness items: - sqlite CLI. DatabaseBackupService SPAWNS `sqlite3` for `.backup` and PRAGMA integrity_check; the npm module does not ship that binary. backend/Dockerfile omits it because compose always runs Postgres — this image defaults to SQLite, so every database backup failed with ENOENT. - /backup wired in. Migrations 029 + 030 seed /backup/picpeak and /backup/database as the backup destinations; nothing created or mounted them, so backups had nowhere to write and anything written would die with the container. Symlinked into the volume, subdirectories created at startup (a bind mount hides the tree baked into the image), and adopted only when BACKUP_DIR is set so it never gates boot for compose deployments that do not mount it. - logger.js honours LOG_DIR. It hard-coded <backend>/logs, so logs could not leave the container. Unset keeps the old path for every existing install. - wait-for-db.sh derives its writable roots from STORAGE_PATH / DATA_DIR / LOG_DIR instead of hard-coded /app paths, and mkdir -p's them before chown — a bind-mounted /data hides the image's tree, and chown against a missing path reports "the filesystem rejects chown", which is both wrong and a dead end. - .dockerignore excludes backend/-prefixed runtime data. Docker reads only the root file, so the unprefixed data/*.db, logs/* and storage/* rules missed backend/data, backend/logs and backend/storage entirely; a checkout used to run PicPeak would bake its database, photos, logs and SETUP_TOKEN into a published layer. - HEALTHCHECK follows $PORT rather than a hard-coded 3000. - --max-http-header-size=32768 matches nginx's large_client_header_buffers 4 32k; Node's 16 KiB default would reject a guest carrying several per-gallery JWT cookies. docs/single-container.md is added as the in-repo reference the README links to. The smoke job gains four assertions for the above: the one-volume layout and writable backup destinations, the sqlite3 CLI, logs landing on the volume, and the image carrying no runtime data from the build context. Verified on a built image — named volume, bind mount and PORT=8080 all healthy; every existing smoke assertion still passes, including / -> 302 /admin/login, the rendered BRAND_TITLE, immutable assets, gzip and /s/<unknown> -> 404. Co-authored-by: Luca-Timo <102960244+Luca-Timo@users.noreply.github.com> * fix(docker): restore the SPA-fallback exclusions and close the build-context leak (#1042) Both found by external review of the consolidated branch. - The SPA catch-all had no backend-owned exclusions. This was a regression I introduced while merging: #1067 carried a BACKEND_OWNED prefix list, and taking this branch's server.js wholesale (correctly — its index:false and in-process brand render are the better design) dropped it. /photos, /thumbnails, /uploads and /fonts are static mounts whose middleware calls next() on a miss, so the catch-all was answering 200 text/html under image and font URLs instead of 404. nginx gave each of those its own location block, so try_files never applied to them. - backend/data is now excluded wholesale rather than by suffix. The suffix list (*.db, *.db-wal, *.db-shm, SETUP_TOKEN) let real secrets through: a used checkout carries ADMIN_CREDENTIALS.txt next to the database, plus -journal files and any DATABASE_PATH not ending in .db. Since Dockerfile.aio builds from the repository root and COPYs backend/ wholesale, any of those would be baked into a published layer. The directory holds only runtime state and is already gitignored in full. smoke-aio gains an assertion that the backend static routes still 404, so the exclusion cannot be dropped again silently. Verified on a built image: /photos, /thumbnails, /fonts and /uploads misses all 404; /setup, /impressum, /gallery/x, /admin/login still 200; / still 302s to /admin/login; /api/nope still answers JSON; /s/<unknown> still 404s; and the image carries no *.db, ADMIN_CREDENTIALS.txt, logs or storage from the context. * fix(aio): three failures that only surface outside a dev laptop (#1042) Backups aborted on SQLite. getTableChecksums() built its digest with `CAST(t.* AS TEXT)`, which is Postgres row-to-text syntax; SQLite parses `*` there as a syntax error, so every backup threw before reaching the .backup call. Since the all-in-one image ships SQLite by default, that is every AIO install. Enumerate the columns via columnInfo() and sum their lengths instead. The shared /data mount root was never adopted. wait-for-db.sh chowned the children it creates but not the mount point itself, so a host directory arriving as 0700 with a foreign owner stayed untraversable by UID 1001 after the su-exec drop. Docker Desktop's permissive bind mounts hide this completely, which is why local testing passed; a NAS share does not. DATA_ROOT is now adopted first. Maintenance mode locked the admin out of the box. The middleware runs at server.js:493, long before the static block at 891, and exempted the auth endpoints but not the page that calls them. With the backend serving the frontend, /admin/login and /assets/* returned 503 JSON, so an admin who enabled maintenance mode could never load the UI to turn it off. nginx serves those paths in the compose stack, which is why it never surfaced there. Guest and API surfaces stay gated. Verified on a built image: checksums compute across all 95 tables; a bind mount created 0700/4000:4000 boots healthy and ends up 1001:1001; with general_maintenance_mode=true, /admin/login, /admin and /assets/* return 200 while /gallery/* and /api/gallery/* return 503 — and 503 across all three once the exemption is removed again. Claude-Session: https://claude.ai/code/session_01Ra4hcsYiKuQLbbRsg6EjAc * fix(aio): stop leaking .env into the image, fix the broken checksum test (#1042) The Jest suite was red: mocking db.raw is no longer enough now that the SQLite checksum branch asks the query builder for its column list, so db(table) came back undefined and getTableChecksums failed on every PR. The production code is right; the fixture needed to know about the call. backend/.env was landing in the published layer. The root ignore file's `.env`, `.env.*` and `data/*.db` rules read as unanchored but Docker matches them from the context root, so they catch ./.env and never backend/.env — and `COPY backend/ .` then puts a real JWT_SECRET at /app/.env. Matched at any depth instead, the way **/node_modules in the same file already is. Confirmed by building from a checkout carrying a planted secret: before, `cat /app/.env` printed it back. Business documents wrote outside the volume. quoteService, invoice sending/reminders and contract signatures build paths from process.cwd()/storage and never read STORAGE_PATH; compose hides it by setting STORAGE_PATH=/app/storage with WORKDIR /app so the two are the same directory. Here they are not, and /app is root-owned, so a quote or invoice PDF failed to write as UID 1001 — and would not survive the container if it had. Symlinked /app/storage into the volume, matching the /backup symlink beside it. Teaching those services STORAGE_PATH is the real fix and wants its own change. Two smaller ones: the mount root is now chowned shallow rather than recursively, since every child below it is already walked recursively and a NAS-sized photo library should not be traversed twice on each restart; and /assets/ joins the backend-owned prefixes, so a stale hashed chunk requested by a tab left open across an upgrade gets a 404 instead of index.html served with 200 under a .js URL. Verified on a built image: planted backend/.env and backend/probe.db are absent; /app/storage resolves to /data/storage and a business-doc write as UID 1001 appears on the host; a 0700 bind mount owned by 4000:4000 boots healthy; a missing /assets chunk 404s while the real bundle still serves 200 as application/javascript. The databaseBackup suite is green again, and the branch adds no failing suite that origin/main does not already fail on the same machine. Claude-Session: https://claude.ai/code/session_01Ra4hcsYiKuQLbbRsg6EjAc * test(aio): teach the leak assertion about the storage symlink (#1042) The previous check listed /app/storage/events and treated a hit as a leak. That was true while /app/storage was either absent or a copied directory; now it is a symlink into the volume, so the check followed it and found the empty tree the image itself creates — a false positive on its own design. Check the shape instead: /app/storage must be a symlink pointing at /data/storage, and the volume's photo tree must contain no files on a fresh install. A real directory there now fails loudly, which is the condition the assertion was always trying to catch. Also extended the path list to /app/.env and loose database files, matching the .dockerignore rules added alongside. Claude-Session: https://claude.ai/code/session_01Ra4hcsYiKuQLbbRsg6EjAc * fix(aio): show the maintenance screen instead of raw JSON to guests (#1042) The previous commit exempted the admin shell so an admin could still reach the switch they had just flipped. Guests had the same problem for the same reason: with no nginx in front, /gallery/<slug> reaches this middleware long before the static block, so a visitor during maintenance got a 503 JSON body where every other deployment shows the branded maintenance screen the frontend already ships. Replaced the two path-specific exemptions with the rule they were both special cases of: a GET that is not an API call and not a backend-owned content mount is the SPA shell, and the shell is inert HTML — it boots, reads /api/public/settings (already exempt) and renders MaintenanceMode on its own. Everything that carries real data stays gated: /api/*, /photos/, /thumbnails/, /fonts/, and any non-GET. Compose is untouched by construction, since nginx answers those paths and they never arrive here. Verified on a built image with the flag on: /gallery/x, /customer/x, /admin and /admin/login return 200 text/html while /api/gallery/x/verify, /photos/x.jpg and /thumbnails/x.jpg return 503 and a POST to a public API still returns 503; with the flag off the same paths go back to 404. Added a middleware test over that exemption matrix — over-exemption is the real risk in this change, so it asserts the gated half too. It fails on five cases without the fix. Claude-Session: https://claude.ai/code/session_01Ra4hcsYiKuQLbbRsg6EjAc * fix(aio): stop the shell exemption from un-gating /og and the public CMS (#1042) The previous commit exempted "any GET that is not an API call". That negative rule reads as safe and is not: /og/gallery/<slug> and its /cover render the event name and the hero thumbnail, /s/<code> renders short-link previews, and `/` is handed to the public CMS. All four are proxy_passed to the backend by nginx, so they were gated before this PR in every deployment — the rule un-gated them, and for compose too, not just the new image. A site switched to maintenance would have kept publishing gallery metadata. Replaced the guess with the split nginx already defines: exempt what the frontend container answers itself, gate what it proxies. That is the same rule the all-in-one image needs by definition, since its whole job is to be both halves of that stack, and it now matches compose in both directions rather than only in the direction the last commit tested. Verified on a built image with the flag on: /admin/login, /gallery/<slug> and /customer/* return 200, while /, /og/gallery/x, /og/gallery/x/cover, /s/abc, /robots.txt, /api/* and /photos/* return 503; with the flag off all of them behave normally again. The middleware test grew the gated cases — it now covers 21, most of them asserting what must NOT be exempt. Claude-Session: https://claude.ai/code/session_01Ra4hcsYiKuQLbbRsg6EjAc * fix(aio): give the image a FRONTEND_URL default so share links are absolute (#1042) getFrontendBaseUrl() reads FRONTEND_URL, falls back to the general_site_url setting, and otherwise returns an empty string — which makes share_url come back as a bare "/gallery/<slug>/<token>". Compose defaults the variable to http://localhost:3000, but the documented one-liner for this image passes only JWT_SECRET, so every fresh single-container install handed out relative links in API responses, QR codes and emails. Defaulted to the same value compose uses; -e FRONTEND_URL=https://... overrides it, as does the site URL field in Settings. Found by pointing tests/e2e/local at a running AIO container: auth/06-api-tokens asserts share_url matches /^https?:\/\//, and it was the one spec that failed for a product reason rather than a harness one. It passes now, and the suite is 19/20 against the image — the remaining failure is smoke/02-auth-flow, whose seed helper shells out to a hard-coded `docker exec picpeak-backend`, so it cannot arrange its precondition against any other container. Claude-Session: https://claude.ai/code/session_01Ra4hcsYiKuQLbbRsg6EjAc * feat(aio): mark the image so face recognition stays off (#1042, #1074) Face recognition needs a separate ML container this image does not contain, and enabling it here would add a second image-processing pipeline competing with Sharp for the CPU and memory of a container sized for one photographer plus guests browsing. The failure mode would not be a clear error — just a slow install that looks broken. The backend gate for this lands in #1075 and keys on PICPEAK_SINGLE_CONTAINER. Without this line the guard never triggers on an actual all-in-one build, so the two changes have to arrive together: whichever merges second completes the pair. Verified against this file's exact value — isFeatureEnabled() returns false with it set. An explicit marker rather than inferring from SERVE_FRONTEND or the SQLite path, because legitimate multi-container deployments do both of those and should keep the feature. Also adds it to the Limits section of docs/single-container.md, next to the SQLite and Redis constraints, since that is where someone will look before choosing this image. --------- Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local> Co-authored-by: the-luap <paul-nothaft@hotmail.de>
1083 lines
49 KiB
YAML
1083 lines
49 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'
|
||
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` 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'
|
||
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` 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'
|
||
timeout: '10m'
|
||
|
||
- name: Upload Trivy scan results to GitHub Security tab
|
||
if: steps.push-decision.outputs.push == 'true'
|
||
uses: github/codeql-action/upload-sarif@v4
|
||
with:
|
||
sarif_file: 'trivy-aio-${{ env.PLATFORM_PAIR }}.sarif'
|
||
category: 'aio-vulnerabilities-${{ env.PLATFORM_PAIR }}'
|
||
|
||
merge-aio:
|
||
needs: build-aio
|
||
runs-on: ubuntu-latest
|
||
permissions:
|
||
contents: read
|
||
packages: write
|
||
if: github.event_name != 'pull_request' || github.event.inputs.push == 'true'
|
||
|
||
steps:
|
||
- name: Compute image name (lowercase for GHCR)
|
||
run: |
|
||
repo_lc="${GITHUB_REPOSITORY,,}"
|
||
echo "AIO_IMAGE_NAME=${repo_lc}/aio" >> "$GITHUB_ENV"
|
||
|
||
- name: Download digest artifacts
|
||
uses: actions/download-artifact@v4
|
||
with:
|
||
path: /tmp/digests
|
||
pattern: digests-aio-*
|
||
merge-multiple: true
|
||
|
||
- name: Set up Docker Buildx
|
||
uses: docker/setup-buildx-action@v3
|
||
|
||
- name: Log in to Container Registry
|
||
id: login-ghcr
|
||
continue-on-error: true
|
||
uses: docker/login-action@v3
|
||
with:
|
||
registry: ${{ env.REGISTRY }}
|
||
username: ${{ github.actor }}
|
||
password: ${{ secrets.GITHUB_TOKEN }}
|
||
|
||
- name: Determine build context
|
||
id: context
|
||
run: |
|
||
if [[ "${{ github.ref }}" == refs/tags/v*-beta* ]] || [[ "${{ github.ref }}" == refs/heads/main ]]; then
|
||
echo "channel=beta" >> $GITHUB_OUTPUT
|
||
echo "is_prerelease=true" >> $GITHUB_OUTPUT
|
||
else
|
||
echo "channel=stable" >> $GITHUB_OUTPUT
|
||
echo "is_prerelease=false" >> $GITHUB_OUTPUT
|
||
fi
|
||
|
||
# Same per-version tag scheme as backend/frontend: every Release Please
|
||
# version publishes a matching aio image. GHCR-only for now — the Docker
|
||
# Hub mirror (docker.io/picpeak/aio) is wired later once the Hub repo
|
||
# exists: add the images line + Docker Hub login exactly like
|
||
# merge-backend (#1042).
|
||
- name: Extract metadata for AIO
|
||
id: meta-aio
|
||
uses: docker/metadata-action@v5
|
||
with:
|
||
images: ${{ env.REGISTRY }}/${{ env.AIO_IMAGE_NAME }}
|
||
labels: |
|
||
org.opencontainers.image.title=PicPeak All-in-one
|
||
org.opencontainers.image.description=PicPeak backend + frontend in a single container (SQLite default)
|
||
org.opencontainers.image.vendor=PicPeak
|
||
maintainer=${{ github.repository_owner }}
|
||
tags: |
|
||
type=ref,event=branch
|
||
type=ref,event=pr
|
||
type=semver,pattern={{version}}
|
||
type=semver,pattern={{major}}.{{minor}},enable=${{ steps.context.outputs.is_prerelease == 'false' }}
|
||
type=semver,pattern={{major}},enable=${{ steps.context.outputs.is_prerelease == 'false' }}
|
||
# #668/#783: publish the git-tag name verbatim, same as backend/frontend.
|
||
type=ref,event=tag
|
||
type=sha,format=short
|
||
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/stable' || (startsWith(github.ref, 'refs/tags/v') && steps.context.outputs.is_prerelease == 'false') }}
|
||
type=raw,value=stable,enable=${{ github.ref == 'refs/heads/stable' || (startsWith(github.ref, 'refs/tags/v') && steps.context.outputs.is_prerelease == 'false') }}
|
||
|
||
- name: Create and push multi-arch manifest
|
||
working-directory: /tmp/digests
|
||
run: |
|
||
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||
$(printf "${{ env.REGISTRY }}/${{ env.AIO_IMAGE_NAME }}@sha256:%s " *)
|
||
|
||
- name: Inspect manifest (GHCR)
|
||
run: |
|
||
docker buildx imagetools inspect ${{ env.REGISTRY }}/${{ env.AIO_IMAGE_NAME }}:${{ steps.meta-aio.outputs.version }}
|
||
|
||
# Boot-level verification of the AIO image on every PR: build for the
|
||
# runner's arch, run it with no DB env (SQLite default), and assert the
|
||
# things nginx used to guarantee — SPA shell with the brand title rendered,
|
||
# immutable asset caching, /health green, and the resolver landing on
|
||
# SQLite. Mirrors the install-smoke workflow's build pattern.
|
||
smoke-aio:
|
||
runs-on: ubuntu-latest
|
||
timeout-minutes: 20
|
||
permissions:
|
||
contents: read
|
||
|
||
steps:
|
||
- name: Checkout code
|
||
uses: actions/checkout@v4
|
||
|
||
- name: Set up Docker Buildx
|
||
uses: docker/setup-buildx-action@v3
|
||
|
||
- name: Build AIO image (single arch)
|
||
uses: docker/build-push-action@v5
|
||
with:
|
||
context: .
|
||
file: ./Dockerfile.aio
|
||
load: true
|
||
tags: picpeak-aio:smoke
|
||
cache-from: type=gha,scope=aio-linux-amd64
|
||
cache-to: type=gha,mode=max,scope=aio-linux-amd64,ignore-error=true
|
||
|
||
- name: Boot container (SQLite default, no volumes)
|
||
run: |
|
||
docker run -d --name aio -p 3000:3000 \
|
||
-e JWT_SECRET=smoke-test-secret-at-least-32-characters-long \
|
||
-e BRAND_TITLE="AIO Smoke" \
|
||
picpeak-aio:smoke
|
||
|
||
- name: Wait for /health
|
||
run: |
|
||
for i in $(seq 1 60); do
|
||
if curl -fsS http://localhost:3000/health > /dev/null 2>&1; then
|
||
echo "healthy after ~$((i*2))s"; exit 0
|
||
fi
|
||
sleep 2
|
||
done
|
||
echo "::error::/health never came up"; docker logs aio | tail -100; exit 1
|
||
|
||
- name: Assert engine resolved to SQLite
|
||
run: |
|
||
docker exec aio ls -la /data/db/picpeak.db
|
||
docker logs aio 2>&1 | grep -i "sqlite" | head -5
|
||
|
||
- name: Assert SPA shell served with rendered brand title
|
||
run: |
|
||
body=$(curl -fsS http://localhost:3000/admin)
|
||
echo "$body" | grep -q '<div id="root">' || { echo "::error::/admin did not serve the SPA shell"; exit 1; }
|
||
echo "$body" | grep -q '<title>AIO Smoke</title>' || { echo "::error::BRAND_TITLE was not rendered into index.html"; exit 1; }
|
||
# -F on the literal token: index.html's explanatory comment mentions
|
||
# BRAND_TITLE in prose and Vite keeps that comment in the built shell,
|
||
# so a bare `grep BRAND_TITLE` always matches. Only an unsubstituted
|
||
# ${BRAND_TITLE}/${BRAND_DESCRIPTION} is a real leak.
|
||
for tok in '${BRAND_TITLE}' '${BRAND_DESCRIPTION}'; do
|
||
echo "$body" | grep -qF "$tok" && { echo "::error::unrendered placeholder $tok leaked"; exit 1; } || true
|
||
done
|
||
|
||
- name: Assert hashed assets are cached immutably
|
||
run: |
|
||
asset=$(curl -fsS http://localhost:3000/admin | grep -oE '/assets/[^"]+\.js' | head -1)
|
||
test -n "$asset" || { echo "::error::no asset reference found in SPA shell"; exit 1; }
|
||
headers=$(curl -fsSI "http://localhost:3000${asset}")
|
||
echo "$headers" | grep -qi 'cache-control:.*immutable' || { echo "::error::asset served without immutable cache header"; echo "$headers"; exit 1; }
|
||
|
||
- name: Assert API and root respond
|
||
run: |
|
||
curl -fsS http://localhost:3000/api/public/settings > /dev/null
|
||
# `/` goes to handlePublicSiteRequest, which 302s to /admin/login while
|
||
# the public landing site is disabled — the state of a fresh install,
|
||
# which is exactly what this container is. Assert the redirect target
|
||
# rather than a 200, so the check still proves express.static's index
|
||
# option isn't shadowing the handler.
|
||
code=$(curl -s -o /dev/null -w '%{http_code}' http://localhost:3000/)
|
||
loc=$(curl -s -o /dev/null -w '%{redirect_url}' http://localhost:3000/)
|
||
[[ "$code" == "302" && "$loc" == *"/admin/login" ]] \
|
||
|| { echo "::error::/ returned $code (Location: ${loc:-none}); expected 302 -> /admin/login"; exit 1; }
|
||
|
||
- name: Assert every client route survives a direct hit
|
||
run: |
|
||
# nginx did `try_files $uri $uri/ /index.html`, so behind compose these
|
||
# always worked and nothing caught their absence here. /setup is the
|
||
# first URL a new install visits.
|
||
for r in /setup /customer /impressum /datenschutz /payment-check \
|
||
/quote/x /contract/x /invite/x /transfer/x /transfer-upload/x; do
|
||
code=$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:3000${r}")
|
||
[[ "$code" == "200" ]] || { echo "::error::${r} returned $code, expected the SPA shell"; exit 1; }
|
||
done
|
||
|
||
- name: Assert the catch-all did not swallow the API or the short-URL resolver
|
||
run: |
|
||
# The SPA catch-all is registered after the /api 404 handler, so an
|
||
# unknown API route must still answer JSON rather than the HTML shell.
|
||
body=$(curl -s "http://localhost:3000/api/nope")
|
||
grep -q '<div id="root">' <<< "$body" && { echo "::error::unknown /api route served the SPA shell"; exit 1; } || true
|
||
grep -q '"error"' <<< "$body" || { echo "::error::unknown /api route did not answer JSON: $body"; exit 1; }
|
||
# A typo'd short URL must still 404 rather than render the shell (#699).
|
||
code=$(curl -s -o /dev/null -w '%{http_code}' http://localhost:3000/s/nonexistent)
|
||
[[ "$code" == "404" ]] || { echo "::error::/s/<unknown> returned $code, expected 404"; exit 1; }
|
||
|
||
- name: Assert the SPA bundle is gzipped
|
||
run: |
|
||
asset=$(curl -fsS http://localhost:3000/admin | grep -oE '/assets/[^"]+\.js' | head -1)
|
||
# GET, not HEAD: the compression middleware skips bodyless responses,
|
||
# so a HEAD probe reports no Content-Encoding even when gzip is active.
|
||
enc=$(curl -s -o /dev/null -D - -H 'Accept-Encoding: gzip' "http://localhost:3000${asset}" | grep -i '^content-encoding:')
|
||
grep -qi gzip <<< "$enc" || { echo "::error::asset served uncompressed (compression middleware inactive?)"; exit 1; }
|
||
|
||
- name: Assert the one-volume layout and backup destinations
|
||
run: |
|
||
# #1042 asks for a single mountable root. Everything that must survive a
|
||
# container replacement lives under /data, and /backup — where migrations
|
||
# 029/030 seed the backup destinations — symlinks into it rather than
|
||
# dangling inside the container.
|
||
docker exec aio sh -c 'test -L /backup' || { echo "::error::/backup is not a symlink into the volume"; exit 1; }
|
||
for d in /data/db /data/storage /data/logs /data/backup/picpeak /data/backup/database; do
|
||
docker exec aio sh -c "test -d $d" || { echo "::error::$d missing from the volume layout"; exit 1; }
|
||
done
|
||
docker exec aio sh -c 'touch /backup/database/.w && rm /backup/database/.w' \
|
||
|| { echo "::error::/backup/database is not writable by the app user"; exit 1; }
|
||
|
||
- name: Assert the sqlite3 CLI the backup service shells out to
|
||
run: |
|
||
# DatabaseBackupService spawns `sqlite3` for .backup and integrity_check;
|
||
# the npm module does not ship the binary.
|
||
docker exec aio sqlite3 --version > /dev/null \
|
||
|| { echo "::error::sqlite3 CLI missing — database backups would fail with ENOENT"; exit 1; }
|
||
|
||
- name: Assert logs land on the volume
|
||
run: |
|
||
docker exec aio sh -c 'ls /data/logs/*.log > /dev/null 2>&1' \
|
||
|| { echo "::error::logs are not being written under /data (LOG_DIR ignored?)"; exit 1; }
|
||
|
||
- name: Assert backend static routes still 404 instead of the SPA shell
|
||
run: |
|
||
# /photos, /thumbnails, /uploads and /fonts are backend-owned mounts whose
|
||
# middleware calls next() on a miss. nginx gave them their own location
|
||
# blocks so try_files never applied; without an explicit exclusion the
|
||
# catch-all answers 200 text/html under an image or font URL.
|
||
for r in /photos/missing.jpg /thumbnails/missing.jpg /fonts/missing.woff2; do
|
||
code=$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:3000${r}")
|
||
[[ "$code" != "200" ]] || { echo "::error::${r} returned 200 — the SPA catch-all swallowed a backend 404"; exit 1; }
|
||
done
|
||
|
||
- name: Assert the image carries no runtime data from the build context
|
||
run: |
|
||
# Dockerfile.aio builds from the repo root; a checkout used to run
|
||
# PicPeak must never bake its database, photos, logs or secrets into a
|
||
# layer. /app/storage is deliberately a symlink into the volume, so it
|
||
# is checked by shape rather than by listing it — following the link
|
||
# would only find the empty tree the image creates at /data/storage.
|
||
for leak in '/app/data/*.db' '/app/logs/*' '/app/.env' '/app/*.db' '/app/*.sqlite*'; do
|
||
if docker exec aio sh -c "ls $leak > /dev/null 2>&1"; then
|
||
echo "::error::build context leaked $leak into the image"; exit 1
|
||
fi
|
||
done
|
||
docker exec aio sh -c 'test -L /app/storage' \
|
||
|| { echo "::error::/app/storage is a real directory — the build context leaked it in"; exit 1; }
|
||
test "$(docker exec aio sh -c 'readlink /app/storage')" = /data/storage \
|
||
|| { echo "::error::/app/storage does not point into the mounted volume"; exit 1; }
|
||
# The volume's photo tree must start empty on a fresh install.
|
||
found=$(docker exec aio sh -c 'find /data/storage/events -type f | head -1')
|
||
test -z "$found" || { echo "::error::build context leaked photos into /data/storage/events: $found"; exit 1; }
|
||
|
||
- name: Dump logs on failure
|
||
if: failure()
|
||
run: docker logs aio 2>&1 | tail -200
|
||
|
||
summary:
|
||
needs: [build-backend, merge-backend, build-frontend, merge-frontend, build-aio, merge-aio, smoke-aio]
|
||
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 "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
|
||
|
||
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
|
||
echo "- All-in-one: \`${{ env.REGISTRY }}/${{ env.AIO_IMAGE_NAME }}\` (GHCR only — Docker Hub mirror pending)" >> $GITHUB_STEP_SUMMARY
|
||
if [[ "$DOCKERHUB_ENABLED" == "true" ]]; then
|
||
echo "- Backend (Docker Hub): \`docker.io/picpeak/backend\`" >> $GITHUB_STEP_SUMMARY
|
||
echo "- Frontend (Docker Hub): \`docker.io/picpeak/frontend\`" >> $GITHUB_STEP_SUMMARY
|
||
fi
|
||
|
||
echo "" >> $GITHUB_STEP_SUMMARY
|
||
echo "### 🏗️ Architectures" >> $GITHUB_STEP_SUMMARY
|
||
echo "Published manifests include both \`linux/amd64\` and \`linux/arm64\` (built natively, no QEMU)." >> $GITHUB_STEP_SUMMARY
|
||
|
||
echo "" >> $GITHUB_STEP_SUMMARY
|
||
echo "### 🏷️ Tags" >> $GITHUB_STEP_SUMMARY
|
||
echo "Images are tagged based on:" >> $GITHUB_STEP_SUMMARY
|
||
echo "- Branch name (for branch pushes)" >> $GITHUB_STEP_SUMMARY
|
||
echo "- PR number (for pull requests, when push is enabled)" >> $GITHUB_STEP_SUMMARY
|
||
echo "- Version tags (for releases)" >> $GITHUB_STEP_SUMMARY
|
||
echo "- Short SHA" >> $GITHUB_STEP_SUMMARY
|
||
echo "- \`latest\` (for main branch)" >> $GITHUB_STEP_SUMMARY
|
||
echo "- \`stable\` (for main branch and stable releases)" >> $GITHUB_STEP_SUMMARY
|
||
echo "- \`beta\` (for beta branch and pre-releases)" >> $GITHUB_STEP_SUMMARY
|