[E00-S05-T01] Implement CI quality baseline (required PR stages) #405
+204
-293
@@ -5,14 +5,48 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
|
|
||||||
|
# Minimal workflow token: the pipeline only reads repository contents
|
||||||
|
# (checkout, frozen install, typecheck, lint, tests, build) — nothing writes
|
||||||
|
# back, so the token is scoped to contents: read (E00-S05-T01).
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
# E00-S05-T01 — CI quality baseline (required PR stages).
|
||||||
|
#
|
||||||
|
# Every pull request runs the required quality stages in order, each gated on
|
||||||
|
# the previous stage through `needs`:
|
||||||
|
#
|
||||||
|
# 1. frozen-install — the committed lockfile installs cleanly
|
||||||
|
# 2. typecheck — every workspace package passes `tsc --noEmit`
|
||||||
|
# 3. formatting-lint — the dependency-free formatting/lint policy (`pnpm lint`)
|
||||||
|
# 4. unit — deterministic unit suites (health, config, env example…)
|
||||||
|
# 5. architecture — static workspace/container structure and policy suites
|
||||||
|
# 6. postgres-integration — PostgreSQL adapter suites (docker-gated real-stack
|
||||||
|
# probes run where a Docker daemon is available and
|
||||||
|
# skip cleanly otherwise)
|
||||||
|
# 7. build-apps — builds the workspace applications (apps/*: server
|
||||||
|
# today, admin when E06-S01 lands) and verifies the
|
||||||
|
# compiled artifact
|
||||||
|
#
|
||||||
|
# The stage order, the `needs` chain and the tests/ coverage are locked in by
|
||||||
|
# tests/ci-stages.test.mjs (architecture stage). Third-party actions
|
||||||
|
# (actions/checkout, actions/setup-node) are pinned to full commit SHAs — no
|
||||||
|
# floating tags — and the workflow token is scoped to `contents: read`
|
||||||
|
# (E00-S05-T01 hardening). Container/Compose smoke on main/release branches
|
||||||
|
# and the Docker Compose baseline stack (E00-S02) stay out of scope for this
|
||||||
|
# stage list.
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
# Stage 1 — frozen install (E00-S05-T01). Runs before every later stage: the
|
||||||
|
# committed lockfile must install cleanly and be up to date with the
|
||||||
|
# manifests before any stage proceeds.
|
||||||
frozen-install:
|
frozen-install:
|
||||||
name: Frozen lockfile install
|
name: Stage 1 — Frozen lockfile install (E00-S05-T01)
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
- name: Install Node.js 24
|
- name: Install Node.js 24
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0
|
||||||
with:
|
with:
|
||||||
node-version: '24'
|
node-version: '24'
|
||||||
- name: Enable pnpm (corepack, pinned to 11.23.0 via packageManager)
|
- name: Enable pnpm (corepack, pinned to 11.23.0 via packageManager)
|
||||||
@@ -22,308 +56,185 @@ jobs:
|
|||||||
- name: Verify workspace groups
|
- name: Verify workspace groups
|
||||||
run: pnpm -r list --depth -1
|
run: pnpm -r list --depth -1
|
||||||
|
|
||||||
# E00-S02-T08: the static assertions of tests/secrets-not-embedded.test.mjs
|
# Stage 2 — typecheck (E00-S05-T01).
|
||||||
# gate every PR (the docker-gated layer-scan probe inside the same file runs
|
typecheck:
|
||||||
# where a Docker daemon is available and skips cleanly otherwise).
|
name: Stage 2 — Typecheck (E00-S05-T01)
|
||||||
secrets-not-embedded:
|
needs: frozen-install
|
||||||
name: Secrets not embedded (E00-S02-T08)
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
- name: Install Node.js 24
|
- name: Install Node.js 24
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0
|
||||||
with:
|
with:
|
||||||
node-version: '24'
|
node-version: '24'
|
||||||
- name: Run secrets-not-embedded test suite
|
- name: Enable pnpm (corepack, pinned to 11.23.0 via packageManager)
|
||||||
|
run: corepack enable
|
||||||
|
- name: Install dependencies (frozen lockfile)
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
- name: Typecheck every workspace package
|
||||||
|
run: pnpm typecheck
|
||||||
|
|
||||||
|
# Stage 3 — formatting/lint policy (E00-S05-T01). `pnpm lint` runs the
|
||||||
|
# dependency-free formatting-policy suite (tests/formatting-policy.test.mjs):
|
||||||
|
# LF line endings, no BOM, no trailing whitespace, no tab indentation, final
|
||||||
|
# newline, and valid JSON with 2-space indentation and no duplicate keys.
|
||||||
|
formatting-lint:
|
||||||
|
name: Stage 3 — Formatting/lint policy (E00-S05-T01)
|
||||||
|
needs: typecheck
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
- name: Install Node.js 24
|
||||||
|
uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0
|
||||||
|
with:
|
||||||
|
node-version: '24'
|
||||||
|
- name: Enable pnpm (corepack, pinned to 11.23.0 via packageManager)
|
||||||
|
run: corepack enable
|
||||||
|
- name: Install dependencies (frozen lockfile)
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
- name: Run the formatting/lint policy
|
||||||
|
run: pnpm lint
|
||||||
|
|
||||||
|
# Stage 4 — unit tests (E00-S05-T01). Deterministic suites that gate every
|
||||||
|
# PR without external services: the app health endpoint, the secrets scan
|
||||||
|
# and the configuration service suites (schema, startup error, log
|
||||||
|
# redaction, env adapter, .env.example). The config suites boot the
|
||||||
|
# committed server, so the config and database-postgres packages are built
|
||||||
|
# first.
|
||||||
|
unit:
|
||||||
|
name: Stage 4 — Unit tests (E00-S05-T01)
|
||||||
|
needs: formatting-lint
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
- name: Install Node.js 24
|
||||||
|
uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0
|
||||||
|
with:
|
||||||
|
node-version: '24'
|
||||||
|
- name: Enable pnpm (corepack, pinned to 11.23.0 via packageManager)
|
||||||
|
run: corepack enable
|
||||||
|
- name: Install dependencies (frozen lockfile)
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
- name: Build the config and database-postgres packages (the probes boot the committed server which imports them)
|
||||||
|
run: pnpm --filter @personal-blog/config build && pnpm --filter @personal-blog/database-postgres build
|
||||||
|
- name: Run the health-endpoint unit suite
|
||||||
|
run: node --test tests/health-endpoint.test.mjs
|
||||||
|
- name: Run the secrets-not-embedded unit suite
|
||||||
run: node --test tests/secrets-not-embedded.test.mjs
|
run: node --test tests/secrets-not-embedded.test.mjs
|
||||||
|
- name: Run the config-schema unit suite
|
||||||
# E00-S03-T02: the static assertions of tests/database-postgres-imports.test.mjs
|
|
||||||
# gate every PR — the scan proves pg/Kysely imports live only in
|
|
||||||
# packages/database-postgres and the mutation probes prove the scan catches
|
|
||||||
# a driver import injected into any other package.
|
|
||||||
database-postgres-imports:
|
|
||||||
name: Database-postgres import isolation (E00-S03-T02)
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- name: Install Node.js 24
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: '24'
|
|
||||||
- name: Run database-postgres import isolation suite
|
|
||||||
run: node --test tests/database-postgres-imports.test.mjs
|
|
||||||
|
|
||||||
# E00-S03-T03: the static assertions of tests/database-postgres-ledger.test.mjs
|
|
||||||
# gate every PR — the suite locks in the migration ledger (schema_migrations
|
|
||||||
# table DDL, idempotent parameterized record, driver-boundary re-export)
|
|
||||||
# with mutation probes, and the docker-gated real-stack probe (migrate an
|
|
||||||
# empty database and confirm the ledger exists) runs where a Docker daemon
|
|
||||||
# is available and skips cleanly otherwise. The job installs the frozen
|
|
||||||
# workspace because the real-stack probe executes the committed ledger
|
|
||||||
# module from the host (it imports `pg` through the package's own links).
|
|
||||||
database-postgres-ledger:
|
|
||||||
name: Migration ledger (E00-S03-T03)
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- name: Install Node.js 24
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: '24'
|
|
||||||
- name: Enable pnpm (corepack, pinned to 11.23.0 via packageManager)
|
|
||||||
run: corepack enable
|
|
||||||
- name: Install dependencies (frozen lockfile)
|
|
||||||
run: pnpm install --frozen-lockfile
|
|
||||||
- name: Run migration ledger test suite
|
|
||||||
run: node --test tests/database-postgres-ledger.test.mjs
|
|
||||||
|
|
||||||
# E00-S03-T04: the static assertions of tests/database-postgres-lock.test.mjs
|
|
||||||
# gate every PR — the suite locks in the migration advisory lock (session-
|
|
||||||
# scoped pg_advisory_lock/pg_try_advisory_lock over a stable keyed hash on a
|
|
||||||
# dedicated connection, re-entrant-safe in-flight acquire so concurrent
|
|
||||||
# acquire() calls share one connection, driver-boundary re-export) with
|
|
||||||
# mutation probes, and the docker-gated real-stack concurrent probe (a
|
|
||||||
# second runner waits or fails while the first holds the lock; concurrent
|
|
||||||
# acquire() checks out exactly one connection; the lock releases when the
|
|
||||||
# holding session ends) runs where a Docker daemon is available and skips
|
|
||||||
# cleanly otherwise. The job installs the frozen workspace because the
|
|
||||||
# real-stack probe executes the committed lock module from the host (it
|
|
||||||
# imports `pg` through the package's own links).
|
|
||||||
database-postgres-lock:
|
|
||||||
name: Migration advisory lock (E00-S03-T04)
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- name: Install Node.js 24
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: '24'
|
|
||||||
- name: Enable pnpm (corepack, pinned to 11.23.0 via packageManager)
|
|
||||||
run: corepack enable
|
|
||||||
- name: Install dependencies (frozen lockfile)
|
|
||||||
run: pnpm install --frozen-lockfile
|
|
||||||
- name: Run migration advisory lock test suite
|
|
||||||
run: node --test tests/database-postgres-lock.test.mjs
|
|
||||||
|
|
||||||
# E00-S03-T05: the static assertions of tests/database-postgres-diagnostic.test.mjs
|
|
||||||
# gate every PR — the suite locks in the migration failure diagnostic (a
|
|
||||||
# structured MigrationFailedError whose diagnostic identifies the failing
|
|
||||||
# migration, the failure phase, the underlying cause, and the applied/pending
|
|
||||||
# ledger state, serializable via toJSON) with mutation probes, and a
|
|
||||||
# deterministic stub-pool behavioral probe (intentionally failing migration
|
|
||||||
# fixture -> structured diagnostic naming the failing migration) runs on
|
|
||||||
# Node 24; the docker-gated real-stack probe (the issue's test plan: "run an
|
|
||||||
# intentionally failing migration fixture and confirm the diagnostic") runs
|
|
||||||
# where a Docker daemon is available and skips cleanly otherwise. The job
|
|
||||||
# installs the frozen workspace because the probes execute the committed
|
|
||||||
# runner module from the host (it imports `pg` through the package's own
|
|
||||||
# links).
|
|
||||||
database-postgres-diagnostic:
|
|
||||||
name: Migration failure diagnostic (E00-S03-T05)
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- name: Install Node.js 24
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: '24'
|
|
||||||
- name: Enable pnpm (corepack, pinned to 11.23.0 via packageManager)
|
|
||||||
run: corepack enable
|
|
||||||
- name: Install dependencies (frozen lockfile)
|
|
||||||
run: pnpm install --frozen-lockfile
|
|
||||||
- name: Run migration failure diagnostic test suite
|
|
||||||
run: node --test tests/database-postgres-diagnostic.test.mjs
|
|
||||||
|
|
||||||
# E00-S03-T06: the static assertions of tests/app-readiness.test.mjs gate
|
|
||||||
# every PR — the suite locks in the readiness gate (the app answers
|
|
||||||
# GET /health with 503 {"status":"not ready"} until the startup migration
|
|
||||||
# run completes, then 200 {"status":"ok"}) with mutation probes, the
|
|
||||||
# deterministic probes (boot the committed server: no DATABASE_URL ->
|
|
||||||
# ready immediately; unreachable DATABASE_URL -> stays not-ready) run on
|
|
||||||
# Node 24, and the docker-gated real-stack probe (the issue's test plan:
|
|
||||||
# "start with pending migrations and confirm readiness waits" — the app's
|
|
||||||
# migration run is blocked behind a held ACCESS EXCLUSIVE lock on the
|
|
||||||
# migration ledger, /health stays not-ready, then flips ready once the lock
|
|
||||||
# releases) runs where a Docker daemon is available and skips cleanly
|
|
||||||
# otherwise. The job installs the frozen workspace and builds the config
|
|
||||||
# and database-postgres packages because the probes boot the committed
|
|
||||||
# server from the host (it imports @personal-blog/config and
|
|
||||||
# @personal-blog/database-postgres through the packages' own links; the
|
|
||||||
# required EPPP_SESSION_SECRET is provided by the probe's boot env).
|
|
||||||
app-readiness:
|
|
||||||
name: App readiness after migrations (E00-S03-T06)
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- name: Install Node.js 24
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: '24'
|
|
||||||
- name: Enable pnpm (corepack, pinned to 11.23.0 via packageManager)
|
|
||||||
run: corepack enable
|
|
||||||
- name: Install dependencies (frozen lockfile)
|
|
||||||
run: pnpm install --frozen-lockfile
|
|
||||||
- name: Build the config and database-postgres packages (the probes boot the committed server which imports them)
|
|
||||||
run: pnpm --filter @personal-blog/config build && pnpm --filter @personal-blog/database-postgres build
|
|
||||||
- name: Run app readiness test suite
|
|
||||||
run: node --test tests/app-readiness.test.mjs
|
|
||||||
|
|
||||||
# E00-S04-T02: the static assertions of tests/config-startup-error.test.mjs
|
|
||||||
# gate every PR — the suite locks in the field-specific startup error (a
|
|
||||||
# missing required setting fails startup with an error naming the missing
|
|
||||||
# field: packages/config's MissingRequiredSettingError/assertValidConfig,
|
|
||||||
# wired into the committed server before it binds) with mutation probes, and
|
|
||||||
# the deterministic probes execute the issue's test plan ("start with a
|
|
||||||
# missing required field and confirm the error names it"): booting the
|
|
||||||
# committed server without EPPP_SESSION_SECRET exits non-zero naming
|
|
||||||
# sessionSecret, while a valid secret boots to GET /health 200. The job
|
|
||||||
# installs the frozen workspace and builds the config and database-postgres
|
|
||||||
# packages because the probes boot the committed server which imports them.
|
|
||||||
config-startup-error:
|
|
||||||
name: Field-specific startup errors (E00-S04-T02)
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- name: Install Node.js 24
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: '24'
|
|
||||||
- name: Enable pnpm (corepack, pinned to 11.23.0 via packageManager)
|
|
||||||
run: corepack enable
|
|
||||||
- name: Install dependencies (frozen lockfile)
|
|
||||||
run: pnpm install --frozen-lockfile
|
|
||||||
- name: Build the config and database-postgres packages (the probes boot the committed server which imports them)
|
|
||||||
run: pnpm --filter @personal-blog/config build && pnpm --filter @personal-blog/database-postgres build
|
|
||||||
- name: Run config startup error test suite
|
|
||||||
run: node --test tests/config-startup-error.test.mjs
|
|
||||||
|
|
||||||
# E00-S04-T03: the static assertions of tests/config-log-redaction.test.mjs
|
|
||||||
# gate every PR — the suite locks in automatic secret redaction from logs
|
|
||||||
# (packages/config's redactConfig/redactText + the server's redacting
|
|
||||||
# logger: every log line is scrubbed of the config's secret values) with
|
|
||||||
# mutation probes, and the deterministic probes execute the issue's test
|
|
||||||
# plan ("log configuration and confirm secret values are redacted"):
|
|
||||||
# booting the committed server logs its resolved configuration with the
|
|
||||||
# secret values replaced by [REDACTED], and no secret value appears in the
|
|
||||||
# log output. The job installs the frozen workspace and builds the config
|
|
||||||
# and database-postgres packages because the probes boot the committed
|
|
||||||
# server which imports them.
|
|
||||||
config-log-redaction:
|
|
||||||
name: Secret redaction from logs (E00-S04-T03)
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- name: Install Node.js 24
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: '24'
|
|
||||||
- name: Enable pnpm (corepack, pinned to 11.23.0 via packageManager)
|
|
||||||
run: corepack enable
|
|
||||||
- name: Install dependencies (frozen lockfile)
|
|
||||||
run: pnpm install --frozen-lockfile
|
|
||||||
- name: Build the config and database-postgres packages (the probes boot the committed server which imports them)
|
|
||||||
run: pnpm --filter @personal-blog/config build && pnpm --filter @personal-blog/database-postgres build
|
|
||||||
- name: Run config log redaction test suite
|
|
||||||
run: node --test tests/config-log-redaction.test.mjs
|
|
||||||
|
|
||||||
# E00-S04-T04: the static assertions of tests/config-env-adapter.test.mjs
|
|
||||||
# gate every PR — the suite locks in the environment adapter (packages/config
|
|
||||||
# is the single owner of process.env reads; the server and every other module
|
|
||||||
# read no process.env, all settings flow through loadConfigFromEnv into the
|
|
||||||
# validated config) with a comment-stripped workspace scan, mutation probes
|
|
||||||
# (injecting a direct process.env read into any other module fails the scan),
|
|
||||||
# a deterministic boundary probe (full env mapping, defaults, bad-PORT
|
|
||||||
# fallback, HOST validated as hostname/IP with invalid values throwing a
|
|
||||||
# field-specific startup error, missing required secret ->
|
|
||||||
# MissingRequiredSettingError) and server-boot probes (a PORT/HOST override
|
|
||||||
# shows up in the resolved configuration; HOST=127.0.0.1 binds loopback only
|
|
||||||
# and the startup log reflects the actual bind; an invalid HOST fails startup
|
|
||||||
# naming the field without echoing the raw value; a missing required secret
|
|
||||||
# still fails startup). The job installs the frozen workspace and builds the
|
|
||||||
# config and database-postgres packages because the probes boot the committed
|
|
||||||
# server which imports them.
|
|
||||||
config-env-adapter:
|
|
||||||
name: Env adapter owns process.env (E00-S04-T04)
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- name: Install Node.js 24
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: '24'
|
|
||||||
- name: Enable pnpm (corepack, pinned to 11.23.0 via packageManager)
|
|
||||||
run: corepack enable
|
|
||||||
- name: Install dependencies (frozen lockfile)
|
|
||||||
run: pnpm install --frozen-lockfile
|
|
||||||
- name: Build the config and database-postgres packages (the probes boot the committed server which imports them)
|
|
||||||
run: pnpm --filter @personal-blog/config build && pnpm --filter @personal-blog/database-postgres build
|
|
||||||
- name: Run config env adapter test suite
|
|
||||||
run: node --test tests/config-env-adapter.test.mjs
|
|
||||||
|
|
||||||
# E00-S04-T01: the static assertions of tests/config-schema.test.mjs gate
|
|
||||||
# every PR — the suite locks in the TypeBox/Ajv configuration schema
|
|
||||||
# (packages/config, golden-tuple pins @sinclair/typebox@0.34.52 +
|
|
||||||
# ajv@8.20.0) with mutation probes, and the deterministic probe executes
|
|
||||||
# the issue's test plan ("validate a full config against the TypeBox/Ajv
|
|
||||||
# schema") against the committed schema through Ajv. The job installs the
|
|
||||||
# frozen workspace and builds the config package because the probe also
|
|
||||||
# exercises the compiled package boundary (@personal-blog/config) exactly
|
|
||||||
# as the later configuration adapter will consume it.
|
|
||||||
config-schema:
|
|
||||||
name: TypeBox/Ajv config schema (E00-S04-T01)
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- name: Install Node.js 24
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: '24'
|
|
||||||
- name: Enable pnpm (corepack, pinned to 11.23.0 via packageManager)
|
|
||||||
run: corepack enable
|
|
||||||
- name: Install dependencies (frozen lockfile)
|
|
||||||
run: pnpm install --frozen-lockfile
|
|
||||||
- name: Build the config package (the probe exercises the compiled package boundary)
|
|
||||||
run: pnpm --filter @personal-blog/config build
|
|
||||||
- name: Run config schema test suite
|
|
||||||
run: node --test tests/config-schema.test.mjs
|
run: node --test tests/config-schema.test.mjs
|
||||||
|
- name: Run the config-startup-error unit suite
|
||||||
# E00-S04-T05: the static assertions of tests/env-example.test.mjs gate every
|
run: node --test tests/config-startup-error.test.mjs
|
||||||
# PR — the suite locks in the committed `.env.example` template: it exists at
|
- name: Run the config-log-redaction unit suite
|
||||||
# the repo root, is un-ignored in .gitignore (real `.env` files stay ignored
|
run: node --test tests/config-log-redaction.test.mjs
|
||||||
# while the example is tracked), documents every configuration environment
|
- name: Run the config-env-adapter unit suite
|
||||||
# source (HOST/PORT/DATABASE_URL/EPPP_SESSION_SECRET), and contains
|
run: node --test tests/config-env-adapter.test.mjs
|
||||||
# placeholder values only — no credential URI, no long secret-looking value,
|
- name: Run the env-example unit suite
|
||||||
# and no compose default credential — with mutation probes proving the
|
|
||||||
# assertions are non-vacuous. It also locks the fail-closed EPPP_SESSION_SECRET
|
|
||||||
# placeholder (shorter than the schema's 32-character minimum), builds the
|
|
||||||
# secret-shaped probe at runtime so the branch stays gitleaks-clean, and
|
|
||||||
# masks raw values in assertion messages. The test needs no dependencies, so
|
|
||||||
# the job only installs Node.
|
|
||||||
env-example:
|
|
||||||
name: .env.example placeholders only (E00-S04-T05)
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- name: Install Node.js 24
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: '24'
|
|
||||||
- name: Run .env.example test suite
|
|
||||||
run: node --test tests/env-example.test.mjs
|
run: node --test tests/env-example.test.mjs
|
||||||
|
|
||||||
# E00-S03-T01: the static assertions of tests/compose-config.test.mjs (db
|
# Stage 5 — architecture tests (E00-S05-T01). Static structure and policy
|
||||||
# image pinned to postgres:18.6-bookworm, health gate, volume persistence,
|
# suites: dependency boundaries, workspace layout/configuration, strict
|
||||||
# build platforms) gate every PR (the docker-gated real-stack probes inside
|
# TypeScript base, engine/TypeScript pins, root commands, frozen-install
|
||||||
# the same file run where a Docker daemon is available and skip cleanly
|
# clean clone, container definition structure, and the CI baseline itself.
|
||||||
# otherwise).
|
architecture:
|
||||||
compose-config:
|
name: Stage 5 — Architecture tests (E00-S05-T01)
|
||||||
name: Compose config (E00-S03-T01)
|
needs: unit
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
- name: Install Node.js 24
|
- name: Install Node.js 24
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0
|
||||||
with:
|
with:
|
||||||
node-version: '24'
|
node-version: '24'
|
||||||
- name: Run compose-config test suite
|
- name: Enable pnpm (corepack, pinned to 11.23.0 via packageManager)
|
||||||
|
run: corepack enable
|
||||||
|
- name: Install dependencies (frozen lockfile)
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
- name: Build the config and database-postgres packages (strict-tsconfig typechecks apps/server which imports them)
|
||||||
|
run: pnpm --filter @personal-blog/config build && pnpm --filter @personal-blog/database-postgres build
|
||||||
|
- name: Run the architecture-import suite
|
||||||
|
run: node --test tests/architecture-import.test.mjs
|
||||||
|
- name: Run the no-core-extension-imports suite
|
||||||
|
run: node --test tests/no-core-extension-imports.test.mjs
|
||||||
|
- name: Run the workspace-layout suite
|
||||||
|
run: node --test tests/workspace-layout.test.mjs
|
||||||
|
- name: Run the workspace-config suite
|
||||||
|
run: node --test tests/workspace-config.test.mjs
|
||||||
|
- name: Run the strict-tsconfig suite
|
||||||
|
run: node --test tests/strict-tsconfig.test.mjs
|
||||||
|
- name: Run the typescript-pin suite
|
||||||
|
run: node --test tests/typescript-pin.test.mjs
|
||||||
|
- name: Run the node-engine suite
|
||||||
|
run: node --test tests/node-engine.test.mjs
|
||||||
|
- name: Run the frozen-install suite
|
||||||
|
run: node --test tests/frozen-install.test.mjs
|
||||||
|
- name: Run the root-commands suite
|
||||||
|
run: node --test tests/root-commands.test.mjs
|
||||||
|
- name: Run the compose-config suite
|
||||||
run: node --test tests/compose-config.test.mjs
|
run: node --test tests/compose-config.test.mjs
|
||||||
|
- name: Run the build-targets suite
|
||||||
|
run: node --test tests/build-targets.test.mjs
|
||||||
|
- name: Run the non-root-user suite
|
||||||
|
run: node --test tests/non-root-user.test.mjs
|
||||||
|
- name: Run the readonly-rootfs suite
|
||||||
|
run: node --test tests/readonly-rootfs.test.mjs
|
||||||
|
- name: Run the database-postgres-imports suite
|
||||||
|
run: node --test tests/database-postgres-imports.test.mjs
|
||||||
|
- name: Run the ci-stages baseline suite
|
||||||
|
run: node --test tests/ci-stages.test.mjs
|
||||||
|
|
||||||
|
# Stage 6 — PostgreSQL integration tests (E00-S05-T01). The PostgreSQL
|
||||||
|
# adapter suites (migration ledger, advisory lock, failure diagnostic) and
|
||||||
|
# the app readiness suite: their docker-gated real-stack probes (migrate an
|
||||||
|
# empty database, hold/release the advisory lock, readiness waits on the
|
||||||
|
# startup migration run) run where a Docker daemon is available and skip
|
||||||
|
# cleanly otherwise; the static and deterministic probes always gate. The
|
||||||
|
# app-readiness probes boot the committed server, so the config and
|
||||||
|
# database-postgres packages are built first.
|
||||||
|
postgres-integration:
|
||||||
|
name: Stage 6 — PostgreSQL integration tests (E00-S05-T01)
|
||||||
|
needs: architecture
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
- name: Install Node.js 24
|
||||||
|
uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0
|
||||||
|
with:
|
||||||
|
node-version: '24'
|
||||||
|
- name: Enable pnpm (corepack, pinned to 11.23.0 via packageManager)
|
||||||
|
run: corepack enable
|
||||||
|
- name: Install dependencies (frozen lockfile)
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
- name: Build the config and database-postgres packages (the probes boot the committed server which imports them)
|
||||||
|
run: pnpm --filter @personal-blog/config build && pnpm --filter @personal-blog/database-postgres build
|
||||||
|
- name: Run the database-postgres-ledger suite
|
||||||
|
run: node --test tests/database-postgres-ledger.test.mjs
|
||||||
|
- name: Run the database-postgres-lock suite
|
||||||
|
run: node --test tests/database-postgres-lock.test.mjs
|
||||||
|
- name: Run the database-postgres-diagnostic suite
|
||||||
|
run: node --test tests/database-postgres-diagnostic.test.mjs
|
||||||
|
- name: Run the app-readiness suite
|
||||||
|
run: node --test tests/app-readiness.test.mjs
|
||||||
|
|
||||||
|
# Stage 7 — build the applications (E00-S05-T01). Builds every workspace
|
||||||
|
# application under apps/ (apps/server today; apps/admin when E06-S01 lands
|
||||||
|
# — the pnpm apps-group glob picks it up automatically) and verifies the
|
||||||
|
# compiled server artifact.
|
||||||
|
build-apps:
|
||||||
|
name: Stage 7 — Build the admin and server applications (E00-S05-T01)
|
||||||
|
needs: postgres-integration
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
- name: Install Node.js 24
|
||||||
|
uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0
|
||||||
|
with:
|
||||||
|
node-version: '24'
|
||||||
|
- name: Enable pnpm (corepack, pinned to 11.23.0 via packageManager)
|
||||||
|
run: corepack enable
|
||||||
|
- name: Install dependencies (frozen lockfile)
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
- name: Build the workspace applications (apps/* — server today, admin when E06-S01 lands)
|
||||||
|
run: pnpm --filter "./apps/**" run build
|
||||||
|
- name: Verify the compiled server application artifact
|
||||||
|
run: test -f apps/server/dist/index.js
|
||||||
|
|||||||
@@ -143,11 +143,22 @@ node <package-dir>/dist/index.js # e.g. node packages/core/dist/index.js
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
pnpm test
|
pnpm test
|
||||||
|
pnpm lint
|
||||||
```
|
```
|
||||||
|
|
||||||
`pnpm test` runs the `node:test` suites under `tests/` (currently
|
`pnpm test` runs the `node:test` suites under `tests/` with zero extra
|
||||||
`tests/architecture-import.test.mjs`, 10 tests) with zero extra dependencies.
|
dependencies (this is also the suite that enforces the dependency-boundary
|
||||||
This is also the suite that enforces the dependency-boundary rule.
|
rule). `pnpm lint` runs the formatting/lint policy suite
|
||||||
|
(`tests/formatting-policy.test.mjs`): every tracked text file must use LF
|
||||||
|
line endings, no BOM, no trailing whitespace, no tab indentation and exactly
|
||||||
|
one final newline; JSON files must additionally parse, carry no duplicate
|
||||||
|
keys and use 2-space indentation.
|
||||||
|
|
||||||
|
Pull requests run these checks as CI stages, in order — frozen lockfile
|
||||||
|
install → typecheck → formatting/lint → unit → architecture → PostgreSQL
|
||||||
|
integration → build of the applications (E00-S05-T01); the stage order,
|
||||||
|
the `needs` chain and the `tests/` coverage are locked in by
|
||||||
|
`tests/ci-stages.test.mjs`.
|
||||||
|
|
||||||
## Smoke check from a clean clone
|
## Smoke check from a clean clone
|
||||||
|
|
||||||
@@ -157,7 +168,8 @@ corepack enable
|
|||||||
pnpm install --frozen-lockfile # exit 0, lockfile untouched
|
pnpm install --frozen-lockfile # exit 0, lockfile untouched
|
||||||
pnpm build # 5/5 packages emit dist/, exit 0
|
pnpm build # 5/5 packages emit dist/, exit 0
|
||||||
pnpm typecheck # 5/5 packages pass --noEmit, exit 0
|
pnpm typecheck # 5/5 packages pass --noEmit, exit 0
|
||||||
pnpm test # 10/10 pass, exit 0
|
pnpm lint # formatting/lint policy passes, exit 0
|
||||||
|
pnpm test # all node:test suites pass, exit 0
|
||||||
pnpm --filter @personal-blog/server start # requires EPPP_SESSION_SECRET (see [Run](#run)); serves GET /health on port 3000, stays up
|
pnpm --filter @personal-blog/server start # requires EPPP_SESSION_SECRET (see [Run](#run)); serves GET /health on port 3000, stays up
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "pnpm -r run build",
|
"build": "pnpm -r run build",
|
||||||
|
"lint": "node --test tests/formatting-policy.test.mjs",
|
||||||
"test": "node --test \"tests/**/*.test.mjs\"",
|
"test": "node --test \"tests/**/*.test.mjs\"",
|
||||||
"typecheck": "pnpm -r run typecheck"
|
"typecheck": "pnpm -r run typecheck"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,370 @@
|
|||||||
|
/**
|
||||||
|
* CI quality baseline test — locks in the [E00-S05-T01] required PR stages of
|
||||||
|
* the committed workflow (`.gitea/workflows/ci.yml`).
|
||||||
|
*
|
||||||
|
* The baseline (issue #187 acceptance criteria):
|
||||||
|
* - "CI runs frozen install before later stages" → `frozen-install` is the
|
||||||
|
* first job and every later stage declares `needs` on its predecessor, so
|
||||||
|
* the pipeline runs the required stages strictly in order and nothing
|
||||||
|
* proceeds past a failed stage.
|
||||||
|
* - "CI runs typecheck, formatting/lint, unit, architecture and PostgreSQL
|
||||||
|
* integration tests" → the five stage jobs exist with the expected
|
||||||
|
* commands: `pnpm typecheck`, `pnpm lint`, and the unit / architecture /
|
||||||
|
* postgres-integration `node --test` suite runs.
|
||||||
|
* - "CI builds the admin and server applications" → the `build-apps` stage
|
||||||
|
* compiles the whole apps group (`./apps/**` — apps/server today, the
|
||||||
|
* admin app when E06-S01 lands) and verifies the compiled server
|
||||||
|
* artifact.
|
||||||
|
* - "CI workflow pins third-party actions (actions/checkout,
|
||||||
|
* actions/setup-node) to full commit SHAs, not floating tags" → every
|
||||||
|
* `uses:` ref is a 40-char commit SHA pinned to the committed
|
||||||
|
* PINNED_ACTIONS values — no `@v4`-style floating tags.
|
||||||
|
* - "CI workflow declares minimal permissions (`permissions: contents:
|
||||||
|
* read`) at the workflow level" → the workflow declares a top-level
|
||||||
|
* `permissions:` block granting exactly `contents: read`.
|
||||||
|
* - every committed test suite under tests/ is wired into exactly one stage
|
||||||
|
* (`pnpm lint` runs the formatting-policy suite; every other suite is
|
||||||
|
* named by a `node --test` run in the unit, architecture or
|
||||||
|
* postgres-integration stage).
|
||||||
|
*
|
||||||
|
* Mutation probes prove the assertions are non-vacuous: removing a stage,
|
||||||
|
* breaking the `needs` chain, or dropping a suite from its stage all fail.
|
||||||
|
*
|
||||||
|
* Run: `node --test tests/ci-stages.test.mjs`
|
||||||
|
* (node:test — built into Node >= 18; no dependencies, lockfile untouched.)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFileSync, readdirSync } from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
|
|
||||||
|
const read = (relPath) => readFileSync(path.join(REPO_ROOT, relPath), 'utf8');
|
||||||
|
|
||||||
|
/** The workflow file under test. */
|
||||||
|
const WORKFLOW = '.gitea/workflows/ci.yml';
|
||||||
|
|
||||||
|
/** The required PR stages, in the order the pipeline must run them. */
|
||||||
|
const REQUIRED_STAGES = [
|
||||||
|
'frozen-install',
|
||||||
|
'typecheck',
|
||||||
|
'formatting-lint',
|
||||||
|
'unit',
|
||||||
|
'architecture',
|
||||||
|
'postgres-integration',
|
||||||
|
'build-apps',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** The root lint command the formatting-lint stage must run. */
|
||||||
|
const LINT_SCRIPT = 'node --test tests/formatting-policy.test.mjs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The third-party actions the workflow may use, pinned to the full commit
|
||||||
|
* SHA of a released version (E00-S05-T01 hardening). Updating a pin means
|
||||||
|
* updating this table and the workflow together, in the same PR.
|
||||||
|
*/
|
||||||
|
const PINNED_ACTIONS = {
|
||||||
|
'actions/checkout': '11bd71901bbe5b1630ceea73d27597364c9af683', // v4.2.2
|
||||||
|
'actions/setup-node': '1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a', // v4.2.0
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses the workflow's `jobs:` section (the committed file is 2-space
|
||||||
|
* indented YAML) into `{ order, jobs }` where `order` lists job keys in
|
||||||
|
* document order and each job carries its `needs` value and `run:` commands.
|
||||||
|
* Comments and blank lines are skipped; unknown keys under a job are ignored.
|
||||||
|
*/
|
||||||
|
function parseWorkflowJobs(yamlText) {
|
||||||
|
const lines = yamlText.split('\n');
|
||||||
|
const jobsIndex = lines.findIndex((line) => line === 'jobs:');
|
||||||
|
assert.ok(jobsIndex >= 0, 'the workflow must declare a top-level jobs: section');
|
||||||
|
|
||||||
|
const order = [];
|
||||||
|
const jobs = {};
|
||||||
|
let current = null;
|
||||||
|
|
||||||
|
for (let i = jobsIndex + 1; i < lines.length; i++) {
|
||||||
|
const line = lines[i];
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (trimmed === '' || trimmed.startsWith('#')) continue;
|
||||||
|
const indent = line.length - line.trimStart().length;
|
||||||
|
if (indent === 2) {
|
||||||
|
const key = /^([A-Za-z0-9_-]+):/.exec(trimmed);
|
||||||
|
assert.ok(key, `unexpected jobs: entry at indent 2: "${line}"`);
|
||||||
|
current = key[1];
|
||||||
|
order.push(current);
|
||||||
|
jobs[current] = { needs: null, runs: [] };
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (current && indent > 2) {
|
||||||
|
const needs = /^needs:\s*(.+)$/.exec(trimmed);
|
||||||
|
if (needs) jobs[current].needs = needs[1].trim().replace(/^\[|\]$/g, '');
|
||||||
|
const run = /^run:\s*(.+)$/.exec(trimmed);
|
||||||
|
if (run) jobs[current].runs.push(run[1].trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { order, jobs };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extracts the tests/*.test.mjs suite names named by `node --test` runs. */
|
||||||
|
function suitesNamedInRuns(runs) {
|
||||||
|
const out = [];
|
||||||
|
for (const run of runs) {
|
||||||
|
for (const token of run.split(/\s+/)) {
|
||||||
|
if (token.startsWith('tests/') && token.endsWith('.test.mjs')) {
|
||||||
|
out.push(token.slice('tests/'.length));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extracts the `uses:` refs from the workflow, e.g. "actions/checkout@<sha>". */
|
||||||
|
function usesRefs(yamlText) {
|
||||||
|
const refs = [];
|
||||||
|
for (const line of yamlText.split('\n')) {
|
||||||
|
// `uses:` appears either as a bare key or as a sequence item ("- uses:").
|
||||||
|
const match = /^\s*(?:-\s+)?uses:\s*(\S+)/.exec(line);
|
||||||
|
if (match) refs.push(match[1]);
|
||||||
|
}
|
||||||
|
return refs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asserts the E00-S05-T01 hardening criteria: every third-party `uses:` ref
|
||||||
|
* is pinned to a full 40-char commit SHA (exactly the committed PINNED_ACTIONS
|
||||||
|
* values — no floating tags) and the workflow declares `permissions:
|
||||||
|
* contents: read` at the top level. Throws an AssertionError describing the
|
||||||
|
* first violated invariant.
|
||||||
|
*/
|
||||||
|
function assertHardening(yamlText) {
|
||||||
|
const refs = usesRefs(yamlText);
|
||||||
|
assert.ok(refs.length > 0, 'the workflow must use at least one third-party action');
|
||||||
|
for (const ref of refs) {
|
||||||
|
const match = /^([\w.-]+\/[\w.-]+)@([0-9a-f]{40})$/.exec(ref);
|
||||||
|
assert.ok(
|
||||||
|
match,
|
||||||
|
`every third-party action must be pinned to a full 40-char commit SHA, not a floating tag (got "${ref}")`,
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
Object.hasOwn(PINNED_ACTIONS, match[1]),
|
||||||
|
`unexpected third-party action "${match[1]}" — add it to the PINNED_ACTIONS policy table if it is approved`,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
match[2],
|
||||||
|
PINNED_ACTIONS[match[1]],
|
||||||
|
`"${match[1]}" must be pinned to the committed full commit SHA ${PINNED_ACTIONS[match[1]]} (got ${match[2]})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const action of Object.keys(PINNED_ACTIONS)) {
|
||||||
|
assert.ok(
|
||||||
|
refs.some((ref) => ref.startsWith(`${action}@`)),
|
||||||
|
`the workflow must use "${action}" pinned to a full commit SHA`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert.match(
|
||||||
|
yamlText,
|
||||||
|
/^permissions:\n[ \t]+contents: read$/m,
|
||||||
|
'the workflow must declare a top-level "permissions: contents: read" block',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asserts the whole E00-S05-T01 baseline for a parsed workflow. Throws an
|
||||||
|
* AssertionError describing the first violated invariant.
|
||||||
|
*/
|
||||||
|
function assertBaseline({ order, jobs }) {
|
||||||
|
// Every required stage exists.
|
||||||
|
for (const stage of REQUIRED_STAGES) {
|
||||||
|
assert.ok(jobs[stage], `required CI stage "${stage}" is missing from the workflow`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The required stages run in order (their relative order is preserved).
|
||||||
|
const present = order.filter((name) => REQUIRED_STAGES.includes(name));
|
||||||
|
assert.deepEqual(
|
||||||
|
present,
|
||||||
|
REQUIRED_STAGES,
|
||||||
|
`the required CI stages must run in order: ${REQUIRED_STAGES.join(' -> ')}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Frozen install runs before later stages: every later stage gates on its
|
||||||
|
// predecessor, so the pipeline is strictly ordered.
|
||||||
|
for (let i = 1; i < REQUIRED_STAGES.length; i++) {
|
||||||
|
assert.equal(
|
||||||
|
jobs[REQUIRED_STAGES[i]].needs,
|
||||||
|
REQUIRED_STAGES[i - 1],
|
||||||
|
`stage "${REQUIRED_STAGES[i]}" must gate on the previous stage "${REQUIRED_STAGES[i - 1]}"`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stage commands.
|
||||||
|
assert.ok(
|
||||||
|
jobs['frozen-install'].runs.some((run) => run.includes('pnpm install --frozen-lockfile')),
|
||||||
|
'frozen-install must run the frozen lockfile install',
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
jobs['typecheck'].runs.some((run) => run.includes('pnpm typecheck')),
|
||||||
|
'the typecheck stage must run pnpm typecheck',
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
jobs['formatting-lint'].runs.some((run) => run.includes('pnpm lint')),
|
||||||
|
'the formatting-lint stage must run pnpm lint',
|
||||||
|
);
|
||||||
|
|
||||||
|
// The build stage compiles the apps group and verifies the server artifact.
|
||||||
|
const buildRuns = jobs['build-apps'].runs;
|
||||||
|
assert.ok(
|
||||||
|
buildRuns.some((run) => run.includes('run build') && run.includes('./apps/**')),
|
||||||
|
'build-apps must build the apps group (pnpm --filter "./apps/**" run build)',
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
buildRuns.some((run) => run.includes('apps/server/dist/index.js')),
|
||||||
|
'build-apps must verify the compiled server application artifact',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Every committed test suite is wired into exactly one stage.
|
||||||
|
const staged = [
|
||||||
|
...suitesNamedInRuns(jobs['unit'].runs),
|
||||||
|
...suitesNamedInRuns(jobs['architecture'].runs),
|
||||||
|
...suitesNamedInRuns(jobs['postgres-integration'].runs),
|
||||||
|
];
|
||||||
|
const allSuites = readdirSync(path.join(REPO_ROOT, 'tests'))
|
||||||
|
.filter((file) => file.endsWith('.test.mjs'))
|
||||||
|
.sort();
|
||||||
|
// The formatting-policy suite is run by the formatting-lint stage via the
|
||||||
|
// root lint script rather than by a node --test run in a test stage.
|
||||||
|
const expected = allSuites.filter((file) => file !== 'formatting-policy.test.mjs');
|
||||||
|
assert.equal(
|
||||||
|
staged.length,
|
||||||
|
expected.length,
|
||||||
|
'each test suite must be wired into exactly one CI stage run ' +
|
||||||
|
`(staged: ${staged.join(', ')}; expected: ${expected.join(', ')})`,
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
[...new Set(staged)].sort(),
|
||||||
|
expected,
|
||||||
|
'every committed test suite must be wired into exactly one CI stage ' +
|
||||||
|
`(staged: ${staged.join(', ')}; expected: ${expected.join(', ')})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Removes the whole ` <jobName>:` block from a workflow text (probe helper). */
|
||||||
|
function removeJobBlock(yamlText, jobName) {
|
||||||
|
const lines = yamlText.split('\n');
|
||||||
|
const start = lines.findIndex((line) => line === ` ${jobName}:`);
|
||||||
|
assert.ok(start >= 0, `job ${jobName} must exist in the workflow text`);
|
||||||
|
let end = lines.length;
|
||||||
|
for (let i = start + 1; i < lines.length; i++) {
|
||||||
|
const trimmed = lines[i].trim();
|
||||||
|
if (
|
||||||
|
trimmed !== '' &&
|
||||||
|
!trimmed.startsWith('#') &&
|
||||||
|
lines[i].length - lines[i].trimStart().length === 2 &&
|
||||||
|
/^[A-Za-z0-9_-]+:/.test(trimmed)
|
||||||
|
) {
|
||||||
|
end = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...lines.slice(0, start), ...lines.slice(end)].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Real-workflow baseline
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
test('the committed CI workflow runs the required PR stages in order', () => {
|
||||||
|
assertBaseline(parseWorkflowJobs(read(WORKFLOW)));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the workflow triggers on pull requests and pushes to main', () => {
|
||||||
|
const text = read(WORKFLOW);
|
||||||
|
assert.match(text, /^on:$/m, 'the workflow must declare an on: trigger block');
|
||||||
|
assert.match(text, /pull_request:/, 'PRs must trigger the CI workflow');
|
||||||
|
assert.match(text, /push:/, 'pushes must trigger the CI workflow');
|
||||||
|
assert.match(text, /branches:\s*\[main\]/, 'the push trigger must cover main');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the root lint script runs the formatting-policy suite', () => {
|
||||||
|
const scripts = JSON.parse(read('package.json')).scripts ?? {};
|
||||||
|
assert.equal(
|
||||||
|
scripts.lint,
|
||||||
|
LINT_SCRIPT,
|
||||||
|
`root package.json must declare scripts.lint exactly as "${LINT_SCRIPT}"`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the workflow pins third-party actions to full commit SHAs and declares minimal permissions', () => {
|
||||||
|
assertHardening(read(WORKFLOW));
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mutation probes — the baseline assertions are non-vacuous
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
test('removing a required stage fails the baseline (mutation probe)', () => {
|
||||||
|
const mutated = removeJobBlock(read(WORKFLOW), 'unit');
|
||||||
|
assert.throws(() => assertBaseline(parseWorkflowJobs(mutated)), /required CI stage "unit"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('breaking the needs chain fails the baseline (mutation probe)', () => {
|
||||||
|
const text = read(WORKFLOW);
|
||||||
|
const mutated = text.replace('needs: formatting-lint', 'needs: typecheck');
|
||||||
|
assert.notEqual(mutated, text, 'the probe must mutate the workflow');
|
||||||
|
assert.throws(
|
||||||
|
() => assertBaseline(parseWorkflowJobs(mutated)),
|
||||||
|
/must gate on the previous stage/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dropping a suite from its stage fails the coverage assertion (mutation probe)', () => {
|
||||||
|
const text = read(WORKFLOW);
|
||||||
|
const mutated = text.replace('tests/config-schema.test.mjs', '');
|
||||||
|
assert.notEqual(mutated, text, 'the probe must mutate the workflow');
|
||||||
|
assert.throws(
|
||||||
|
() => assertBaseline(parseWorkflowJobs(mutated)),
|
||||||
|
/must be wired into exactly one CI stage/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reordering the stages fails the baseline (mutation probe)', () => {
|
||||||
|
const text = read(WORKFLOW);
|
||||||
|
// Swap the typecheck and formatting-lint job blocks so their document order
|
||||||
|
// no longer matches the required stage order.
|
||||||
|
const typecheckBlock = text.slice(text.indexOf(' typecheck:'), text.indexOf(' formatting-lint:'));
|
||||||
|
const lintBlock = text.slice(text.indexOf(' formatting-lint:'), text.indexOf(' unit:'));
|
||||||
|
const mutated = text.replace(typecheckBlock + lintBlock, lintBlock + typecheckBlock);
|
||||||
|
assert.notEqual(mutated, text, 'the probe must mutate the workflow');
|
||||||
|
assert.throws(() => assertBaseline(parseWorkflowJobs(mutated)), /must run in order/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reverting an action pin to a floating tag fails the hardening assertion (mutation probe)', () => {
|
||||||
|
const text = read(WORKFLOW);
|
||||||
|
const mutated = text.replace(
|
||||||
|
`actions/checkout@${PINNED_ACTIONS['actions/checkout']}`,
|
||||||
|
'actions/checkout@v4',
|
||||||
|
);
|
||||||
|
assert.notEqual(mutated, text, 'the probe must mutate the workflow');
|
||||||
|
assert.throws(() => assertHardening(mutated), /full 40-char commit SHA/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('changing a pinned action SHA fails the hardening assertion (mutation probe)', () => {
|
||||||
|
const text = read(WORKFLOW);
|
||||||
|
const mutated = text.replace(
|
||||||
|
`actions/setup-node@${PINNED_ACTIONS['actions/setup-node']}`,
|
||||||
|
`actions/setup-node@${'a'.repeat(40)}`,
|
||||||
|
);
|
||||||
|
assert.notEqual(mutated, text, 'the probe must mutate the workflow');
|
||||||
|
assert.throws(() => assertHardening(mutated), /must be pinned to the committed full commit SHA/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('removing the workflow-level permissions block fails the hardening assertion (mutation probe)', () => {
|
||||||
|
const text = read(WORKFLOW);
|
||||||
|
const mutated = text.replace(/^permissions:\n contents: read\n\n/m, '');
|
||||||
|
assert.notEqual(mutated, text, 'the probe must mutate the workflow');
|
||||||
|
assert.throws(() => assertHardening(mutated), /permissions: contents: read/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
/**
|
||||||
|
* Formatting/lint policy test — locks in the workspace formatting and lint
|
||||||
|
* policy (E00-S05-T01, CI stage 3: formatting/lint).
|
||||||
|
*
|
||||||
|
* Policy (every file tracked by git, i.e. every committed text file):
|
||||||
|
* - LF line endings: no carriage returns (no CRLF, no lone CR)
|
||||||
|
* - no UTF-8 byte-order mark
|
||||||
|
* - no trailing whitespace on any line
|
||||||
|
* - no tab characters anywhere (indentation is spaces)
|
||||||
|
* - exactly one final newline: the file must end with `\n`, with no blank
|
||||||
|
* line left at the end of the file
|
||||||
|
* JSON files additionally must:
|
||||||
|
* - parse as strict JSON (no trailing commas, no comments)
|
||||||
|
* - contain no duplicate object keys
|
||||||
|
* - use 2-space indentation (every line's leading spaces are an even count)
|
||||||
|
*
|
||||||
|
* The scan is scoped to files tracked by git (`git ls-files`), so ignored and
|
||||||
|
* generated files (node_modules/, dist/, .env, probe scratch files) never
|
||||||
|
* enter the policy. Binary files (containing a NUL byte) are skipped.
|
||||||
|
*
|
||||||
|
* Mutation probes prove every rule is non-vacuous: each violation below is
|
||||||
|
* injected into a temp file and must be reported.
|
||||||
|
*
|
||||||
|
* Run: `pnpm lint` (== `node --test tests/formatting-policy.test.mjs`)
|
||||||
|
* (node:test — built into Node >= 18; no dependencies, lockfile untouched.)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFileSync, mkdtempSync, writeFileSync, rmSync } from 'node:fs';
|
||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
|
|
||||||
|
const read = (relPath) => readFileSync(path.join(REPO_ROOT, relPath), 'utf8');
|
||||||
|
|
||||||
|
/** True for binary files: the policy applies to text files only. */
|
||||||
|
const isBinary = (content) => content.includes('\0');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the list of tracked files (git ls-files, NUL-delimited) under the
|
||||||
|
* given repo root, or fails the suite when git is unavailable.
|
||||||
|
*/
|
||||||
|
function trackedFiles(root = REPO_ROOT) {
|
||||||
|
const result = spawnSync('git', ['-C', root, 'ls-files', '-z'], { encoding: 'utf8' });
|
||||||
|
assert.equal(result.status, 0, `git ls-files must succeed in ${root}`);
|
||||||
|
return result.stdout.split('\0').filter((p) => p.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collects the duplicate object keys of a JSON document. JSON.parse collapses
|
||||||
|
* duplicate keys (last value wins) before any reviver or post-parse walker can
|
||||||
|
* see them, so this scans the raw text: a string literal immediately followed
|
||||||
|
* by `:` is an object key, and keys are tracked per enclosing `{…}` frame, so
|
||||||
|
* same-named keys in different objects stay legal while a repeated key inside
|
||||||
|
* one object is reported.
|
||||||
|
*/
|
||||||
|
function collectDuplicateKeys(text, out) {
|
||||||
|
const frames = [];
|
||||||
|
let i = 0;
|
||||||
|
const n = text.length;
|
||||||
|
while (i < n) {
|
||||||
|
const ch = text[i];
|
||||||
|
if (ch === '"') {
|
||||||
|
let j = i + 1;
|
||||||
|
while (j < n && text[j] !== '"') {
|
||||||
|
if (text[j] === '\\') j += 1;
|
||||||
|
j += 1;
|
||||||
|
}
|
||||||
|
const key = text.slice(i + 1, j).replace(/\\"/g, '"').replace(/\\\\/g, '\\');
|
||||||
|
let k = j + 1;
|
||||||
|
while (k < n && (text[k] === ' ' || text[k] === '\t' || text[k] === '\n' || text[k] === '\r')) k += 1;
|
||||||
|
if (text[k] === ':' && frames.length > 0) {
|
||||||
|
const seen = frames[frames.length - 1];
|
||||||
|
if (seen.has(key)) out.push(`duplicate JSON key: ${key}`);
|
||||||
|
seen.add(key);
|
||||||
|
}
|
||||||
|
i = k;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === '{') {
|
||||||
|
frames.push(new Set());
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === '}') {
|
||||||
|
frames.pop();
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the formatting/lint violations for one file relative to `root`, or
|
||||||
|
* [] when the file conforms. Binary files are out of policy scope.
|
||||||
|
*/
|
||||||
|
function violationsForFile(root, relPath) {
|
||||||
|
let content;
|
||||||
|
try {
|
||||||
|
content = readFileSync(path.join(root, relPath), 'utf8');
|
||||||
|
} catch (err) {
|
||||||
|
return [`${relPath}: unreadable: ${err.message}`];
|
||||||
|
}
|
||||||
|
if (isBinary(content)) return [];
|
||||||
|
|
||||||
|
const label = (message) => `${relPath}: ${message}`;
|
||||||
|
const out = [];
|
||||||
|
|
||||||
|
if (content.includes('\r')) out.push(label('carriage return (use LF line endings)'));
|
||||||
|
if (content.startsWith('\uFEFF')) out.push(label('UTF-8 byte-order mark'));
|
||||||
|
if (content.includes('\t')) out.push(label('tab character (indentation must be spaces)'));
|
||||||
|
|
||||||
|
const lines = content.split('\n');
|
||||||
|
lines.forEach((line, i) => {
|
||||||
|
if (/[ \t]+$/.test(line)) out.push(label(`trailing whitespace on line ${i + 1}`));
|
||||||
|
});
|
||||||
|
|
||||||
|
if (content.length > 0) {
|
||||||
|
if (lines[lines.length - 1] !== '') out.push(label('missing final newline'));
|
||||||
|
else if (lines[lines.length - 2] === '') out.push(label('blank line at end of file'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (relPath.endsWith('.json')) {
|
||||||
|
let parsed;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(content);
|
||||||
|
} catch (err) {
|
||||||
|
out.push(label(`invalid JSON: ${err.message}`));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
const duplicateKeys = [];
|
||||||
|
collectDuplicateKeys(content, duplicateKeys);
|
||||||
|
for (const message of duplicateKeys) out.push(label(message));
|
||||||
|
lines.forEach((line, i) => {
|
||||||
|
const indent = /^([ ]*)\S/.exec(line);
|
||||||
|
if (indent && indent[1].length % 2 !== 0) {
|
||||||
|
out.push(label(`JSON indentation must be 2 spaces per level (line ${i + 1})`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Writes a probe file into a fresh temp dir and returns its violations. */
|
||||||
|
function probeViolations(filename, content) {
|
||||||
|
const dir = mkdtempSync(path.join(os.tmpdir(), 'eppp-format-policy-'));
|
||||||
|
try {
|
||||||
|
writeFileSync(path.join(dir, filename), content);
|
||||||
|
return violationsForFile(dir, filename);
|
||||||
|
} finally {
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Real-tree scan
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
test('every tracked text file conforms to the formatting/lint policy', () => {
|
||||||
|
const files = trackedFiles();
|
||||||
|
assert.ok(
|
||||||
|
files.length >= 25,
|
||||||
|
`expected a meaningful tracked file set to scan (got ${files.length})`,
|
||||||
|
);
|
||||||
|
const violations = [];
|
||||||
|
for (const file of files) violations.push(...violationsForFile(REPO_ROOT, file));
|
||||||
|
assert.deepEqual(violations, [], `formatting/lint violations:\n${violations.join('\n')}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the scan covers the workspace source, docs, manifests and workflow', () => {
|
||||||
|
const files = trackedFiles();
|
||||||
|
for (const expected of [
|
||||||
|
'apps/server/src/index.ts',
|
||||||
|
'packages/config/src/env.ts',
|
||||||
|
'docs/development/non-container.md',
|
||||||
|
'package.json',
|
||||||
|
'pnpm-lock.yaml',
|
||||||
|
'pnpm-workspace.yaml',
|
||||||
|
'compose.yaml',
|
||||||
|
'.gitea/workflows/ci.yml',
|
||||||
|
]) {
|
||||||
|
assert.ok(files.includes(expected), `tracked file set must include ${expected}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mutation probes — every rule is non-vacuous
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
test('a trailing whitespace is reported (mutation probe)', () => {
|
||||||
|
const violations = probeViolations('probe.txt', 'line with trailing space \n');
|
||||||
|
assert.ok(violations.some((v) => /trailing whitespace/.test(v)), `got: ${violations.join('; ')}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a tab character is reported (mutation probe)', () => {
|
||||||
|
const violations = probeViolations('probe.txt', 'line\twith tab\n');
|
||||||
|
assert.ok(violations.some((v) => /tab character/.test(v)), `got: ${violations.join('; ')}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a CRLF line ending is reported (mutation probe)', () => {
|
||||||
|
const violations = probeViolations('probe.txt', 'line\r\n');
|
||||||
|
assert.ok(violations.some((v) => /carriage return/.test(v)), `got: ${violations.join('; ')}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a UTF-8 byte-order mark is reported (mutation probe)', () => {
|
||||||
|
const violations = probeViolations('probe.txt', '\uFEFFline\n');
|
||||||
|
assert.ok(violations.some((v) => /byte-order mark/.test(v)), `got: ${violations.join('; ')}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a missing final newline is reported (mutation probe)', () => {
|
||||||
|
const violations = probeViolations('probe.txt', 'no final newline');
|
||||||
|
assert.ok(violations.some((v) => /missing final newline/.test(v)), `got: ${violations.join('; ')}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a blank line at the end of the file is reported (mutation probe)', () => {
|
||||||
|
const violations = probeViolations('probe.txt', 'line\n\n');
|
||||||
|
assert.ok(violations.some((v) => /blank line at end of file/.test(v)), `got: ${violations.join('; ')}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('invalid JSON is reported (mutation probe)', () => {
|
||||||
|
const violations = probeViolations('probe.json', '{"a": 1,}\n');
|
||||||
|
assert.ok(violations.some((v) => /invalid JSON/.test(v)), `got: ${violations.join('; ')}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a duplicate JSON key is reported (mutation probe)', () => {
|
||||||
|
const violations = probeViolations('probe.json', '{"a": 1, "a": 2}\n');
|
||||||
|
assert.ok(violations.some((v) => /duplicate JSON key/.test(v)), `got: ${violations.join('; ')}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('odd JSON indentation is reported (mutation probe)', () => {
|
||||||
|
const violations = probeViolations('probe.json', '{\n "a": 1,\n "b": 2\n}\n');
|
||||||
|
assert.ok(violations.some((v) => /2 spaces per level/.test(v)), `got: ${violations.join('; ')}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('binary files are out of policy scope (mutation probe)', () => {
|
||||||
|
const violations = probeViolations('probe.bin', 'a\0b');
|
||||||
|
assert.deepEqual(violations, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a conforming file yields no violations (mutation probe)', () => {
|
||||||
|
const violations = probeViolations('probe.json', '{\n "a": 1\n}\n');
|
||||||
|
assert.deepEqual(violations, []);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user