Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .github/workflows/ci-cd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,21 @@ jobs:
run: node .harness/scripts/ci/69-validate-audit-transparency-gate.mjs --verbose


# GT-692 — a deployable image must not ship the tree its BUILD needed.
#
# All four images copied the whole workspace `node_modules` into their runtime
# stage and then ran `chown -R` over it, which in Docker rewrites every file
# into a new layer: 586 MB of pure duplicate on core-api alone. The cost was
# paid in ANOTHER repository — the Tracker's `Deploy (kind + Helm + smoke)`
# died importing this image with `no space left on device`.
#
# This checks the SHAPE, which is the cause, in seconds and without Docker.
# The measured sizes live in `runtime-image-budgets.json`; a real size budget
# needs a Docker-building job, and the baseline is written down for the day
# one exists.
- name: Deployable images ship no build tree (GT-692)
run: node .harness/scripts/ci/70-validate-runtime-image-shape.mjs --verbose

# CD gate = the evolith-cli unit suite (fast, deterministic). The full e2e
# (env-sensitive: spawns servers, loads rulesets) is covered by the
# dedicated sdk-cli-ci.yml e2e job + the per-flow E2E playbooks, not here.
Expand Down
2 changes: 1 addition & 1 deletion .harness/agents/discovery-agents.es.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

> **Navegación Bilingüe:** [English Version](./discovery-agents.md)

Los siguientes agentes soportan el Architecture Planning Gate (Fase 00) y la subfase Knowledge-First Discovery (01.1). Cada agente sigue la regla de Calidad de Actualización de Agente: alcance, entradas, salidas, restricciones, handoff, checklist de validación y formato de auditoría.
Los siguientes agentes soportan el Architecture Planning Gate (Fase 00). Cada agente sigue la regla de Calidad de Actualización de Agente: alcance, entradas, salidas, restricciones, handoff, checklist de validación y formato de auditoría.

| Agente | Alcance | Entradas | Salidas | Handoff A |
|--------|---------|----------|---------|-----------|
Expand Down
2 changes: 1 addition & 1 deletion .harness/agents/discovery-agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

> **Bilingual Navigation:** [Versión en Español](./discovery-agents.es.md)

The following agents support the Architecture Planning Gate (Phase 00) and the Knowledge-First Discovery subphase (01.1). Each agent follows the Agent Update Quality rule: scope, inputs, outputs, constraints, handoff, validation checklist, and audit output format.
The following agents support the Architecture Planning Gate (Phase 00). Each agent follows the Agent Update Quality rule: scope, inputs, outputs, constraints, handoff, validation checklist, and audit output format.

