PR #51's migration-smoke failed 0.7 s after the build: the suite pinned
PostgreSQL to an ephemeral port but inherited S3_PORT=8333 from
.env.example, so concurrent runs on one runner (Gitea ignores workflow
concurrency) contended for one host port. Compose's own error was
discarded, so the failure said only that the services "did not become
ready".
Isolation, from an audit of every script CI runs:
- test-migrations-integration.sh: S3_PORT=0, like POSTGRES_PORT=0.
- test-migrations-integration.sh and smoke-infrastructure.sh: project names
gain a random suffix; run id plus PID alone is not unique when job
containers share low PIDs.
- smoke-deploy.sh: the edge /24 is chosen from those no network on the
daemon uses, and the first `up` chooses again when a concurrent run wins
a race for the same subnet (Docker's address-pool overlap), up to five
times.
- CI image-build: the image tag includes the run id and attempt, so a pull
request and a push of the same commit cannot remove each other's image.
- Already isolated: every other published port (ephemeral), the TLS
suite's container and network (random), smoke staging directories and
image tags (random).
Diagnostics: scripts/lib/compose-diagnostics.sh prints the failed `up`
command's output, `docker compose ps`, and the last 40 log lines of each
service, all redacted (supplied literals, credential-looking NAME=value
pairs, URL user information, Authorization headers) with terminal control
sequences stripped. All three scripts use it at every readiness check; the
infrastructure smoke's unredacted full-log dump is replaced by it.
tests/ci-scripts.test.ts pins ephemeral ports, unique project names, the
subnet choice, the CI image tag, the diagnostics, and the redaction itself
(run through bash).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Implement ADR 0044's reference deployment as a base Compose file plus
overlays, so an installation that already runs a proxy can use the base
alone.
- compose.yaml: api, postgres, object-storage, and the operator-only
migrate (profile ops, plan by default). No published port. api and
migrate run init, read-only, tmpfs /tmp, cap_drop ALL,
no-new-privileges. postgres is only on the internal data network.
- TLS everywhere from one installation-private CA: PostgreSQL TLS 1.3
only, with pg_hba rejecting every non-TLS connection, and the API on
verify-full via EKKLESIA_DATABASE_CA (PEM text). SeaweedFS serves S3
over HTTPS (ADR 0030 requires https in production), trusted by the
API through NODE_EXTRA_CA_CERTS. No Core change.
- compose.proxy.yaml + Caddyfile.example: Caddy pinned by digest, ACME
HTTP-01 (DNS-01 documented), routes /, /api/*, /display/*, /assets/*
to api and refuses everything else; the storage hostname preserves
Host. The API trusts exactly the proxy's fixed /32 on an edge network
whose dynamic range excludes the fixed addresses; Caddy overwrites
X-Forwarded-For and strips caller-supplied forwarding headers.
- compose.tunnel.yaml: cloudflared pinned by digest, token from its own
env file; Caddy honors CF-Connecting-IP only from its fixed address.
- Four per-service root-owned env files, examples with no values;
scripts/init-tls.sh (idempotent, never overwrites a key) and
scripts/gen-secrets.sh (prints only).
- CI: pnpm deploy:config in ci:verify for all three combinations, and a
deploy-smoke job (scripts/smoke-deploy.sh) proving readiness, the
Dashboard, signed storage delivery, the client-address chain, initial
owner sign-in/out, verify-full database TLS, and Doctor through the
proxy, against disposable services. tests/deploy.test.ts pins the
structural guarantees.
- ADR 0044 (still Proposed) amended to match: API-to-storage HTTPS,
DNS-01 needs a custom Caddy build and tunnel-only needs DNS-01, the
tunnel's origin TLS settings live in the Cloudflare dashboard, port
80 serves HTTP-01, and secrets are one env file per service.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Add deploy/Dockerfile and .dockerignore (ADR 0044): one image for the
api service and every operator-run CLI, built from an already-assembled
workspace (the platform plus whatever modules/<id> the context holds).
- node:24.18.0-trixie-slim pinned by index digest; pnpm via Corepack at
the root packageManager version.
- Build stage: frozen install, modules:validate, migrations:validate,
build. Runtime: production-only node_modules plus every dist/, core
migrations, and each module directory, in the repository layout the
API and CLIs resolve paths from; root-owned, run as node.
- NODE_ENV=production and EKKLESIA_API_HOST=0.0.0.0 inside the image
only; the platform default stays 127.0.0.1. Configuration comes only
from the environment; no .env enters the context or any layer.
- HEALTHCHECK on /api/v1/health/ready with Node's fetch; OCI labels for
source, revision, and version.
- CI builds the image without modules: build only, no credentials, no
push.
- deploy/README.md documents the image, the read-only production
posture (verified), init: true, and the exact CLI commands.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The security document claimed the CI workflow "is least privilege" because it
declares `permissions: contents: read`. That is not true on the server this
repository actually uses.
The inventoried server is Gitea 1.24.2, and its official comparison page lists
`permissions` / `jobs.<job_id>.permissions`, `concurrency`, and
`jobs.<job_id>.timeout-minutes` under unsupported workflow syntax — verified
against https://docs.gitea.com/1.24/usage/actions/comparison rather than assumed.
On this server those keys are inert, so the branch cannot claim the in-job
GITEA_TOKEN is read-only, and cannot claim YAML-enforced timeout or concurrency
behavior. Job-token permission controls, repository/owner defaults, and
server-side maximum-permission clamping arrive in the 1.26 feature line
(https://docs.gitea.com/1.26/usage/actions/token-permissions).
The document now separates what is actually true of this repository — full-SHA
action pins, frozen-lockfile install, no pull_request_target, no referenced
secret, `persist-credentials: false` on every checkout, and the declared
`permissions` block — from what the server enforces. It also states precisely
what `persist-credentials: false` proves: checkout leaves no credential in
`.git/config` for a later step, which is not the same as removing or reducing
the job token. The CI blockers are recorded: upgrade to a reviewed supported
release, configure repository/owner defaults and a maximum permission, prove
against the real server what the token cannot do, move timeout/cancellation/
teardown into the trusted provisioner and reaper, and finish the disposable-guest
proof before opening the real pull request.
The workflow keeps every declaration — they are already correct for a supported
Gitea release and for GitHub Actions — and gains a comment saying plainly that
this server ignores them, so no future reader mistakes them for enforcement.
Nothing about runners, proxies, firewalls, or deployment is asserted here.
Co-Authored-By: Claude Opus 5 <[email protected]>
`actions/checkout` defaults to `persist-credentials: true`, which writes the job
token into `.git/config` and leaves it there for every later step. Nothing in
this workflow pushes, so the credential has no use after checkout — but a pull
request can edit this file, and any step it adds runs in the same workspace.
All three jobs now check out with `persist-credentials: false`. The rest of the
workflow's posture is unchanged and was re-verified: top-level
`permissions: contents: read`, both actions pinned by full commit SHA,
`pnpm install --frozen-lockfile`, no `pull_request_target`, and no secrets
referenced anywhere.
This is the one property from the Pass 5 runner-isolation requirements that is
repository-local and statically verifiable. The rest — whether pull-request jobs
run on a disposable guest, what Docker daemon they reach, and which runners this
repository can select — depends on the actual Gitea and runner hosts and is not
claimed here.
Co-Authored-By: Claude Opus 5 <[email protected]>