Files
Ekklesia/docs/development/repository-workflow.md
ekklesiaandClaude Opus 5.5 e36b70d8d4
CI / verify (pull_request) Failing after 47s
CI / infrastructure-smoke (pull_request) Skipped
CI / migration-smoke (pull_request) Skipped
CI / image-build (pull_request) Skipped
CI / deploy-smoke (same-origin) (pull_request) Skipped
CI / deploy-smoke (split) (pull_request) Skipped
feat: one generic image, with installed modules in installation-owned storage (ADR 0048)
The production image now contains zero feature modules and is the same for every
installation. Installed modules live in module storage that one Core resolver
selects from EKKLESIA_MODULES_DIR: the checkout's modules/ when unset, an
absolute path that must exist when set (/var/lib/ekklesia/modules in the image).
A relative, blank, missing, or unreadable configured directory fails closed with
a sanitized error that never contains the value.

- Host configuration reads EKKLESIA_MODULES_DIR. The API, migration plan, apply
  and validate, module validation, module enablement, and Doctor all resolve it
  the same way; the test-only API override still takes precedence.
- Doctor receives Core's migrations directory explicitly instead of deriving it
  from the modules directory's parent.
- The migration runner retains and reports the applied history of a module that
  is not installed, and plans and applies Core's and present modules' migrations
  around it. Core's history and every installed module's history stay strict: a
  missing or changed applied migration, or a downgrade, still fails. No down
  migrations, no data deletion.
- deploy/Dockerfile copies no module and installs nothing for the module
  template; .dockerignore excludes modules/ and the build refuses a context that
  contains it; the runtime has an empty root-owned /var/lib/ekklesia/modules and
  sets EKKLESIA_MODULES_DIR.
- deploy/compose.yaml bind-mounts EKKLESIA_MODULES_HOST_DIR read-only into api
  and migrate, with create_host_path disabled.
- scripts/check-image.sh proves the image holds no modules and that a populated
  modules/ directory leaves its runtime file system identical; CI runs it.
- The split deploy smoke now uses the same generic image: it boots with no
  modules, installs a self-contained test module packaged from the template
  (test-only), migrates and restarts, removes it (Core runs, planning reports the
  retained history, data survives), and restores it with its data.

Docs: deploy README (module storage, install/update/remove/restore, the runtime
modules target), .env.example, API configuration, module development guide,
testing, workflow, security, and the architecture overview.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 08:38:14 -05:00

272 lines
14 KiB
Markdown

# Repository workflow
## Prerequisites
- Node.js 24 LTS (`.node-version` and `.nvmrc` pin the reviewed patch)
- pnpm 11.12.0
- Docker with the Compose plugin for local infrastructure
Install dependencies from the committed lockfile:
```bash
pnpm install --frozen-lockfile
```
## Quality gates
```bash
pnpm format:check # formatting without mutation
pnpm lint # Biome recommended rules
pnpm boundaries # architectural directions, UI ownership, and cycles
pnpm modules:validate # installed module manifests, compatibility, and dependency graph
pnpm migrations:validate # offline Core + module migration inventory and exact checksums
pnpm identity:schema:validate # detects drift against the pinned Better Auth schema generator
pnpm migrations:plan # read-only comparison with PostgreSQL applied history
pnpm migrations:apply # locked, transactional application of the pending batch
pnpm doctor # build-first, read-only human readiness dashboard
pnpm typecheck # strict TypeScript
pnpm test # Vitest
pnpm test:migrations:integration # production runner against disposable PostgreSQL
pnpm build # Turborepo package build graph
pnpm verify # every code-only gate; never plan or apply
```
The lint, typecheck, and test commands run both repository-root checks and every matching
package-level Turborepo task. New packages add their own `lint`, `typecheck`, and `test` scripts;
the root verification command discovers them automatically.
`pnpm modules:validate` builds the workspace, then scans the installed-modules directory without a
central registry: the checkout's own `modules/`, or `EKKLESIA_MODULES_DIR` when it is set (ADR
0048). A missing or empty `modules/` is valid and reports zero installed modules; a configured
directory must exist. Installed modules
must produce the compiled JavaScript entrypoints declared by their static manifests. Each module's
declared browser contributions are listed with their target capability and range, and marked
according to whether a compatible provider is installed — a contribution nobody provides is otherwise
silent, since the manifest is valid and the artifact exists while nothing renders (ADR 0041). Optional
schema entrypoints are loaded and validated for table ownership, namespacing, duplicates, and
foreign-key isolation; this command does not connect to PostgreSQL or run migrations.
`pnpm migrations:validate` also builds first, completes static module discovery, and atomically
checks Core's own `core/drizzle` migration directory together with every declared module Drizzle
migration directory. It reports Core's migration first, followed by dependency-first module
inventories, with exact SHA-256 checksums, without importing module code, connecting to
PostgreSQL, or executing SQL. Modules with no migration history are valid.
`pnpm identity:schema:validate` regenerates Better Auth's canonical schema with the pinned local
`auth` CLI into a temporary, package-local directory, structurally compares every core model
(columns, types, nullability, defaults, foreign keys, and indexes) against the checked-in
`core/src/identity/schema.ts`, and always removes the temporary output. It requires no
`DATABASE_URL`. See [Identity](identity.md) for the full upgrade workflow.
## Developer Doctor
Run the complete local readiness dashboard with:
```bash
pnpm doctor
```
The root command follows the repository's build-first convention, so it works from a clean checkout
without preexisting ignored `dist` output. The build itself may create ignored compiled artifacts;
the diagnostics that follow are operationally read-only. Doctor never edits configuration, applies
migrations, creates the Core history table, acquires the migration apply lock, starts module server
entrypoints, provisions storage, or invokes Docker.
Doctor evaluates the **same host infrastructure configuration the runtime uses**. Both `pnpm doctor`
and `apps/api` map one deployment environment through the same `loadHostConfiguration(env)` in Core,
so a configured object-storage provider, a set module secret, and a configured SMTP relay are all
recognized by Doctor exactly as `startApi` would recognize them — and neither process can silently
interpret a value differently from the other. Doctor needs no running API process to do this; export
or set the environment the platform would start with and run the command.
Each optional service is reported in one of three distinct states: **not configured** (absent, and a
complete deployment), **configured incorrectly** (a blocking failure, reported as a sanitized code
with no value echoed), or **configured but unreachable** (a warning, because a transient outage is
not a broken deployment).
Doctor reports a fixed, deterministic set of checks:
- The active Node.js version against the repository's declared engine range.
- Static module discovery and dependency-first ordering. A module that declares browser
contributions also reports how many of them have a compatible provider among the installed set.
- Schema discovery, including validated module and table counts. This imports compiled schema
definitions, which the module contract requires to perform no configuration reads, I/O, or
database connection.
- Offline migration discovery for Core's own identity migration and every module, with safe
identities and consistently truncated checksums, Core listed first.
- PostgreSQL connectivity and read-only applied-history planning when `DATABASE_URL` is configured.
- Identity runtime configuration: skipped (not a failure) when no secret, base URL, or trusted
origins are set; otherwise validated with `createIdentityConfig` and reported pass or failure.
This check never constructs Better Auth or opens a database connection.
- Module lifecycle and module configuration, resolved exactly as startup resolves them, so Doctor
can never report a module as runnable that startup would block, or the reverse.
- Object storage: skipped when no `S3_ENDPOINT` is set, a blocking failure when the `S3_*` settings
are structurally invalid, and otherwise probed for reachability. Bindings for every module-declared
logical bucket are reported separately, because binding is not health.
- Outbound email: skipped when no `EKKLESIA_SMTP_HOST` is set, a blocking failure when the
`EKKLESIA_SMTP_*` settings are structurally invalid, and otherwise a connection that authenticates
where credentials are set and **sends nothing**. That probe performs the same `EHLO` greeting the
runtime performs, so a relay that refuses the greeting is reported here rather than discovered when
a message silently fails to arrive — see
[Outbound email](email.md#when-a-relay-refuses-the-connection).
No check prints a credential: not the storage access or secret key, not the SMTP password, not the
identity secret, not a module secret value.
Every check has a stable ID and one of four states: pass, warning, failure, or skipped. Independent
checks continue after a failure; a failed prerequisite causes only its dependent checks to be
skipped. Pending or approval-blocked migrations are warnings and are never applied. Applied
history that belongs to a module which is not installed is reported as `retained` and is neither a
warning nor a failure (ADR 0048). Invalid history, checksum disagreement, an applied migration that
an installed module or Core no longer contains, or a failed configured connection are failures. An
unusable configured `EKKLESIA_MODULES_DIR` fails module discovery. If `DATABASE_URL` is missing,
PostgreSQL is skipped and all static checks still run.
`READY` means every check passed. `READY WITH WARNINGS` means no check failed but at least one check
warned or was skipped. Both results exit zero. `NOT READY` means at least one check failed and exits
one. Meaning never depends on color, a TTY, timestamps, or interactive input.
For the current repository with no installed modules and no `DATABASE_URL`, the concise inventory
ends like this:
```text
Modules
✓ Module discovery complete
0 modules installed
✓ Dependency graph valid
Dependency order is empty.
✓ Permission registry composed
6 permissions registered
○ Module lifecycle skipped
DATABASE_URL is not configured.
○ Module configuration skipped
DATABASE_URL is not configured.
Data definitions
✓ Schemas validated
0 modules · 0 tables
✓ Migrations validated
7 migrations · checksums valid
core:0000_identity_core_tables · f8f2086ef06f
core:0001_authorization_core_tables · 830e1fbc26d0
core:0002_authorization_integrity · 634294731180
core:0003_dashboard_navigation_tables · 2cde27807aa4
core:0004_module_lifecycle_state · c8b5d8b3d48b
core:0005_module_configuration · c8d57f24f84b
core:0006_device_identity · 1db97b7e3ac5
PostgreSQL
✓ Database TLS verified
TLS required with certificate and hostname verification.
○ Connection check skipped
DATABASE_URL is not configured.
○ Migration plan skipped
A validated migration inventory and configured PostgreSQL connection are required.
Identity
○ Identity configuration skipped
Core identity schema defines 4 tables (ekklesia_core_account, ekklesia_core_session, ekklesia_core_user, ekklesia_core_verification).
Identity runtime configuration (signing key, base URL, trusted origins) is not set.
✓ Direct client-address mode
Trusts only the actual immediate socket peer for rate limiting; no forwarded header is trusted.
Object storage
○ Object storage not configured
No S3_ENDPOINT is set. This is a complete deployment unless a module requires storage.
✓ No module requires object storage
0 modules declare a logical storage bucket.
Outbound email
○ Outbound email not configured
No EKKLESIA_SMTP_HOST is set. The platform runs and the bootstrap owner can sign in, but email verification and account recovery cannot deliver.
Result: READY WITH WARNINGS
```
Doctor is a local, human-readable summary. `pnpm verify`, `pnpm ci:verify`, infrastructure smoke,
and migration smoke remain the authoritative automated evidence because they exercise formatting,
boundaries, types, tests, builds, containers, and live mutation/rollback behavior that Doctor does
not repeat. Core authorization and the explicit platform-owner bootstrap command have since shipped,
and Doctor reports the composed permission registry; it still does not diagnose grant evaluation, the
`apps/api` HTTP transport, runtime module health, or production storage, and it never constructs an
operational Better Auth instance, starts the API, or claims sign-in works. See [API](api.md) for
`pnpm api:start` and the identity HTTP transport this repository now has.
Migration planning and application require `DATABASE_URL`. Keep it in the uncommitted `.env`
created from `.env.example`:
```bash
cp .env.example .env
pnpm migrations:plan
pnpm migrations:apply
```
Planning completes discovery and validation, reads Core's applied history, and prints a
deterministic applied/pending/approval-blocked inventory. It is read-only and can become stale if
another process applies migrations afterward. Applying is an explicit mutating operation: it
acquires the bounded Core advisory lock, rereads history, checks every destructive approval, and
executes the complete pending aggregate in one transaction. A later failure rolls back all schema
and history changes from that invocation; migrations committed by earlier invocations remain.
The default command composition supplies no destructive approvals. There is no `--force`, wildcard,
or source-controlled approval escape hatch; deployments must later compose a trusted external
provider that supplies an exact module ID, migration identity, and checksum triple.
Migration-runner unit tests cover orchestration through its narrow database port. Run the separate
live suite when changing migration behavior:
```bash
pnpm test:migrations:integration
```
It requires Docker with Compose, starts the pinned PostgreSQL image with a generated throwaway
credential and a Docker-assigned loopback port, creates an isolated database for each scenario,
and removes its containers, network, databases, and volume afterward. It exercises the production
adapter and real PostgreSQL transactions, history, advisory locks, approvals, and sanitized errors.
It remains outside the Docker-free `pnpm verify` command.
CI runs `pnpm ci:verify`, which adds Compose configuration validation to `pnpm verify`. Separate
`infrastructure-smoke` and `migration-smoke` jobs run the live service and migration suites. The
exact pull-request context to add to `main` branch protection after merge is
`CI / migration-smoke (pull_request)`. The explicit `ci:verify` suffix avoids pnpm's built-in `ci`
install command.
## Development infrastructure
Create an untracked local environment file before using Compose:
```bash
cp .env.example .env
pnpm infrastructure:up
```
Stop services or follow their logs with:
```bash
pnpm infrastructure:down
pnpm infrastructure:logs
```
Exercise both services with isolated, throwaway credentials and Docker-assigned host ports:
```bash
pnpm infrastructure:smoke
```
The smoke test verifies PostgreSQL password authentication and authenticated S3 list, put, get,
head, and delete operations. It also proves invalid database passwords, anonymous object access,
and invalid S3 signatures are rejected. Its containers, network, and volumes are removed on exit.
When the smoke script itself runs in a Docker-based CI job, it temporarily joins that job container
to the isolated Compose network and reaches object storage by its service name. Direct host runs
continue to use Docker-assigned random ports published only on loopback.
PostgreSQL and the S3 endpoint bind to `127.0.0.1` only. Compose uses persistent named volumes;
`infrastructure:down` preserves data. The S3 service creates the configured development bucket
idempotently at startup.
The checked-in credentials are safe only for local development. Deployed secrets must come from
an environment-appropriate secret provider and must not be committed.
Docker is not installed in every contributor environment. Compose validation and live service
health therefore run separately from the code-only `pnpm verify` gate.