| Agent | Scope | Inputs | Outputs | Handoff To |
|-------|-------|--------|---------|------------|
Expand Down
10 changes: 8 additions & 2 deletions .harness/playbooks/sdlc-deep-audit.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,10 @@ function auditCorpus() {
// ── 2. MODELO SDLC EJECUTABLE ────────────────────────────────────────

function auditSdlc() {
const sdlcDir = "reference/core/sdlc/sdlc";
// The segment was doubled: the SDLC surface lives at `reference/core/sdlc`, and
// `.../sdlc/sdlc` has never existed. `exists()` made that a silent zero rather than an
// error, so this dimension was scored over no files at all.
const sdlcDir = "reference/core/sdlc";
const files = exists(sdlcDir) ? walk(sdlcDir) : [];

const phaseFiles = files.filter(f => f.match(/phase-0[1-5]/i) || f.match(/fase-0[1-5]/i));
Expand Down Expand Up @@ -233,7 +236,10 @@ function auditEvaluationEngine() {

function auditClientIngestion() {
// Check for client manifest / schema that external projects use
const schemaDir = "rulesets/schema";
// GT-707-era layout: `rulesets/` moved under `src/` (ADR-0048). This path was left
// behind, so `exists()` is false and the audit reports ZERO client schemas over a
// directory that holds 50 of them.
const schemaDir = "src/rulesets/schema";
const schemas = exists(schemaDir) ? walk(schemaDir) : [];
const clientSchemaFiles = schemas.filter(f => f.endsWith(".schema.json") && !f.includes("node_modules"));

Expand Down
184 changes: 184 additions & 0 deletions .harness/scripts/ci/70-validate-runtime-image-shape.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
#!/usr/bin/env node

/**
* GT-692 — a deployable image must not ship the tree its BUILD needed.
*
* ## What this exists to stop happening again
*
* Every one of this repository's four images copied the whole workspace
* `node_modules` — 659 MB, `typescript`, `eslint`, `jest` and `@types/*` included —
* into its runtime stage, and then ran a recursive `chown -R` over it, which in
* Docker rewrites every file into a NEW layer. Measured on `core-api`: that single
* `RUN` was **586 MB**, a byte-for-byte duplicate of everything above it.
*
* The cost was not hypothetical and it was not paid here. The Tracker's
* `Deploy (kind + Helm + smoke)` job died importing `evolith-core-api` into a kind
* node with `ctr: failed to extract layer … no space left on device`, on paths that
* name the cause outright — `@types/node/quic.d.ts`, `@sinonjs/commons/…` and
* `get-intrinsic/CHANGELOG.md`: two declaration trees and a test-double library
* being unpacked into a production image.
*
* ## What it checks, and what it deliberately does not
*
* It checks the SHAPE of every deployable Dockerfile, which is the cause:
*
* 1. the builder prunes development dependencies before the runner copies them;
* 2. no recursive `chown` over a copied tree — ownership is set by `COPY --chown`.
*
* It does NOT check image size, and that gap is deliberate rather than overlooked.
* A size budget requires building four images, which needs Docker and minutes; this
* guard runs in seconds anywhere. Shape is what regresses when someone adds an image
* by copying an existing Dockerfile — size is the symptom of exactly these two lines.
* The measured sizes are recorded in `runtime-image-budgets.json` so the day a
* Docker-building job wants a budget, the baseline is already written down and not
* re-derived from memory.
*
* ## Anti-vacuous pass
*
* The Dockerfile set is discovered, never listed, and asserted through
* `assertScannedPerSource`: zero Dockerfiles found is a hard failure, because a guard
* that scanned nothing has certified nothing.
*
* Usage:
* node .harness/scripts/ci/70-validate-runtime-image-shape.mjs
* node .harness/scripts/ci/70-validate-runtime-image-shape.mjs --verbose
*
* Exit codes:
* 0 - every deployable image prunes, and none rewrites a copied tree with chown -R
* 1 - an image ships development dependencies, duplicates a tree, or none was found
*/

import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
import { join, relative, resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';

import { REPO_ROOT } from '../lib/paths.mjs';
import { assertScannedPerSource, ZeroCoverageError } from '../lib/coverage.mjs';

const HERE = dirname(fileURLToPath(import.meta.url));
export const BUDGETS_PATH = resolve(HERE, 'runtime-image-budgets.json');

const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build', 'coverage', '.claude']);

/** Every Dockerfile in the tree, discovered rather than listed. */
export function findDockerfiles(root) {
const found = [];
(function walk(dir) {
let entries;
try {
entries = readdirSync(dir, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
const full = join(dir, entry.name);
if (entry.isDirectory()) {
if (SKIP_DIRS.has(entry.name)) continue;
// A nested checkout is not part of this repository's tree (see guard 64).
if (existsSync(join(full, '.git'))) continue;
walk(full);
} else if (entry.name === 'Dockerfile') {
found.push(relative(root, full));
}
}
})(root);
return found.sort();
}

/**
* The two shape rules, applied to one Dockerfile's text.
*
* Pure so the unit test can drive it with strings instead of scaffolding images.
*/
export function inspectShape(text) {
const lines = text.split('\n');
const findings = [];

const multiStage = /^FROM\s+\S+\s+AS\s+\w+/im.test(text);
if (!multiStage) {
// A single-stage image has no builder to prune; it is a different shape and this
// guard has nothing to say about it. Reported so the denominator stays honest.
return { findings, applicable: false, prunes: false };
}

const prunes = /npm\s+prune\s+--omit=dev|npm\s+ci\s+[^\n]*--omit=dev|npm\s+install\s+[^\n]*--omit=dev/.test(text);
if (!prunes) {
findings.push({
rule: 'prune',
message:
'the runner receives the build tree unpruned — add `RUN npm prune --omit=dev` at the end of the ' +
'builder stage, or install with `--omit=dev`. Shipping `typescript`, `eslint` and `jest` into a ' +
'runtime image is what exhausted a consumer\'s kind node.',
});
}

for (const [i, line] of lines.entries()) {
// `chown -R` inside a RUN duplicates every file it touches into a new layer.
// Ownership belongs on the COPY that writes the files.
if (/^\s*(RUN|&&)?\s*.*\bchown\s+-R\b/.test(line) && !/^\s*#/.test(line)) {
findings.push({
rule: 'chown',
line: i + 1,
message:
`recursive chown at line ${i + 1} — it rewrites every file into a NEW layer (586 MB on core-api). ` +
'Create the user before the copies and use `COPY --chown=<user>:<group>` instead.',
});
}
}

return { findings, applicable: true, prunes };
}

function main() {
const verbose = process.argv.includes('--verbose');
const root = REPO_ROOT;

console.log('🐳 Runtime image shape — a deployable image must not ship its build tree (GT-692)');

const dockerfiles = findDockerfiles(root);
const budgets = existsSync(BUDGETS_PATH) ? JSON.parse(readFileSync(BUDGETS_PATH, 'utf8')) : { images: [] };

try {
assertScannedPerSource(
{ Dockerfiles: dockerfiles.length, 'recorded budgets': (budgets.images ?? []).length },
{ what: 'deployable image inputs' },
);
} catch (err) {
if (err instanceof ZeroCoverageError) {
console.error(`❌ ${err.message}`);
process.exit(1);
}
throw err;
}

const problems = [];
let multiStage = 0;

for (const rel of dockerfiles) {
const { findings, applicable } = inspectShape(readFileSync(resolve(root, rel), 'utf8'));
if (applicable) multiStage += 1;
for (const f of findings) problems.push({ file: rel, ...f });
if (verbose) {
console.log(` · ${rel}: ${applicable ? (findings.length ? `${findings.length} finding(s)` : 'clean') : 'single-stage, not applicable'}`);
}
}

console.log(` ${dockerfiles.length} Dockerfile(s) scanned, ${multiStage} multi-stage; ${problems.length} finding(s).`);
console.log(
` recorded sizes (${budgets.measuredOn ?? 'undated'}): ` +
(budgets.images ?? []).map((i) => `${i.id} ${i.after}`).join(' · '),
);

if (problems.length > 0) {
console.error(`❌ ${problems.length} deployable image(s) ship or duplicate what they should not:`);
for (const p of problems) console.error(` - ${p.file}: ${p.message}`);
process.exit(1);
}

console.log('✓ 70-validate-runtime-image-shape: every multi-stage image prunes, and none rewrites a copied tree.');
process.exit(0);
}

if (process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url))) {
main();
}
43 changes: 43 additions & 0 deletions .harness/scripts/ci/runtime-image-budgets.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
{
"$comment": [
"GT-692 — the measured size of every deployable image, before and after the runtime-stage fix.",
"This file is a RECORD, not yet a gate: `70-validate-runtime-image-shape.mjs` checks the two lines",
"that cause the size (prune + no recursive chown) and prints these numbers, but does not build",
"images to compare against them. A real budget check needs a Docker-building job; when one exists,",
"the baseline is already written down here instead of being re-derived from memory.",
"Measured locally with `docker build` on the same tree, same day, same base image."
],
"measuredOn": "2026-08-19",
"method": "docker build -f <dockerfile> -t <tag> . ; docker images --format '{{.Size}}'",
"images": [
{
"id": "core-api",
"dockerfile": "src/apps/core-api/Dockerfile",
"before": "1.96GB",
"after": "862MB",
"boot": "runs; GET /health -> HTTP 200; corpus loaded (413 rules); 0 restarts"
},
{
"id": "agent-runtime-api",
"dockerfile": "src/apps/agent-runtime-api/Dockerfile",
"before": "2.49GB",
"after": "1.13GB",
"boot": "runs; GET /health -> HTTP 200"
},
{
"id": "mcp-server",
"dockerfile": "src/packages/mcp-server/Dockerfile",
"before": "1.89GB",
"after": "825MB",
"boot": "runs; 'Evolith MCP HTTP server listening'. NOTE: the prune first exposed `Cannot find module 'keyv'` — a runtime dependency of `@nestjs/cache-manager` that mcp-server never declared. Declared as a production dependency; the prune found the defect, it did not create it."
},
{
"id": "cli",
"dockerfile": "src/sdk/cli/Dockerfile",
"before": "2.00GB",
"after": "890MB",
"boot": "runs; `--version` -> 1.3.2"
}
],
"total": { "before": "8.34GB", "after": "3.71GB", "saved": "4.63GB" }
}
2 changes: 1 addition & 1 deletion AGENTS.es.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ El enrutador frontal principal para el ecosistema BMAD de Evolith. Todas las int

## Agentes de Intake y Discovery (Fases 00 y 01.1)

Los agentes que soportan el Architecture Planning Gate (Fase 00) y la subfase Knowledge-First Discovery (01.1) han sido extraídos a un archivo dedicado para optimizar la carga de contexto.
Los agentes que soportan el Architecture Planning Gate (Fase 00) han sido extraídos a un archivo dedicado para optimizar la carga de contexto.

> **Ver:** [`.harness/agents/discovery-agents.es.md`](./.harness/agents/discovery-agents.es.md) para la lista completa de agentes, alcances, entradas, salidas y handoffs.

Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ The primary frontend router for the Evolith BMAD ecosystem. All raw user intents

## Intake and Discovery Agents (Phases 00 and 01.1)

The agents supporting the Architecture Planning Gate (Phase 00) and the Knowledge-First Discovery subphase (01.1) have been extracted to a dedicated file to optimize context loading.
The agents supporting the Architecture Planning Gate (Phase 00) have been extracted to a dedicated file to optimize context loading.

> **See:** [`.harness/agents/discovery-agents.md`](./.harness/agents/discovery-agents.md) for the full list of agents, scopes, inputs, outputs, and handoffs.

Expand Down
12 changes: 12 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading