Skip to content

M0: image-as-product foundation for self-hosted server deploys#9

Open
bootuz wants to merge 11 commits into
mainfrom
feat/m0-image-foundation
Open

M0: image-as-product foundation for self-hosted server deploys#9
bootuz wants to merge 11 commits into
mainfrom
feat/m0-image-foundation

Conversation

@bootuz
Copy link
Copy Markdown
Owner

@bootuz bootuz commented May 24, 2026

What this is

The first milestone of the in-Mac "Deploy to a server…" feature.

M0 establishes the dual-mode binary (existing local mode unchanged + new server mode), the publishable Docker image, the env-var contract, the database abstraction, the encryption-at-rest primitives, the supply-chain pipeline (GHCR + cosign + SLSA-3), 13 reference deploy manifests across 5 platforms, CI gates, and the operator-facing docs.

All purely additive — the existing macOS solo path is byte-identical to before this PR.

Eleven sub-tasks, eleven commits

Commit
M0.1 env-var manifest as single source of truth
M0.2 ImageMetadata for per-binary identity (version/SHA/buildDate)
M0.3 CI guard against raw Environment.get outside the manifest
M0.4 wire configure.swift through the manifest, exception list now empty
M0.5 DatabaseProvider abstraction — SQLite + Postgres, picked by env
M0.6 SecretBox (AES-GCM) + EncryptionKeyResolver (mode-aware)
M0.7 multi-stage Dockerfile, non-root, slim runtime, builds clean on swift:6.1
M0.8 GHCR release workflow with cosign + SLSA-3 provenance
M0.9 reference deploy manifests for 5 deploy targets
M0.10 image-size CI gate + boot smoke test
M0.11 deploy + architecture docs, README 'Deploy for your team' block

Each commit is self-contained, has tests where applicable, and a substantive message explaining the why. Reviewable commit-by-commit if you'd rather.

What works today

  • swift test: 111/111 tests across 23 suites
  • ./scripts/check-env-manifest.sh: clean (1 read inside the manifest, 0 exceptions)
  • make docker-build: 162 MB arm64 image (under the 200 MB ceiling, slightly over the 150 MB target)
  • make docker-smoke: image boots, /health returns {"status":"ok"}, tears down cleanly
  • ✓ All internal Markdown links resolve

What's deferred to M1+

  • Auth/Session/Invite models + AuthController + RoleMiddleware. M0 builds the primitives; M1 builds the features that use them.
  • Wiring SecretBox to actually encrypt existing ASC .p8 + ASA secret columns — the M0.6 primitive is in place; M1 adds the migration that re-encrypts existing rows.
  • Postgres integration tests in CI. The DatabaseProvider routes correctly (8 unit tests cover that), but full integration coverage against a live PG instance lands when M1's auth tests need it.
  • First GHCR-published image. release-image.yml is committed but has never run. First image-v* tag fires it; or gh workflow run release-image.yml --ref main produces a dryrun-<sha> tag.
  • Multi-arch arm64 cross-build via QEMU. release-image.yml declares both arches but neither M0.7 (native arm64) nor M0.10 (amd64 only in CI) has validated the cross-build end-to-end.

How to verify locally

# Swift backend
swift test                    # full 111-test suite
./scripts/check-env-manifest.sh

# Docker image
make docker-build             # builds keywordista:dev (162 MB on arm64)
make docker-smoke             # build + run + GET /health + tear down

# Deploy artifacts (structural sanity)
ls deploy/                    # 13 files across 5 platforms

Notable design decisions worth a look

Decision Where Why
Env-var manifest as the only place that reads Environment.get Sources/App/Config/EnvVarManifest.swift Type-safe accessors, mode-conditional defaults, single audit surface, CI-enforced
Mode-conditional defaults as @Sendable (RuntimeMode) -> Value? closures EnvVarManifest contract block logFormat defaults JSON in server, text in local — one declaration, no branching at call sites
SecretBox uses AES-GCM via existing swift-crypto dep Sources/App/Crypto/ No new dependency. Sealed format = nonce + cipher + tag (Apple's combined).
Local-mode encryption key derived from IOPlatformUUID EncryptionKeyResolver "Don't carry your secrets to someone else's Mac" — security property by construction
Three independent SemVer streams: app-v* / service-v* / image-v* .github/workflows/release-image.yml Mac DMG, menubar service .zip, and server image evolve on different cadences
Single instance per deployment enforced at every manifest layer deploy/* K8s Recreate strategy, Fly min/max=1, Nomad count=1 — SQLite + iTunes throttling make HA the wrong answer
Image-as-product surface, frozen contract docs/architecture/image-contract.md Anyone can deploy without our cockpit — K8s, Nomad, Coolify, Dokku, raw Docker. Acquisition-ready posture.

Backwards-compat

Existing macOS local-mode behavior is byte-identical. The menubar app's ServiceSupervisor still passes --hostname 127.0.0.1 --port <N> as CLI flags; the manifest's mode-conditional defaults only kick in when those flags are absent (i.e. in Docker). Full Swift test suite (which boots a real Application for many tests) passes 111/111 with zero modifications.

What CI will check on this PR (first run)

  • env-manifest (new from M0.3) — fails if anyone bypasses the manifest
  • server (existing) — Swift build + test on macos-15
  • spa (existing) — Svelte type-check + build
  • mac-app (existing) — menubar app builds
  • image-size (new from M0.10) — Docker build + size ≤200 MB + boot smoke

If anything fails on real CI infrastructure that passed locally, that's surface for follow-up before merging.

Next milestone preview

M1 turns the primitives this PR established into actual features:

  • User, Session, Invite models + migrations (creatorUserId added to existing models)
  • AuthController — login, logout, setup-wizard endpoint, accept-invite
  • AuthMiddleware + RoleMiddleware (admin vs member)
  • Encryption-at-rest migration that finally uses M0.6's SecretBox
  • Backend-only — no frontend changes, no menubar changes (M2 handles those)

Backend-only in M1 means we can ship + verify it independently; M2's frontend can then assume the backend contract.

bootuz added 11 commits May 24, 2026 17:23
Adds Sources/App/Config/EnvVarManifest.swift — the §4.6.3 contract
from the deploy plan, expressed as 23 typed EnvVar<T> declarations
in one file. The manifest is the *only* place that reads env vars
in production code (M0.3 CI script will enforce this); call-sites
use typed accessors: `manifest.require(EnvVars.port)` returns Int.

Highlights:
  - Mode-conditional defaults expressed as @sendable (RuntimeMode)
    -> Value? closures, so 'log format defaults to JSON in server,
    text in local' is one declaration.
  - require/optional split: require<T> -> T for vars that always
    have a value, optional<T> -> T? for legitimately-absent vars.
  - Manifest.bootstrap() is the one-shot boot validator: reads
    KEYWORDISTA_MODE, walks every spec, surfaces missing-required
    or parse-failed errors before any other init runs.
  - Renders --help and /api/v1/version/env from the same single
    declaration. Both formats share canonical ordering.
  - ManifestEnv injectable seam: tests fixture in-memory dicts;
    production reads via Vapor's Environment.get. Manifest file is
    the only place allowed to touch Environment.get directly.

No behavioral changes to existing code in this commit — wiring the
two existing Environment.get sites (KEYWORDISTA_PUBLIC_DIR,
DATABASE_PATH) through the manifest happens in M0.4 alongside
RuntimeMode plumbing.

Tests: 32 swift-testing cases covering all parsers, mode-conditional
defaults, contract integrity (count, naming convention, secret set),
and every bootstrap failure mode. Full suite passes in 0.002s.
Adds Sources/App/Config/ImageMetadata.swift — read at /health,
/api/v1/version, and the --version CLI flag. Values are baked in at
build time via the KEYWORDISTA_BUILD_* env vars (set by ENV
directives in the Dockerfile from M0.7) and cached at first access
so they're immutable for the binary's lifetime.

Deliberately separate from EnvVarManifest (§4.6.3) because these
identify the binary, not configure its runtime. Read via
ProcessInfo.processInfo.environment (not Vapor's Environment.get) so
the M0.3 CI grep-ban targeting operator vars stays clean.

Fallback values are 'dev' / 'unknown' / 'unknown' rather than e.g.
Date() — an honest 'unknown' beats a misleading boot-date that looks
like a real build-date. RemoteUpdateChecker (M5) compares these
across instances to detect drift.

Snapshot is Codable (not just Encodable) so the cockpit can
deserialize remote /api/v1/version responses cleanly. Field names
version/commitSHA/buildDate are pinned by the M0.2 contract; renaming
requires a major-version bump per §4.6.5.

Tests: 5 swift-testing cases — fallback shape, summary format,
Snapshot round-trip, JSON field-name pinning, value-type independence
from the static accessors. All pass.
Adds scripts/check-env-manifest.sh — a portable bash + grep guard
that fails CI if any new Sources/App/*.swift file reads an env var
via Vapor's Environment.get(...) instead of going through
EnvVarManifest's typed accessors.

The script's EXCEPTIONS list grants a temporary pass to
configure.swift (the two pre-existing reads M0.1 deliberately left
in place). M0.4's RuntimeMode plumbing migrates those reads and
removes the exception line. Any OTHER file adding a raw
Environment.get fails the guard immediately — protecting against
drift the moment this lands.

Wires into .github/workflows/ci.yml as a new 'env-manifest' job on
ubuntu-latest. Pure bash, no Swift toolchain, runs in parallel with
the Swift jobs so signal fans out within ~30s of a push.

Sanity-checked locally:
  • Current tree: passes (configure.swift exception honored).
  • Synthetic Sources/App/Services/TmpForbidden.swift with raw
    Environment.get: correctly flagged with file:line + remediation
    text pointing at the manifest.

Once M0.4 lands and the exception list is empty, the guard's job
description becomes its full job description: 'the manifest is the
only place that reads env vars.'
…mpty

The first behavioral change in M0:

  • configure.swift reads via Manifest.bootstrap() at the top —
    missing-required-var failures surface here with a clear message
    BEFORE migrations / queues / routes init.
  • Bind hostname + port from the manifest. Mode-conditional defaults
    handle 0.0.0.0 (server) vs 127.0.0.1 (local) without branching at
    the call site. The menubar app's existing '--hostname 127.0.0.1
    --port <N>' CLI args still take precedence (Vapor's CLI overrides
    these defaults), so the existing local-spawn path is unchanged.
  • The two existing Environment.get reads (KEYWORDISTA_PUBLIC_DIR,
    DATABASE_PATH) now go through manifest.optional / manifest.require.
  • databasePath gains a mode-conditional default: 'db.sqlite'
    cwd-relative in local (dev-friendly), '/data/db.sqlite' in server
    (matches Docker's VOLUME mount from M0.7).
  • scripts/check-env-manifest.sh's TEMPORARY_EXCEPTIONS list is now
    empty — the manifest is the only place that reads env vars.
    Fixed a set -u edge case where expanding an empty array tripped
    bash strict mode; guarded both loops.

Regression: full test suite passes (91/91 across 18 suites), zero
new tests required — M0.1's manifest tests cover the manifest, and
configure.swift's behavior under each mode is exercised end-to-end
by the existing service tests that boot a real Application.

Note: no /api/v1/version endpoint yet (deferred to a small follow-up
if you want it). M0.5's DatabaseProvider abstraction is next: it
keeps require(EnvVars.databasePath) for SQLite, and adds a new
DATABASE_URL branch for Postgres.
Per plan §4.10: operator picks database at deploy time. Default is
SQLite (zero-config, mounted volume); Postgres opt-in by setting
DATABASE_URL=postgres://... (or postgresql://). Both drivers ship
in every build, so the choice is purely runtime.

Architecture:

  • Adds fluent-postgres-driver 2.12.0 (pulls postgres-kit 2.15.1,
    postgres-nio 1.33.0). One new SPM dep, one new product in the
    App target.
  • New enum Sources/App/Composition/DatabaseProvider.swift:
      - .sqlite(path:) / .postgres(url:)
      - resolve(from:env:) reads DATABASE_URL+DATABASE_PATH from the
        manifest; routes per §4.10.
      - register(on:) wires the right Fluent driver.
      - applyDriverSpecificTuning(on:) encapsulates the SQLite
        PRAGMAs (WAL + busy_timeout=5000). No-op for Postgres.
      - displayName: 'sqlite' or 'postgres' for logs and /health —
        never the connection string (DATABASE_URL is secret).
  • configure.swift now reads:
      let database = try DatabaseProvider.resolve(from: manifest)
      try database.register(on: app)
      try await database.applyDriverSpecificTuning(on: app)
    Replaces the inline 'app.databases.use(.sqlite(.file(dbPath)))' +
    'PRAGMA journal_mode=WAL' block. Drops the now-unused
    FluentSQLiteDriver import.

Design notes:

  • resolve(from:env:) takes the same ManifestEnv fixture seam as
    the manifest itself — so tests fixture env through both layers
    via one parameter. No production-vs-test logic divergence.
  • Local mode (KEYWORDISTA_MODE=local, menubar spawn) intentionally
    always resolves to SQLite. Postgres is server-mode-only by
    design — there's no reason to add a Postgres dependency for
    the solo Mac path.
  • Non-postgres DATABASE_URL schemes (e.g. mysql://, sqlite:///,
    garbage) fall back to SQLite silently rather than erroring.
    Defensive: a user pasting 'sqlite:///data/db.sqlite' shouldn't
    accidentally trigger Postgres semantics.

Tests: 8 new DatabaseProvider cases covering both routing paths,
URL scheme alias (postgresql://), DATABASE_URL vs DATABASE_PATH
precedence, displayName-credential-safety. Full suite passes
99/99 across 21 suites.

Next: M0.6 — SecretBox + EncryptionKeyResolver. Both Postgres and
SQLite stores will hold encrypted ASC .p8 and ASA secrets under the
same SecretBox; the abstraction lets M0.6 be driver-agnostic.
Last Swift-only sub-task in M0. Establishes the encryption-at-rest
primitives that M1's auth + ASC/ASA secret migration will use:

SecretBox (Sources/App/Crypto/SecretBox.swift):
  • AES-GCM-256 via swift-crypto (no new dep — already pinned for
    ES256 JWT signing).
  • Sealed format = Apple's AES.GCM.SealedBox.combined:
    [12-byte nonce] [ciphertext] [16-byte auth tag]. Self-describing,
    no separate IV column needed in the DB.
  • Convenience sealString / openString for the common PEM / client-
    secret use case.
  • Errors are structured (SecretBoxError) so callers see 'envelope
    malformed' or 'not UTF-8' rather than raw CryptoKit errors.

EncryptionKeyResolver (Sources/App/Crypto/EncryptionKeyResolver.swift):
  • resolve(mode:explicit:) is the single source of truth for which
    SymmetricKey wraps the SecretBox at boot.
  • If KEYWORDISTA_ENCRYPTION_KEY is set (already validated as exactly
    32 bytes by Parsers.hexBytes in M0.1), wrap as SymmetricKey.
  • Server mode without an explicit key → throws missingInServerMode.
    The manifest's requiredIn check already catches this earlier; the
    throw here is defense in depth.
  • Local mode without an explicit key → derive deterministically from
    the Mac's IOPlatformUUID (SHA-256 → 32 bytes → SymmetricKey).
    Same Mac → same key → existing SQLite stays decryptable across
    runs. Different Mac → different key (correct: 'don't carry your
    secrets to someone else's Mac').
  • #if os(macOS) guard around the IOKit call; Linux build throws
    localModeUnsupportedOnPlatform (local mode on Linux isn't a
    supported configuration — the menubar app is the only spawner).

No wire-up in configure.swift yet — this is purely additive. The
integration happens in M1 alongside the ASC/ASA encryption-at-rest
migration; landing the primitives in M0 means M1 can be 'use these
two things' instead of 'design + use these two things.'

Tests: 12 swift-testing cases across two suites:
  • SecretBox: round-trip (bytes + string), nonce uniqueness,
    wrong-key fails, tampering detected (GCM auth tag), truncated
    envelope yields structured error, openString non-UTF8 handling.
  • EncryptionKeyResolver: explicit key wraps correctly, wrong-size
    rejected, server-missing throws, local derivation is
    deterministic across calls, derived key is non-trivial (not the
    all-zero key — guard against a bad IOPlatformUUID read).

Full suite: 111/111 across 23 suites.
… swift:6.1

The first deliverable in the Docker territory of M0. Three stages:

  1. spa-builder    (node:20-alpine) — npm ci + npm run build →
                     /Public (index.html + assets/)
  2. swift-builder  (swift:6.1-jammy) — swift build -c release
                     --static-swift-stdlib so the runtime base can be
                     *-slim (no libswiftCore at runtime)
  3. runtime        (swift:6.1-jammy-slim) — non-root user uid 10001,
                     VOLUME /data, HEALTHCHECK via curl on /health,
                     entrypoint = /app/keywordista serve

Swift 6.1 (not 5.10): the Vapor 4.99+ dependency graph resolves to
versions (vapor 4.121, fluent 4.13, swift-crypto 4.5, swift-nio
2.100, swift-metrics 2.11, sql-kit 3.36) that declare
swift-tools-version 6.0+/6.1+. macOS-15 CI runners ship Xcode 16+
(Swift 6.x) so this was invisible there; the 5.10 base failed loudly
the first time we tried to build under Linux. The project's own
Package.swift declares swift-tools-version:5.10 which is fine
(minimum to PARSE our manifest), but resolved deps need a 6.1+
TOOLCHAIN to BUILD.

Layering is cache-friendly: COPY Package.swift before Sources/ so
source-only changes reuse the resolved-deps layer; COPY web/package*
before web/src so SPA dependency changes don't invalidate the build
cache for SPA source edits.

Build args:

  KEYWORDISTA_BUILD_VERSION    (default: dev)
  KEYWORDISTA_BUILD_COMMIT_SHA (default: unknown)
  KEYWORDISTA_BUILD_DATE       (default: unknown)

Read at boot by ImageMetadata (M0.2) and surfaced at /health,
/api/v1/version, and the --version flag. M0.8's GHCR workflow will
set all three for tagged releases.

ENV defaults baked in:
  KEYWORDISTA_MODE=server
  KEYWORDISTA_PUBLIC_DIR=/app/Public
  KEYWORDISTA_DATA_DIR=/data
  PORT=8080

Deliberately NOT set: KEYWORDISTA_ENCRYPTION_KEY,
KEYWORDISTA_PUBLIC_BASE_URL. Both are requiredIn: .server in the
manifest — boot fails fast with a clear message if the operator
forgets them. The alternative (image-supplied default key) would
silently share a single 'key' with every other deployment, which is
worse than failing closed.

Vapor's CLI flag parsing is preserved: ENTRYPOINT is [/app/keywordista]
with CMD [serve], so 'docker run keywordista serve --hostname X
--port Y' still works (matches how the macOS menubar supervisor
already invokes the local-mode binary).

Also:

  • .dockerignore does NOT exclude Tests/ — Package.swift declares
    .testTarget(path: 'Tests/AppTests'), so SPM expects the dir to
    exist when it loads the manifest in the swift-builder stage.
    Tests are never compiled into the runtime image (the runtime
    stage only COPYs the binary + Public/).
  • Package.resolved is in build context too — when CI pre-resolves,
    the lockfile pins exact dep versions for reproducible builds.
  • Two new make targets: 'make docker-build' (build with current
    git short SHA + UTC build date) and 'make docker-smoke' (build +
    run + GET /health + tear down).

Verified locally:
  • make docker-build → 162 MB arm64 image (slightly over the 150 MB
    target; slim base alone is ~120 MB, room to optimize later via
    distroless if pressing).
  • make docker-smoke → boots cleanly, /health returns {"status":"ok"}.
  • Build args correctly propagate to ImageMetadata env (logged
    KEYWORDISTA_BUILD_COMMIT_SHA matches current git short SHA).
Adds .github/workflows/release-image.yml — the third tag-stream
release workflow, joining release-app.yml (app-v* → DMG) and
release-service.yml (service-v* → menubar service .zip). Triggers
on image-v* tag pushes; supports workflow_dispatch for dry runs.

Tagging convention:
  git tag image-v1.2.3  →  ghcr.io/<owner>/keywordista:
                              :1.2.3   (exact SemVer)
                              :1.2     (major.minor — gets bumped patches)
                              :1       (major — gets bumped minors)
                              :latest  (newest stable)
                              @sha256:<digest>  (immutable, cockpit pins this)

Architecture: linux/amd64 (native) + linux/arm64 (QEMU-emulated on
the amd64 runner). Slow path for arm64, but standard pattern;
matches what most projects ship without a self-hosted arm64 runner.

Build args wired in:
  KEYWORDISTA_BUILD_VERSION     ← derived from the git tag
  KEYWORDISTA_BUILD_COMMIT_SHA  ← short $GITHUB_SHA
  KEYWORDISTA_BUILD_DATE        ← ISO-8601 UTC at build time
…all three read at boot by ImageMetadata (M0.2).

Supply chain:
  • cosign keyless signing of every published manifest using the
    workflow's GitHub OIDC identity. No secrets to manage.
    Verifiable later with:
      cosign verify ghcr.io/<owner>/keywordista@<digest> \
        --certificate-identity-regexp 'https://github.com/<owner>/keywordista' \
        --certificate-oidc-issuer 'https://token.actions.githubusercontent.com'
  • actions/attest-build-provenance@v2: SLSA-3 build provenance
    attached to the registry image. Verifiable with slsa-verifier
    or cosign verify-attestation — proves 'this image was built by
    THIS workflow run from THIS commit.'
  • SBOM attached via build-push-action's sbom: true.

Caching: docker/build-push-action's GHA cache (cache-from/to:
type=gha) caches layers across runs. First push cold-builds (~10
min); subsequent source-only changes resolve to instant for the
Swift-source-diff cases.

The workflow renders a GitHub-Step-Summary block with the digest,
all tags, pull command, and verification command — so the release
view tells operators exactly what to docker pull and how to verify.

Permissions deliberately scoped:
  contents: read     (checkout)
  packages: write    (GHCR push)
  id-token: write    (cosign + provenance OIDC)
  attestations: write (SLSA attestation)
No PAT required; the workflow's built-in GITHUB_TOKEN suffices for
all the above.

Not exercised against real CI yet. Two paths to validate:
  • Push 'image-v0.0.1-dev' tag → real build + sign + push to GHCR.
  • gh workflow run release-image.yml --ref feat/m0-image-foundation
    → builds + pushes to dryrun-<sha> tag (skips :latest etc).

Either way produces a real artifact in ghcr.io/<owner>/keywordista —
worth doing before M0.9's reference deploy manifests, which
reference the image by name.
Plan §4.6.7: the 'no-cockpit' path is first-class. The cockpit
generates these same artifacts internally for its Custom-Docker-Host
flow (M5); anyone with Docker / K8s / Nomad / a Render account can
deploy WITHOUT the cockpit by following the manifests below.

Twelve files across five deploy targets:

  deploy/docker-compose.yml      — minimum-viable VPS / homelab deploy.
                                   Postgres + Caddy + Litestream as
                                   commented-out optional services.
  deploy/.env.example            — operator template for the compose
                                   stack. Documents required vs optional
                                   env, with inline generation commands
                                   for the encryption key + bcrypt hash.
  deploy/render.yaml             — Render Blueprint. `sync: false` keeps
                                   secrets out of git; Render prompts at
                                   create time. Optional managed Postgres
                                   stanza at the bottom.
  deploy/fly.toml                — Fly.io app config. Machines API +
                                   1 GB volume. flyctl secrets set steps
                                   documented inline. Fly Postgres attach
                                   walk-through.
  deploy/kubernetes/             — five-file K8s set:
                                     • deployment.yaml (Recreate strategy,
                                       non-root, healthchecks, secret refs)
                                     • service.yaml    (ClusterIP, 80→8080)
                                     • pvc.yaml        (1 GB RWO)
                                     • secret.example.yaml (template; do
                                       NOT commit a filled-in copy)
                                     • ingress.yaml    (nginx + cert-manager)
  deploy/nomad/keywordista.nomad — single-instance Nomad job, host volume
                                   mount, Fabio/Traefik service tag.
  deploy/caddy/Caddyfile         — reverse proxy + auto-TLS for the
                                   compose stack's optional caddy service.
  deploy/litestream.yml          — S3/R2/B2 backup config for the
                                   compose stack's optional litestream
                                   sidecar.
  deploy/init.sh                 — one-shot bootstrap script for the
                                   cockpit's eventual Custom-Docker-Host
                                   flow. Idempotent. Installs Docker on
                                   Ubuntu/Debian if missing, validates
                                   .env, pulls, ups, waits for /health.

Shared design principles across every manifest:

  • Single instance per deployment, enforced. SQLite + iTunes API
    throttling both make horizontal scaling counterproductive
    (§4.10). K8s Deployment uses Recreate strategy not RollingUpdate;
    Fly pins min/max=1; Nomad count=1.
  • Image reference uses ghcr.io/bootuz/keywordista:latest by default
    for the convenience case; production should pin by @sha256:<digest>
    (cockpit always does). Each manifest's leading comment explains
    the trade-off.
  • Required secrets (KEYWORDISTA_ENCRYPTION_KEY + _PUBLIC_BASE_URL)
    are SECRETS — explicitly excluded from the committed files. Each
    manifest documents where the operator supplies them (Render's
    sync: false, Fly secrets, K8s Secret, compose's .env, Nomad's
    ${VAR}).
  • Optional Postgres path is consistently a DATABASE_URL-only flip —
    matches the §4.10 DatabaseProvider abstraction. Each manifest
    documents how to wire managed Postgres for that platform (Render
    fromDatabase, Fly postgres attach, K8s/compose external URL).

Sanity-checked:
  • All 12 files pass structural markers per platform's conventions.
  • Full Swift test suite still 111/111 across 23 suites.
  • env-manifest guard still clean.

Not exercised against live infra in this commit — the manifests are
operator-facing documentation that resolves when an operator points
their tooling at them. CI verification is M0.10's job (image-size
gate). Real-deploy verification is M3+'s job (cockpit dogfoods these
same manifests).
New 'image-size' job in ci.yml — runs on every PR alongside the
existing env-manifest / server / spa / mac-app jobs.

What it does:
  1. Builds the Dockerfile (amd64 only — multi-arch is
     release-image.yml's job) with GHA layer cache for fast repeat
     runs.
  2. Inspects the resulting image size and fails CI if > 200 MB
     (the conservative ceiling documented in plan §4.6.1; the
     aspirational target is <150 MB).
  3. Boots the image in a throwaway container with a random
     KEYWORDISTA_ENCRYPTION_KEY and waits up to 30s for /health
     to return 200. Same shape as 'make docker-smoke' locally.

Catches three regression classes before they hit a release:
  • Dockerfile breakage (build fails → job fails).
  • Image bloat (>200 MB → fails with a remediation hint pointing at
    the ceiling constant + 'document the reason' workflow).
  • Boot-time regressions (image builds but doesn't serve /health →
    fails with container logs in the workflow output).

Image size is also surfaced in the GHA Step Summary so trends over
time are visible in the Actions tab without needing to dig into logs.

What this is NOT (yet):
  • Postgres integration tests — deferred to M1 alongside real
    integration tests against the new auth/user/session models.
  • Manifest-name consistency check (every Environment.get call has
    a matching EnvVar declaration) — the M0.3 grep-ban is the
    stronger form of this guarantee for now; the Manifest.bootstrap
    validator surfaces issues at test time.
  • cosign signature verification of pulled images — that's a
    deploy-side concern, not a CI-side one. The cockpit (M3) and the
    docs/deploy/*.md guides (M0.11) both walk through cosign verify
    as part of their pull-and-pin instructions.

Net effect: ci.yml now runs FIVE parallel jobs on every PR:
  • env-manifest (M0.3, ubuntu, ~30s)
  • server (Swift build + test, macos-15)
  • spa (npm ci + check + build, ubuntu)
  • mac-app (Swift build for SwiftUI menubar, macos-15)
  • image-size (Docker build + size + boot smoke, ubuntu)
Closes M0. Five new docs files + README update:

  docs/env-vars.md                          (~280 LOC)
    Per-var reference for the §4.6.3 contract. Mirrors the order +
    structure of EnvVarManifest.swift. Required-vs-optional clearly
    marked. Secret flag noted for the three credential-shaped vars.
    Cross-links to the manifest source so contributors can trace.

  docs/deploy/raw-docker.md                 (~210 LOC)
    The canonical 'no cockpit' operator path. TL;DR docker run,
    pre-baked admin flow, digest pinning, cosign + SLSA-3 verification,
    upgrade flow with migration-forward-only caveat, hot SQLite backup
    one-liner, exit-code troubleshooting table.

  docs/architecture/image-contract.md       (~190 LOC)
    What's in the image vs. external; distribution + multi-arch +
    cosign + SLSA; SemVer + backward-compatibility commitments for
    env-var names, /health shape, /api/v1/version shape, migrations,
    entrypoint; what the macOS app shares with the image; how to add
    a new env var. Plan §4.6 in operator-facing prose.

  docs/architecture/exit-codes.md           (~90 LOC)
    Process exit-code table (0/1/2/3/4 + reserved range), SIGTERM
    behavior, curl exit-code primer for HEALTHCHECK debugging. Cross-
    linked from raw-docker.md's troubleshooting section.

  README.md                                 (+45 LOC)
    New 'Deploy for your team' section between the existing 'Install'
    and 'How it works' blocks. Quick docker run snippet, list of the
    five deploy targets in deploy/, list of the four new docs files,
    note about the cockpit's eventual in-Mac deploy flow.

All internal Markdown links verified by automated check; every relative
path resolves to an existing file. Full Swift test suite still
111/111 across 23 suites. env-manifest CI guard still clean.

M0 complete:
  M0.1  env-var manifest as single source of truth
  M0.2  ImageMetadata (compile-in version/SHA/buildDate)
  M0.3  CI grep-ban on raw Environment.get outside the manifest
  M0.4  configure.swift wired through the manifest + RuntimeMode
  M0.5  DatabaseProvider abstraction + fluent-postgres-driver
  M0.6  SecretBox (AES-GCM) + EncryptionKeyResolver
  M0.7  multi-stage Dockerfile, swift:6.1 base, builds clean (162 MB)
  M0.8  GHCR release workflow + cosign + SLSA-3 provenance
  M0.9  reference deploy manifests for 5 platforms (13 files)
  M0.10 image-size CI gate + boot smoke test
  M0.11 deploy + architecture docs, README pointer

Next: M1 — auth + multi-user backend (User/Session/Invite models,
AuthController, RoleMiddleware, creatorUserId on existing tables,
secrets encryption migration that finally USES the M0.6 SecretBox).
@bootuz bootuz changed the title Feat/m0 image foundation M0: image-as-product foundation for self-hosted server deploys May 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant