From e6d28f94f0933c412ac5b5ce775664b57bad3dcf Mon Sep 17 00:00:00 2001 From: implementer Date: Sun, 30 Aug 2026 06:12:25 +0000 Subject: [PATCH 1/7] feat: add dependency-free formatting/lint policy suite and root lint script (E00-S05-T01) The formatting-policy suite (tests/formatting-policy.test.mjs) locks in the workspace formatting and lint policy: LF line endings, no BOM, no trailing whitespace, no tab indentation, exactly one final newline, and valid JSON with 2-space indentation and no duplicate keys. Every rule has a mutation probe. The root `lint` script runs the suite; the CI formatting-lint stage executes it. --- package.json | 1 + tests/formatting-policy.test.mjs | 249 +++++++++++++++++++++++++++++++ 2 files changed, 250 insertions(+) create mode 100644 tests/formatting-policy.test.mjs diff --git a/package.json b/package.json index 8b9a001..f5a4b48 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ }, "scripts": { "build": "pnpm -r run build", + "lint": "node --test tests/formatting-policy.test.mjs", "test": "node --test \"tests/**/*.test.mjs\"", "typecheck": "pnpm -r run typecheck" }, diff --git a/tests/formatting-policy.test.mjs b/tests/formatting-policy.test.mjs new file mode 100644 index 0000000..34a8b44 --- /dev/null +++ b/tests/formatting-policy.test.mjs @@ -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, []); +}); -- 2.54.0 From d9b493498edb8228a5fc4a399b28eec181069187 Mon Sep 17 00:00:00 2001 From: implementer Date: Sun, 30 Aug 2026 06:12:25 +0000 Subject: [PATCH 2/7] test: lock in the CI quality baseline stage order with the ci-stages suite (E00-S05-T01) tests/ci-stages.test.mjs asserts that .gitea/workflows/ci.yml declares the seven required PR stages (frozen-install, typecheck, formatting-lint, unit, architecture, postgres-integration, build-apps) in order, that every later stage gates on its predecessor through needs, that each stage runs its expected command (frozen install, pnpm typecheck, pnpm lint, the unit / architecture / postgres-integration node --test runs, the apps-group build with the server artifact check), and that every committed test suite is wired into exactly one stage. Mutation probes prove the assertions are non-vacuous. --- tests/ci-stages.test.mjs | 272 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 272 insertions(+) create mode 100644 tests/ci-stages.test.mjs diff --git a/tests/ci-stages.test.mjs b/tests/ci-stages.test.mjs new file mode 100644 index 0000000..4794284 --- /dev/null +++ b/tests/ci-stages.test.mjs @@ -0,0 +1,272 @@ +/** + * 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. + * - 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'; + +/** + * 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; +} + +/** + * 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 ` :` 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}"`, + ); +}); + +// --------------------------------------------------------------------------- +// 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/); +}); -- 2.54.0 From 89fe0b52e68510850e43c73eff7870a7d3517107 Mon Sep 17 00:00:00 2001 From: implementer Date: Sun, 30 Aug 2026 06:12:25 +0000 Subject: [PATCH 3/7] ci: run the required PR quality stages in order (E00-S05-T01) Restructure .gitea/workflows/ci.yml from a flat list of per-suite jobs into the ordered stage baseline: frozen install -> typecheck -> formatting/lint -> unit -> architecture -> PostgreSQL integration -> build of the admin and server applications. Each stage gates on its predecessor through needs, so the frozen install runs before every later stage and the pipeline halts on the first failing stage. The docker-gated real-stack probes in the postgres-integration (and container) suites keep running where a Docker daemon is available and skipping cleanly otherwise. --- .gitea/workflows/ci.yml | 474 ++++++++++++++++------------------------ 1 file changed, 187 insertions(+), 287 deletions(-) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index b92c349..2ff5ade 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -5,9 +5,34 @@ on: push: branches: [main] +# 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). Container/Compose smoke on +# main/release branches and the Docker Compose baseline stack (E00-S02) stay +# out of scope for this stage list. + 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: - name: Frozen lockfile install + name: Stage 1 — Frozen lockfile install (E00-S05-T01) runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -22,11 +47,10 @@ jobs: - name: Verify workspace groups run: pnpm -r list --depth -1 - # E00-S02-T08: the static assertions of tests/secrets-not-embedded.test.mjs - # gate every PR (the docker-gated layer-scan probe inside the same file runs - # where a Docker daemon is available and skips cleanly otherwise). - secrets-not-embedded: - name: Secrets not embedded (E00-S02-T08) + # Stage 2 — typecheck (E00-S05-T01). + typecheck: + name: Stage 2 — Typecheck (E00-S05-T01) + needs: frozen-install runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -34,290 +58,78 @@ jobs: uses: actions/setup-node@v4 with: 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@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 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@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 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 - - # 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 + - name: Run the config-schema unit suite run: node --test tests/config-schema.test.mjs - - # E00-S04-T05: the static assertions of tests/env-example.test.mjs gate every - # PR — the suite locks in the committed `.env.example` template: it exists at - # the repo root, is un-ignored in .gitignore (real `.env` files stay ignored - # while the example is tracked), documents every configuration environment - # source (HOST/PORT/DATABASE_URL/EPPP_SESSION_SECRET), and contains - # placeholder values only — no credential URI, no long secret-looking value, - # 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 + - name: Run the config-startup-error unit suite + run: node --test tests/config-startup-error.test.mjs + - name: Run the config-log-redaction unit suite + run: node --test tests/config-log-redaction.test.mjs + - name: Run the config-env-adapter unit suite + run: node --test tests/config-env-adapter.test.mjs + - name: Run the env-example unit suite run: node --test tests/env-example.test.mjs - # E00-S03-T01: the static assertions of tests/compose-config.test.mjs (db - # image pinned to postgres:18.6-bookworm, health gate, volume persistence, - # build platforms) gate every PR (the docker-gated real-stack probes inside - # the same file run where a Docker daemon is available and skip cleanly - # otherwise). - compose-config: - name: Compose config (E00-S03-T01) + # Stage 5 — architecture tests (E00-S05-T01). Static structure and policy + # suites: dependency boundaries, workspace layout/configuration, strict + # TypeScript base, engine/TypeScript pins, root commands, frozen-install + # clean clone, container definition structure, and the CI baseline itself. + architecture: + name: Stage 5 — Architecture tests (E00-S05-T01) + needs: unit runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -325,5 +137,93 @@ jobs: uses: actions/setup-node@v4 with: 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: 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 + - 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@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 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@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 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 -- 2.54.0 From f3cd70e45d591861109cbb20a4194aafec386f6f Mon Sep 17 00:00:00 2001 From: implementer Date: Sun, 30 Aug 2026 06:12:25 +0000 Subject: [PATCH 4/7] docs: document the lint command and the CI quality baseline stages (E00-S05-T01) The non-container guide now covers pnpm lint (the formatting/lint policy) in the Test and clean-clone smoke sections and describes the ordered PR CI stages locked in by tests/ci-stages.test.mjs. --- docs/development/non-container.md | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/docs/development/non-container.md b/docs/development/non-container.md index f85c1a5..d172635 100644 --- a/docs/development/non-container.md +++ b/docs/development/non-container.md @@ -143,11 +143,22 @@ node /dist/index.js # e.g. node packages/core/dist/index.js ```sh pnpm test +pnpm lint ``` -`pnpm test` runs the `node:test` suites under `tests/` (currently -`tests/architecture-import.test.mjs`, 10 tests) with zero extra dependencies. -This is also the suite that enforces the dependency-boundary rule. +`pnpm test` runs the `node:test` suites under `tests/` with zero extra +dependencies (this is also the suite that enforces the dependency-boundary +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 @@ -157,7 +168,8 @@ corepack enable pnpm install --frozen-lockfile # exit 0, lockfile untouched pnpm build # 5/5 packages emit dist/, 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 ``` -- 2.54.0 From 13b1548df8f9fb912ddbb96aa468677102b45759 Mon Sep 17 00:00:00 2001 From: implementer Date: Sun, 30 Aug 2026 06:22:26 +0000 Subject: [PATCH 5/7] ci: build the config and database-postgres packages before the architecture suites (E00-S05-T01) The strict-tsconfig suite typechecks apps/server with tsc --noEmit, which resolves @personal-blog/config and @personal-blog/database-postgres through their compiled dist/ type declarations. On a fresh checkout dist/ does not exist, so the architecture stage must build those two packages first (the same prerequisite the unit and postgres-integration stages already declare). --- .gitea/workflows/ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 2ff5ade..e68629a 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -141,6 +141,8 @@ jobs: 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 -- 2.54.0 From d0c3b6a83de488888ec32546f5c4455e15b5938e Mon Sep 17 00:00:00 2001 From: implementer Date: Sun, 30 Aug 2026 06:29:43 +0000 Subject: [PATCH 6/7] ci: pin third-party actions to full commit SHAs and declare minimal workflow permissions (E00-S05-T01) - actions/checkout pinned to 11bd71901bbe5b1630ceea73d27597364c9af683 (v4.2.2) - actions/setup-node pinned to 1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a (v4.2.0) - workflow-level permissions: contents: read (the pipeline only reads the repo) - no floating @v4 tags remain anywhere in the workflow --- .gitea/workflows/ci.yml | 43 +++++++++++++++++++++++++---------------- 1 file changed, 26 insertions(+), 17 deletions(-) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index e68629a..b24f95f 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -5,6 +5,12 @@ on: push: 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 @@ -23,9 +29,12 @@ on: # compiled artifact # # The stage order, the `needs` chain and the tests/ coverage are locked in by -# tests/ci-stages.test.mjs (architecture stage). Container/Compose smoke on -# main/release branches and the Docker Compose baseline stack (E00-S02) stay -# out of scope for this stage list. +# 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: # Stage 1 — frozen install (E00-S05-T01). Runs before every later stage: the @@ -35,9 +44,9 @@ jobs: name: Stage 1 — Frozen lockfile install (E00-S05-T01) runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Install Node.js 24 - uses: actions/setup-node@v4 + uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0 with: node-version: '24' - name: Enable pnpm (corepack, pinned to 11.23.0 via packageManager) @@ -53,9 +62,9 @@ jobs: needs: frozen-install runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Install Node.js 24 - uses: actions/setup-node@v4 + uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0 with: node-version: '24' - name: Enable pnpm (corepack, pinned to 11.23.0 via packageManager) @@ -74,9 +83,9 @@ jobs: needs: typecheck runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Install Node.js 24 - uses: actions/setup-node@v4 + uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0 with: node-version: '24' - name: Enable pnpm (corepack, pinned to 11.23.0 via packageManager) @@ -97,9 +106,9 @@ jobs: needs: formatting-lint runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Install Node.js 24 - uses: actions/setup-node@v4 + uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0 with: node-version: '24' - name: Enable pnpm (corepack, pinned to 11.23.0 via packageManager) @@ -132,9 +141,9 @@ jobs: needs: unit runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Install Node.js 24 - uses: actions/setup-node@v4 + uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0 with: node-version: '24' - name: Enable pnpm (corepack, pinned to 11.23.0 via packageManager) @@ -187,9 +196,9 @@ jobs: needs: architecture runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Install Node.js 24 - uses: actions/setup-node@v4 + uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0 with: node-version: '24' - name: Enable pnpm (corepack, pinned to 11.23.0 via packageManager) @@ -216,9 +225,9 @@ jobs: needs: postgres-integration runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Install Node.js 24 - uses: actions/setup-node@v4 + uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0 with: node-version: '24' - name: Enable pnpm (corepack, pinned to 11.23.0 via packageManager) -- 2.54.0 From 10ea1ef3db678f606ae42c1848336bd174fc031c Mon Sep 17 00:00:00 2001 From: implementer Date: Sun, 30 Aug 2026 06:29:43 +0000 Subject: [PATCH 7/7] test: lock in the action SHA pins and workflow-level permissions with the ci-stages suite (E00-S05-T01) - assertHardening: every uses: ref is a full 40-char commit SHA equal to the committed PINNED_ACTIONS table (no floating tags) and the workflow declares a top-level permissions: contents: read block - mutation probes: reverting a pin to @v4, swapping a pinned SHA or removing the permissions block all fail --- tests/ci-stages.test.mjs | 98 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 98 insertions(+) diff --git a/tests/ci-stages.test.mjs b/tests/ci-stages.test.mjs index 4794284..0103250 100644 --- a/tests/ci-stages.test.mjs +++ b/tests/ci-stages.test.mjs @@ -15,6 +15,13 @@ * 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 @@ -54,6 +61,16 @@ const REQUIRED_STAGES = [ /** 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 @@ -106,6 +123,56 @@ function suitesNamedInRuns(runs) { return out; } +/** Extracts the `uses:` refs from the workflow, e.g. "actions/checkout@". */ +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. @@ -231,6 +298,10 @@ test('the root lint script runs the formatting-policy suite', () => { ); }); +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 // --------------------------------------------------------------------------- @@ -270,3 +341,30 @@ test('reordering the stages fails the baseline (mutation probe)', () => { 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/); +}); -- 2.54.0