@starci/hfs 4.6.0 → 4.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +9 -0
- package/ci/cli.mjs +2 -0
- package/ci/local.mjs +148 -0
- package/coverage/cli.mjs +49 -0
- package/package.json +2 -1
- package/release/candidate-ci.mjs +111 -0
- package/release/gate.mjs +4 -5
- package/release/push.mjs +10 -1
- package/runtime/knowledge/hfs/canon-pins.yaml +9 -4
- package/runtime/knowledge/hfs/ci-standard.yaml +103 -0
- package/runtime/knowledge/hfs/rules.yaml +19 -2
- package/runtime/modules/kernel/failure-codes.yaml +10 -0
- package/runtime/modules/schemas/application-stacks.schema.yaml +24 -0
- package/runtime/packages/cli/src/catalog.generated.mjs +123 -30
- package/runtime/scripts/hfs/lcov-merge.mjs +134 -0
- package/runtime/scripts/hfs/rules/a15-findings.mjs +3 -0
- package/runtime/scripts/hfs/rules/ci-standard.mjs +202 -0
- package/runtime/scripts/hfs/rules/coverage-below.mjs +1 -1
- package/runtime/scripts/hfs/rules/deploy-workflow.mjs +14 -5
- package/runtime/scripts/hfs/rules/workflow-contexts.mjs +1 -1
- package/scaffold/app.mjs +3 -2
- package/src/main.mjs +4 -2
- package/sync/ci-commands.mjs +42 -25
- package/templates/app/ci-workflows/build-cache-save.yml +8 -0
- package/templates/app/ci-workflows/github/workflows/ci.yml +49 -9
- package/templates/app/ci-workflows/install-steps.yml +3 -2
- package/templates/app/ci-workflows/jest-cache-save.yml +6 -0
- package/templates/app/ci-workflows/jest-cache.yml +4 -4
- package/templates/app/ci-workflows-lite/github/workflows/ci.yml +50 -10
- package/templates/app/hooks/husky/pre-push +4 -2
- package/release/ci-red.mjs +0 -102
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 4.7.1 - 2026-10-18
|
|
4
|
+
- Fixed: the package files list carries coverage/ (4.7.0 shipped `app coverage merge` without coverage/cli.mjs); the lint upload condition of the managed CI names the per-side report with format() instead of a nested ${{ }} that actionlint reads as always true.
|
|
5
|
+
|
|
6
|
+
## 4.7.0 - 2026-10-18
|
|
7
|
+
- Added: `app release push --execute` pushes the release sha to `release/candidate-<version>` and moves the branch and the tag only when its ci.yml run is green (RELEASE_CANDIDATE_CI_RED / _TIMEOUT, RELEASE_CI_UNKNOWN; the owner's sha-bound override record is the emergency path); the managed ci.yml and pre-push hook carry the candidate ref; R301 CI_STANDARD in the app check.
|
|
8
|
+
- Added: the managed CI shards the unit run (`ci.jobs.test.shards`, default 6), lints each side in its own job and merges coverage in a coverage job (`starci app coverage merge`); `ci.jobs.<job>.timeoutMinutes`; jest/build caches keyed by the lockfile and saved always; `starci app ci local` runs the rendered jobs locally in parallel.
|
|
9
|
+
- Fixed: a scaffolded app root overrides `@graphql-tools/utils` to the canon pin 12.1.0, closing GHSA-7mx3-vvmw-hjmv (npm audit high) under @nestjs/graphql 13.x.
|
|
10
|
+
- Changed: `app release gate` no longer reads the default-branch CI (release/ci-red.mjs removed); the app trigger check allows the candidate branches.
|
|
11
|
+
|
|
3
12
|
## 4.6.0 - 2026-10-18
|
|
4
13
|
- Added: `services.codecov|sonar.badgeToken` README badge tokens and `ci.github.visibility` with a private-repository notice (sync, ci verify); `ci.vars` `ownerValue: true` (var sync skips, var set reads stdin, verify checks the name).
|
|
5
14
|
- Fixed: the app release gate counts tag-push CI runs reachable from origin/main, main or HEAD; the secret scans exempt only declared badge tokens.
|
package/ci/cli.mjs
CHANGED
|
@@ -44,6 +44,8 @@ function parse(argv) {
|
|
|
44
44
|
|
|
45
45
|
/** Manifest and sealed key reads remain offline; only github.mjs may invoke the GitHub CLI. */
|
|
46
46
|
export async function ciMain(argv, { cwd = process.cwd(), stdout = (s) => process.stdout.write(s), stderr = (s) => process.stderr.write(s), stdin, env = process.env, tools = {} } = {}) {
|
|
47
|
+
// `ci local` runs the rendered CI on this machine (local.mjs): no manifest custody and no GitHub.
|
|
48
|
+
if (argv[0] === 'local') return (await import('./local.mjs')).ciLocalMain(argv.slice(1), { cwd, stdout, stderr, env });
|
|
47
49
|
try {
|
|
48
50
|
const parsed = parse(argv);
|
|
49
51
|
let doc, declaration, sealed;
|
package/ci/local.mjs
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// local.mjs - `starci app ci local [--jobs <id,...>] [--parallel <n>] [--dry-run] [--json]`: predict the CI result while GitHub runs it. It reads the app's
|
|
2
|
+
// managed .github/workflows/ci.yml (the CI standard's jobs: fast, lint per side, test per shard, coverage, build) and runs the `run:` steps of every job
|
|
3
|
+
// instance on this machine, matrix values substituted, independent instances in parallel and a job after the jobs it `needs`:
|
|
4
|
+
// - steps GitHub alone can run are skipped and listed: `uses:` actions (checkout, caches, artifacts, Codecov, Sonar), the install (`npm ci`: the local
|
|
5
|
+
// install is used), the lint baseline (STARCI_BASE_REF is set to origin/<default branch>), and every step whose `if:` or text needs GitHub context
|
|
6
|
+
// (a Sonar token, a tag ref, step outputs);
|
|
7
|
+
// - the unit shards write their lcov to be/coverage/shards/shard-<i> (on GitHub each shard has its own runner and uploads it), which is where the
|
|
8
|
+
// coverage job's `starci app coverage merge` reads them;
|
|
9
|
+
// - sonar and db-types run only when --jobs names them.
|
|
10
|
+
// Each instance logs to <job>[-<matrix>].log in --logs <dir>, else a new <tmp>/starci-ci-local-* directory. Exit 0 every instance green, 1 one red, 2 bad usage or no ci.yml. Run steps through bash.
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
import os from 'node:os';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import { spawn } from 'node:child_process';
|
|
15
|
+
import { parseYaml } from '../runtime/engine/yaml.mjs';
|
|
16
|
+
|
|
17
|
+
const DEFAULT_JOBS = ['fast', 'lint', 'test', 'coverage', 'build'];
|
|
18
|
+
const GITHUB_ONLY_IF = /SONAR_TOKEN\s*!=\s*''|refs\/tags|startsWith\(github\.ref|steps\.|hashFiles|github\.event/;
|
|
19
|
+
const LOCAL_IF = /^(?:\$\{\{\s*)?(?:always\(\)|!cancelled\(\)|env\.SONAR_TOKEN\s*==\s*'')(?:\s*\}\})?$/;
|
|
20
|
+
|
|
21
|
+
function parseArgs(argv) {
|
|
22
|
+
const opts = { logs: null, jobs: null, parallel: Math.max(1, Math.min(4, Math.floor(os.cpus().length / 2))), dryRun: false, json: false };
|
|
23
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
24
|
+
const arg = argv[index];
|
|
25
|
+
if (arg === '--dry-run') opts.dryRun = true;
|
|
26
|
+
else if (arg === '--json') opts.json = true;
|
|
27
|
+
else if (arg === '--jobs' || arg === '--parallel' || arg === '--logs') {
|
|
28
|
+
const value = argv[index + 1];
|
|
29
|
+
if (!value || value.startsWith('--')) throw new Error(`${arg} needs a value`);
|
|
30
|
+
if (arg === '--logs') opts.logs = value;
|
|
31
|
+
else if (arg === '--jobs') opts.jobs = value.split(',').map((job) => job.trim()).filter(Boolean);
|
|
32
|
+
else if (!/^[1-9]\d*$/.test(value)) throw new Error('--parallel takes a positive integer');
|
|
33
|
+
else opts.parallel = Number(value);
|
|
34
|
+
index += 1;
|
|
35
|
+
} else throw new Error(`unknown argument ${arg}`);
|
|
36
|
+
}
|
|
37
|
+
return opts;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Every combination of a one-level matrix ({side: [be, fe]} -> [{side: be}, {side: fe}]); [{}] without one. */
|
|
41
|
+
function combinations(matrix) {
|
|
42
|
+
const keys = Object.keys(matrix ?? {}).filter((key) => Array.isArray(matrix[key]));
|
|
43
|
+
return keys.reduce((all, key) => all.flatMap((partial) => matrix[key].map((value) => ({ ...partial, [key]: value }))), [{}]);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const substitute = (text, values) => String(text).replace(/\$\{\{\s*matrix\.([A-Za-z0-9_-]+)\s*\}\}/g, (whole, key) => (Object.hasOwn(values, key) ? String(values[key]) : whole));
|
|
47
|
+
|
|
48
|
+
/** The local verdict of one step: {run: command} or {skip: why}. */
|
|
49
|
+
function stepPlan(step, values) {
|
|
50
|
+
const name = step.name ?? (step.uses ? String(step.uses).split('@')[0] : String(step.run ?? '').split('\n')[0].slice(0, 60));
|
|
51
|
+
if (step.uses) return { name, skip: 'a GitHub action' };
|
|
52
|
+
const condition = step.if === undefined ? null : String(step.if).trim();
|
|
53
|
+
if (condition !== null && (GITHUB_ONLY_IF.test(condition) || !LOCAL_IF.test(condition))) return { name, skip: `if: ${condition} needs GitHub context` };
|
|
54
|
+
let command = substitute(step.run ?? '', values);
|
|
55
|
+
if (/^npm ci\b/.test(command.trim())) return { name, skip: 'the install: the local node_modules is used' };
|
|
56
|
+
if (command.includes('$GITHUB_ENV') || command.includes('GITHUB_OUTPUT')) return { name, skip: 'writes GitHub step state' };
|
|
57
|
+
if (/\$\{\{/.test(command)) return { name, skip: 'needs a GitHub expression' };
|
|
58
|
+
if (name === 'unit' && values.shard !== undefined) command = `${command} --coverageDirectory coverage/shards/shard-${values.shard}`;
|
|
59
|
+
const env = Object.fromEntries(Object.entries(step.env ?? {}).filter(([, value]) => !String(value).includes('${{')).map(([key, value]) => [key, substitute(value, values)]));
|
|
60
|
+
return { name, run: command, env, cwd: step['working-directory'] ? substitute(step['working-directory'], values) : '.' };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The plan of the workflow: [{id, label, needs, steps: [{name, run|skip}]}], one entry per job instance. */
|
|
64
|
+
function localPlan(workflow, jobs = null) {
|
|
65
|
+
const wanted = jobs ?? DEFAULT_JOBS;
|
|
66
|
+
const plan = [];
|
|
67
|
+
for (const [id, job] of Object.entries(workflow?.jobs ?? {})) {
|
|
68
|
+
if (!wanted.includes(id)) continue;
|
|
69
|
+
const needs = [job.needs ?? []].flat().map(String).filter((need) => wanted.includes(need));
|
|
70
|
+
for (const values of combinations(job.strategy?.matrix)) {
|
|
71
|
+
const suffix = Object.values(values).join('-');
|
|
72
|
+
plan.push({ id, label: suffix ? `${id}-${suffix}` : id, needs, steps: (job.steps ?? []).map((step) => stepPlan(step, values)) });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return plan;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function runStep(step, { root, env, log }) {
|
|
79
|
+
return new Promise((resolve) => {
|
|
80
|
+
fs.appendFileSync(log, `\n$ ${step.run}\n`);
|
|
81
|
+
const child = spawn('bash', ['-c', step.run], { cwd: path.resolve(root, step.cwd), env: { ...env, ...step.env }, windowsHide: true });
|
|
82
|
+
child.stdout.on('data', (chunk) => fs.appendFileSync(log, chunk));
|
|
83
|
+
child.stderr.on('data', (chunk) => fs.appendFileSync(log, chunk));
|
|
84
|
+
child.on('error', (error) => { fs.appendFileSync(log, `\n${error.message}\n`); resolve(1); });
|
|
85
|
+
child.on('close', (code) => resolve(code ?? 1));
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function runInstance(instance, { root, env, logs }) {
|
|
90
|
+
const log = path.join(logs, `${instance.label}.log`);
|
|
91
|
+
fs.writeFileSync(log, `starci app ci local: ${instance.label}\n`);
|
|
92
|
+
const started = Date.now();
|
|
93
|
+
for (const step of instance.steps.filter((entry) => entry.run !== undefined)) {
|
|
94
|
+
const code = await runStep(step, { root, env, log });
|
|
95
|
+
if (code !== 0) return { label: instance.label, ok: false, failed: step.name, seconds: Math.round((Date.now() - started) / 1000), log };
|
|
96
|
+
}
|
|
97
|
+
return { label: instance.label, ok: true, seconds: Math.round((Date.now() - started) / 1000), log };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Run the plan: instances whose needs are all done, `parallel` at a time; an instance whose need failed is not run. */
|
|
101
|
+
async function runPlan(plan, { parallel, root, env, logs, report }) {
|
|
102
|
+
const results = new Map();
|
|
103
|
+
const pending = [...plan];
|
|
104
|
+
const running = new Set();
|
|
105
|
+
const doneJob = (id) => plan.filter((entry) => entry.id === id).every((entry) => results.has(entry.label));
|
|
106
|
+
const jobOk = (id) => plan.filter((entry) => entry.id === id).every((entry) => results.get(entry.label)?.ok);
|
|
107
|
+
await new Promise((resolve) => {
|
|
108
|
+
const pump = () => {
|
|
109
|
+
for (const instance of [...pending]) {
|
|
110
|
+
if (running.size >= parallel) break;
|
|
111
|
+
if (!instance.needs.every(doneJob)) continue;
|
|
112
|
+
pending.splice(pending.indexOf(instance), 1);
|
|
113
|
+
if (!instance.needs.every(jobOk)) { results.set(instance.label, { label: instance.label, ok: false, failed: `needs ${instance.needs.join(', ')}`, seconds: 0 }); continue; }
|
|
114
|
+
const task = runInstance(instance, { root, env, logs }).then((result) => { results.set(instance.label, result); report(result); running.delete(task); pump(); });
|
|
115
|
+
running.add(task);
|
|
116
|
+
}
|
|
117
|
+
if (!pending.length && !running.size) resolve();
|
|
118
|
+
else if (!running.size && pending.length) { for (const instance of pending.splice(0)) results.set(instance.label, { label: instance.label, ok: false, failed: 'its needs never completed', seconds: 0 }); resolve(); }
|
|
119
|
+
};
|
|
120
|
+
pump();
|
|
121
|
+
});
|
|
122
|
+
return plan.map((instance) => results.get(instance.label));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** `starci app ci local`. */
|
|
126
|
+
export async function ciLocalMain(argv, { cwd = process.cwd(), stdout = (s) => process.stdout.write(s), stderr = (s) => process.stderr.write(s), env = process.env } = {}) {
|
|
127
|
+
let opts;
|
|
128
|
+
try { opts = parseArgs(argv); } catch (error) { stderr(`starci app ci local: ${error.message}; usage: starci app ci local [--jobs <id,...>] [--parallel <n>] [--logs <dir>] [--dry-run] [--json]\n`); return 2; }
|
|
129
|
+
const file = path.join(cwd, '.github', 'workflows', 'ci.yml');
|
|
130
|
+
if (!fs.existsSync(file)) { stderr('starci app ci local: no .github/workflows/ci.yml (run starci app sync --write)\n'); return 2; }
|
|
131
|
+
const plan = localPlan(parseYaml(fs.readFileSync(file, 'utf8')), opts.jobs);
|
|
132
|
+
if (!plan.length) { stderr('starci app ci local: no job of the workflow is selected\n'); return 2; }
|
|
133
|
+
if (opts.dryRun) {
|
|
134
|
+
if (opts.json) stdout(`${JSON.stringify(plan)}\n`);
|
|
135
|
+
else for (const instance of plan) stdout(`${instance.label}${instance.needs.length ? ` (after ${instance.needs.join(', ')})` : ''}\n${instance.steps.map((step) => ` ${step.run !== undefined ? 'run ' : 'skip'} ${step.name}${step.skip ? ` - ${step.skip}` : ''}`).join('\n')}\n`);
|
|
136
|
+
return 0;
|
|
137
|
+
}
|
|
138
|
+
const logs = opts.logs ? path.resolve(cwd, opts.logs) : fs.mkdtempSync(path.join(os.tmpdir(), 'starci-ci-local-'));
|
|
139
|
+
fs.mkdirSync(logs, { recursive: true });
|
|
140
|
+
const defaultBranch = 'main';
|
|
141
|
+
const runEnv = { ...env, CI: 'true', STARCI_BASE_REF: env.STARCI_BASE_REF ?? `origin/${defaultBranch}`, SONAR_TOKEN: '' };
|
|
142
|
+
stdout(`starci app ci local: ${plan.length} job instance(s), ${opts.parallel} at a time; logs in ${logs}\n`);
|
|
143
|
+
const results = await runPlan(plan, { parallel: opts.parallel, root: cwd, env: runEnv, logs, report: (result) => { if (!opts.json) stdout(`${result.ok ? 'green' : 'RED '} ${result.label} (${result.seconds}s)${result.ok ? '' : ` at ${result.failed}: ${result.log}`}\n`); } });
|
|
144
|
+
const red = results.filter((result) => !result.ok);
|
|
145
|
+
if (opts.json) stdout(`${JSON.stringify({ ok: red.length === 0, results, logs })}\n`);
|
|
146
|
+
else stdout(red.length ? `starci app ci local: ${red.length} of ${results.length} red: ${red.map((result) => result.label).join(', ')}\n` : `starci app ci local: all ${results.length} green\n`);
|
|
147
|
+
return red.length ? 1 : 0;
|
|
148
|
+
}
|
package/coverage/cli.mjs
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// cli.mjs - `starci app coverage merge --out <lcov> <path>...`: the lcov reports of the sharded unit run (one per `npm test -- --shard=i/N`, downloaded into a
|
|
2
|
+
// directory by the coverage job of the managed CI) merged into the one report the coverage proof, the Codecov upload and the Sonar import read
|
|
3
|
+
// (runtime/scripts/hfs/lcov-merge.mjs: hits summed by line, function and branch identity, one record per source file). A path is an lcov file or a
|
|
4
|
+
// directory searched for every lcov.info below it; no report found is a refusal, so a merge never writes an empty report that would read as "nothing to measure".
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { lcovFilesUnder, mergeLcov } from '../runtime/scripts/hfs/lcov-merge.mjs';
|
|
8
|
+
|
|
9
|
+
const USAGE = 'usage: starci app coverage merge --out <lcov> <lcov-or-dir>... [--cwd <dir>]';
|
|
10
|
+
|
|
11
|
+
function parse(argv) {
|
|
12
|
+
const [action, ...rest] = argv;
|
|
13
|
+
if (action !== 'merge') throw new Error(USAGE);
|
|
14
|
+
const opts = { inputs: [] };
|
|
15
|
+
for (let index = 0; index < rest.length; index += 1) {
|
|
16
|
+
const arg = rest[index];
|
|
17
|
+
if (arg === '--out' || arg === '--cwd') {
|
|
18
|
+
const value = rest[index + 1];
|
|
19
|
+
if (!value || value.startsWith('--')) throw new Error(`${arg} needs a value; ${USAGE}`);
|
|
20
|
+
opts[arg.slice(2)] = value;
|
|
21
|
+
index += 1;
|
|
22
|
+
} else if (arg.startsWith('--')) throw new Error(`unknown flag ${arg}; ${USAGE}`);
|
|
23
|
+
else opts.inputs.push(arg);
|
|
24
|
+
}
|
|
25
|
+
if (!opts.out || !opts.inputs.length) throw new Error(USAGE);
|
|
26
|
+
return opts;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Exit 0 merged, 2 bad usage or no report found. */
|
|
30
|
+
export async function coverageMain(argv, { cwd: baseCwd = process.cwd(), stdout = (s) => process.stdout.write(s), stderr = (s) => process.stderr.write(s) } = {}) {
|
|
31
|
+
let opts;
|
|
32
|
+
try { opts = parse(argv); } catch (error) { stderr(`starci app coverage: ${error.message}\n`); return 2; }
|
|
33
|
+
const cwd = path.resolve(baseCwd, opts.cwd ?? '.');
|
|
34
|
+
const files = [];
|
|
35
|
+
for (const input of opts.inputs) {
|
|
36
|
+
const target = path.resolve(cwd, input);
|
|
37
|
+
if (!fs.existsSync(target)) { stderr(`starci app coverage: ${input} does not exist\n`); return 2; }
|
|
38
|
+
files.push(...lcovFilesUnder(target));
|
|
39
|
+
}
|
|
40
|
+
const out = path.resolve(cwd, opts.out);
|
|
41
|
+
const sources = files.filter((file) => path.resolve(file) !== out);
|
|
42
|
+
if (!sources.length) { stderr(`starci app coverage: no lcov report under ${opts.inputs.join(', ')}; the unit shards wrote none\n`); return 2; }
|
|
43
|
+
const merged = mergeLcov(sources.map((file) => fs.readFileSync(file, 'utf8')));
|
|
44
|
+
fs.mkdirSync(path.dirname(out), { recursive: true });
|
|
45
|
+
fs.writeFileSync(out, merged);
|
|
46
|
+
const records = merged.split('\n').filter((line) => line.startsWith('SF:')).length;
|
|
47
|
+
stdout(`starci app coverage merge: ${sources.length} report(s), ${records} source file(s) -> ${path.relative(cwd, out).replaceAll('\\', '/')}\n`);
|
|
48
|
+
return 0;
|
|
49
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@starci/hfs",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.7.1",
|
|
4
4
|
"description": "The StarCi app command implementation for scaffolding, linting, checking and synchronizing one product repository.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
"release/**",
|
|
23
23
|
"db/**",
|
|
24
24
|
"ci/**",
|
|
25
|
+
"coverage/**",
|
|
25
26
|
"lint/**",
|
|
26
27
|
"scaffold/**",
|
|
27
28
|
"report/**",
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// candidate-ci.mjs - the candidate CI of `starci app release push` (OWNER RULING candidate-ci, 2026-10-18: "never again have main receive a release whose CI is
|
|
2
|
+
// red, with the owner confirming afterwards"). The push sends the exact release sha to refs/heads/release/candidate-<version> first (the managed ci.yml triggers on
|
|
3
|
+
// release/candidate-*), waits for the ci.yml run of THAT sha, and moves the default branch and the tag only when it is green:
|
|
4
|
+
// green the newest non-cancelled run of the sha concluded success
|
|
5
|
+
// red RELEASE_CANDIDATE_CI_RED with the run URL and the failing jobs and steps; nothing reaches the default branch. The candidate ref stays for diagnosis
|
|
6
|
+
// timeout RELEASE_CANDIDATE_CI_TIMEOUT: no run started within the grace, or it did not end within the wait
|
|
7
|
+
// unknown RELEASE_CI_UNKNOWN: GitHub could not be read to the end; a release never proceeds blind
|
|
8
|
+
// Emergency path (documented, owner-only): the owner's `starci release ci-override --sha <sha>` record for this exact sha
|
|
9
|
+
// (<git common dir>/starci-release/<sha>.ci-override.json, schema starci/ci-override@1) lets a red candidate through, and the push says so.
|
|
10
|
+
// Every process goes through ctx.spawn (git, gh); the wait through ctx.sleep and ctx.now.
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
|
|
14
|
+
const CANDIDATE_PREFIX = 'refs/heads/release/candidate-';
|
|
15
|
+
/** The wait of the candidate run: poll interval, the grace for a run to appear, the wait for it to end (an app's ci.yml jobs time out within 45 minutes). */
|
|
16
|
+
const CANDIDATE_WAIT = Object.freeze({ pollSeconds: 30, graceMinutes: 10, waitMinutes: 90 });
|
|
17
|
+
const FIELDS = 'databaseId,headSha,status,conclusion,url,event,createdAt';
|
|
18
|
+
const SHA = /^[a-f0-9]{40}$/u;
|
|
19
|
+
const PASSING = new Set(['success', 'neutral', 'skipped']);
|
|
20
|
+
|
|
21
|
+
/** The candidate ref of the release tag `tag` with the app's tag prefix: refs/heads/release/candidate-<version>. */
|
|
22
|
+
export const candidateRef = (tag, prefix = 'v') => `${CANDIDATE_PREFIX}${String(tag).startsWith(prefix) ? String(tag).slice(prefix.length) : String(tag)}`;
|
|
23
|
+
|
|
24
|
+
function run(ctx, command, args) {
|
|
25
|
+
try { return ctx.spawn(command, args, { cwd: ctx.cwd }); } catch { return { status: null }; }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function jsonOf(result) {
|
|
29
|
+
if (!result || result.error || result.status !== 0) return null;
|
|
30
|
+
try { return JSON.parse(result.stdout); } catch { return null; }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** pending (no run) | running | green | red over the runs of the sha; a cancelled run is no verdict. Pure. */
|
|
34
|
+
export function candidateVerdict(rows) {
|
|
35
|
+
const runs = (rows ?? []).filter((row) => row && row.conclusion !== 'cancelled');
|
|
36
|
+
runs.sort((a, b) => Date.parse(b.createdAt ?? 0) - Date.parse(a.createdAt ?? 0) || Number(b.databaseId) - Number(a.databaseId));
|
|
37
|
+
const newest = runs[0];
|
|
38
|
+
if (!newest) return { state: 'pending', run: null };
|
|
39
|
+
if (newest.status !== 'completed') return { state: 'running', run: newest };
|
|
40
|
+
return { state: newest.conclusion === 'success' ? 'green' : 'red', run: newest };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** One read of the ci.yml runs of `sha`: {ok, state, run} or {ok: false}. */
|
|
44
|
+
function readCandidateRun(ctx, sha) {
|
|
45
|
+
const rows = jsonOf(run(ctx, 'gh', ['run', 'list', '--workflow', 'ci.yml', '--commit', sha, '--limit', '20', '--json', FIELDS]));
|
|
46
|
+
if (!Array.isArray(rows)) return { ok: false };
|
|
47
|
+
return { ok: true, ...candidateVerdict(rows.filter((row) => !row.headSha || row.headSha === sha)) };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The failing jobs and steps of a run: ['job (conclusion): step, step']; [] when unreadable. */
|
|
51
|
+
export function failingJobs(ctx, runRow) {
|
|
52
|
+
if (!/^\d+$/u.test(String(runRow?.databaseId ?? ''))) return [];
|
|
53
|
+
const reply = jsonOf(run(ctx, 'gh', ['run', 'view', String(runRow.databaseId), '--json', 'jobs']));
|
|
54
|
+
return (reply?.jobs ?? []).filter((job) => job.conclusion && !PASSING.has(job.conclusion)).map((job) => {
|
|
55
|
+
const steps = (job.steps ?? []).filter((step) => step.conclusion && !PASSING.has(step.conclusion)).map((step) => step.name);
|
|
56
|
+
return `${job.name} (${job.conclusion})${steps.length ? `: ${steps.join(', ')}` : ''}`;
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Only a complete starci/ci-override@1 record for this exact commit can excuse a red candidate. */
|
|
61
|
+
export function overrideOf(ctx, sha) {
|
|
62
|
+
if (!SHA.test(String(sha))) return null;
|
|
63
|
+
const common = run(ctx, 'git', ['rev-parse', '--git-common-dir']);
|
|
64
|
+
if (common.error || common.status !== 0 || !String(common.stdout ?? '').trim()) return null;
|
|
65
|
+
const file = path.join(path.resolve(ctx.cwd, String(common.stdout).trim()), 'starci-release', `${sha}.ci-override.json`);
|
|
66
|
+
try {
|
|
67
|
+
const record = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
68
|
+
if (record.schema !== 'starci/ci-override@1' || record.sha !== sha || typeof record.reason !== 'string' || !record.reason.trim() || typeof record.by !== 'string' || !record.by.trim() || typeof record.at !== 'string' || !Number.isFinite(Date.parse(record.at))) return null;
|
|
69
|
+
return record;
|
|
70
|
+
} catch { return null; }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Poll the candidate run of `sha` until green or red, or the grace (no run) or the wait passes: {state: green|red|timeout|unstarted|unknown, run}. */
|
|
74
|
+
export async function waitForCandidate(ctx, sha, wait = CANDIDATE_WAIT) {
|
|
75
|
+
const started = ctx.now();
|
|
76
|
+
let readable = null;
|
|
77
|
+
for (;;) {
|
|
78
|
+
const seen = readCandidateRun(ctx, sha);
|
|
79
|
+
if (seen.ok) readable = seen;
|
|
80
|
+
if (seen.ok && (seen.state === 'green' || seen.state === 'red')) return seen;
|
|
81
|
+
const elapsed = ctx.now() - started;
|
|
82
|
+
if (seen.ok && seen.state === 'pending' && elapsed >= wait.graceMinutes * 60_000) return { state: 'unstarted', run: null };
|
|
83
|
+
if (elapsed >= wait.waitMinutes * 60_000) return readable ? { state: 'timeout', run: readable.run } : { state: 'unknown', run: null };
|
|
84
|
+
await ctx.sleep(wait.pollSeconds * 1000);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The candidate step of the push: push HEAD to the candidate ref (through the pre-push hook), wait for its CI. {ok, lines, data: {ref, state, run, failing, override}}.
|
|
90
|
+
* ok only on green, or on red under the owner's override record for this exact sha.
|
|
91
|
+
*/
|
|
92
|
+
export async function candidateStep(ctx, { head, tag, prefix, remote, wait = CANDIDATE_WAIT }) {
|
|
93
|
+
const ref = candidateRef(tag, prefix);
|
|
94
|
+
const lines = [];
|
|
95
|
+
const pushed = run(ctx, 'git', ['push', remote, `+${head}:${ref}`]);
|
|
96
|
+
if (pushed.status !== 0) return { ok: false, lines: [`candidate push of ${head.slice(0, 10)} to ${ref} failed: ${String(pushed.stderr ?? '').trim().split('\n')[0]}`], data: { ref, state: 'push-failed' } };
|
|
97
|
+
lines.push(`candidate pushed ${head.slice(0, 10)} to ${remote} ${ref}; waiting for its ci.yml run`);
|
|
98
|
+
const seen = await waitForCandidate(ctx, head, wait);
|
|
99
|
+
const url = seen.run?.url ? ` ${seen.run.url}` : '';
|
|
100
|
+
if (seen.state === 'green') return { ok: true, lines: [...lines, `candidate CI green${url}`], data: { ref, state: 'green', run: seen.run } };
|
|
101
|
+
if (seen.state === 'red') {
|
|
102
|
+
const failing = failingJobs(ctx, seen.run);
|
|
103
|
+
const record = overrideOf(ctx, head);
|
|
104
|
+
const detail = failing.length ? failing.join('; ') : 'the failing jobs could not be read';
|
|
105
|
+
if (record) return { ok: true, lines: [...lines, `candidate CI RED${url}; failing: ${detail}`, `RELEASE_CANDIDATE_CI_RED overridden by the owner (${record.by}): ${record.reason}`], data: { ref, state: 'red', run: seen.run, failing, override: { by: record.by, reason: record.reason, at: record.at } } };
|
|
106
|
+
return { ok: false, lines: [...lines, `RELEASE_CANDIDATE_CI_RED: the ci.yml run of ${head.slice(0, 10)} on ${ref} is red${url}; failing: ${detail}. Nothing reached the default branch; the candidate ref stays for diagnosis`], data: { ref, state: 'red', run: seen.run, failing } };
|
|
107
|
+
}
|
|
108
|
+
if (seen.state === 'unstarted') return { ok: false, lines: [...lines, `RELEASE_CANDIDATE_CI_TIMEOUT: no ci.yml run started for ${head.slice(0, 10)} on ${ref} within ${wait.graceMinutes} minutes (does .github/workflows/ci.yml trigger on release/candidate-*? run starci app sync)`], data: { ref, state: 'unstarted' } };
|
|
109
|
+
if (seen.state === 'timeout') return { ok: false, lines: [...lines, `RELEASE_CANDIDATE_CI_TIMEOUT: the ci.yml run of ${head.slice(0, 10)} did not end within ${wait.waitMinutes} minutes${url}`], data: { ref, state: 'timeout', run: seen.run } };
|
|
110
|
+
return { ok: false, lines: [...lines, `RELEASE_CI_UNKNOWN: GitHub could not be read for the candidate ${head.slice(0, 10)} on ${ref}; a release never proceeds blind`], data: { ref, state: 'unknown' } };
|
|
111
|
+
}
|
package/release/gate.mjs
CHANGED
|
@@ -10,7 +10,6 @@ import { isPlainObject as plain } from '../runtime/engine/plain-object.mjs';
|
|
|
10
10
|
import { qualityServices } from '../runtime/scripts/hfs/ci-declaration.mjs';
|
|
11
11
|
import { firstLine, lines, tool } from '../stack/context.mjs';
|
|
12
12
|
import { failure, refusal } from '../stack/errors.mjs';
|
|
13
|
-
import { latestDefaultBranchCi, requireGreenCi } from './ci-red.mjs';
|
|
14
13
|
|
|
15
14
|
export const RELEASE_CODE = 'RELEASE_CONFIG_INVALID';
|
|
16
15
|
const SONAR_UNREACHABLE = 'SONAR_UNREACHABLE';
|
|
@@ -346,14 +345,14 @@ function summaryOf(result) {
|
|
|
346
345
|
*/
|
|
347
346
|
export async function releaseGate(ctx, config, options = {}) {
|
|
348
347
|
if (options.sonar) config = { ...config, gates: [...config.gates.filter((gate) => gate.name !== 'sonar'), sonarGate(config.services, ctx.env)] };
|
|
349
|
-
|
|
350
|
-
|
|
348
|
+
// The CI verdict of a release is the candidate run of its own sha, judged by `starci app release push` (candidate-ci.mjs, OWNER RULING candidate-ci 2026-10-18):
|
|
349
|
+
// the gate no longer refuses over the latest default-branch run.
|
|
351
350
|
const facts = inspect(ctx, config, options);
|
|
352
351
|
const common = releaseDir(ctx);
|
|
353
352
|
const recordPath = path.join(common, `${facts.head}.l4.json`);
|
|
354
353
|
const dir = path.join(common, facts.head.slice(0, 12));
|
|
355
|
-
const plan = [...
|
|
356
|
-
const base = { head: facts.head, tag: facts.chosen.tag, record: recordPath, gates: config.gates.map((gate) => gate.name)
|
|
354
|
+
const plan = [...facts.out, ...planLines(config, facts, dir, recordPath)];
|
|
355
|
+
const base = { head: facts.head, tag: facts.chosen.tag, record: recordPath, gates: config.gates.map((gate) => gate.name) };
|
|
357
356
|
if (facts.red.length > 0) return { ok: false, lines: [...plan, ...facts.red.map((item) => `RED: ${item}`), 'nothing was run.'], data: { ...base, red: facts.red, executed: false } };
|
|
358
357
|
if (options.execute !== true) return { ok: true, lines: [...plan, '', 'dry run: no gate was run, nothing written. preconditions OK.'], data: { ...base, red: [], executed: false } };
|
|
359
358
|
const results = await runAll(ctx, config, { dir, platform: options.platform ?? process.platform });
|
package/release/push.mjs
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
// push.mjs - `starci app release push`: sends the default branch and the release tag to the remote in ONE atomic push through the repository's own
|
|
2
2
|
// pre-push hook (never --no-verify). It requires what the hook requires: an annotated release tag on HEAD and the L4 record of the gate for that
|
|
3
3
|
// head and tag. `git push --dry-run --atomic` always runs first (the hook's verdict is the proof) and any failure refuses the push.
|
|
4
|
+
// With --execute the exact sha first goes to its candidate ref refs/heads/release/candidate-<version> and the push waits for the ci.yml run of that sha
|
|
5
|
+
// (candidate-ci.mjs, OWNER RULING candidate-ci 2026-10-18): only a green candidate (or the owner's sha-bound override record) moves the branch and the tag.
|
|
4
6
|
import fs from 'node:fs';
|
|
5
7
|
import path from 'node:path';
|
|
6
8
|
import { firstLine } from '../stack/context.mjs';
|
|
7
9
|
import { failure, refusal } from '../stack/errors.mjs';
|
|
8
10
|
import { annotatedOnHead, git, refName, releaseDir } from './gate.mjs';
|
|
11
|
+
import { candidateRef, candidateStep } from './candidate-ci.mjs';
|
|
9
12
|
|
|
10
13
|
const VERDICT = /RIGHTS_PUSH_NOT_RELEASE|pre-push|failed to push|Everything up|->/u;
|
|
11
14
|
|
|
@@ -95,7 +98,9 @@ export async function releasePush(ctx, config, options = {}) {
|
|
|
95
98
|
const red = [];
|
|
96
99
|
const refs = [...branchRefs(ctx, { head, remote, branch }, red).refs, ...tagRefs(ctx, { remote, tag: gate.tag }, red)];
|
|
97
100
|
data.refs = refs;
|
|
98
|
-
|
|
101
|
+
const candidate = refs.length > 0 ? candidateRef(gate.tag, config.tagPrefix) : null;
|
|
102
|
+
if (candidate) out.push(`plan git push ${remote} +${head.slice(0, 10)}:${candidate}, wait for its ci.yml run to be green, then git push --atomic ${remote} ${refs.join(' ')}`);
|
|
103
|
+
else out.push(`plan git push --atomic ${remote} (nothing)`);
|
|
99
104
|
if (refs.length === 0 && red.length === 0) return { ok: true, lines: [...out, `already pushed: ${remote}/${branch} holds HEAD and ${gate.tag} is on ${remote}. Nothing to do.`], data };
|
|
100
105
|
if (refs.length > 0) {
|
|
101
106
|
const hook = probe(ctx, remote, refs, red);
|
|
@@ -104,6 +109,10 @@ export async function releasePush(ctx, config, options = {}) {
|
|
|
104
109
|
}
|
|
105
110
|
if (red.length > 0) return refuse(out, red, data, options.execute === true ? 'nothing pushed.' : 'dry run: nothing pushed.');
|
|
106
111
|
if (options.execute !== true) return { ok: true, lines: [...out, 'dry run: nothing pushed. ready; re-run with --execute.'], data };
|
|
112
|
+
const judged = await candidateStep(ctx, { head, tag: gate.tag, prefix: config.tagPrefix, remote, wait: options.candidateWait });
|
|
113
|
+
data.candidate = judged.data;
|
|
114
|
+
out.push(...judged.lines);
|
|
115
|
+
if (!judged.ok) return { ok: false, lines: [...out, `nothing pushed to ${branch} or ${gate.tag}.`], data };
|
|
107
116
|
const run = git(ctx, ['push', '--atomic', remote, ...refs], { allowFail: true });
|
|
108
117
|
if (run.code !== 0) return { ok: false, lines: [...out, `push failed: ${firstLine(run.stderr)}`], data };
|
|
109
118
|
return { ok: true, lines: [...out, `pushed to ${remote}: ${refs.join(' ')}`], data: { ...data, pushed: true } };
|
|
@@ -29,14 +29,14 @@ pins:
|
|
|
29
29
|
source: packages/grammar/package.json
|
|
30
30
|
why: 'the grammar front ends read: the brand layer sets `--font-sans` and `--font-mono` and the grammar reads them; it carries the Input tel kind, the copyable and read-only Input, the IconButton disclosure props and count badge, ChatBubble, FillViewport, Textarea mentions and Enter-to-send, the leading PrimaryRailLayout rail, the plate-less Image, ListRow, the soft Alert, Progress steps, the floating primary IconButton, FlowGraph, the STATE-8 loading views and the bordered selectable TagGroup chips. The runtime source version wins for a @starci package.'
|
|
31
31
|
'@starci/eslint-canon-be':
|
|
32
|
-
version: 3.4.
|
|
32
|
+
version: 3.4.6
|
|
33
33
|
group: starci
|
|
34
34
|
install: registry
|
|
35
35
|
side: be
|
|
36
36
|
source: packages/eslint/be/package.json
|
|
37
37
|
why: 'the back-end ESLint canon: `loadHfs(import.meta.url)` of be/eslint.config.mjs finds the app-root hfs.json (kind app) and gives every linted file the view of the be side, and the project graph is built per side; its bundled runtime copies (slots, canon-pins, failure codes, the architecture machine) put .starcistacks and .sops.yaml at the app root; `starciBeConfig({ hfs: loadHfs(import.meta.url) })` is the typed factory and the BE-CONVENTION laws are its rules.'
|
|
38
38
|
'@starci/eslint-canon-fe':
|
|
39
|
-
version: 8.4.
|
|
39
|
+
version: 8.4.6
|
|
40
40
|
group: starci
|
|
41
41
|
install: registry
|
|
42
42
|
side: fe
|
|
@@ -76,14 +76,14 @@ pins:
|
|
|
76
76
|
source: packages/test-world/package.json
|
|
77
77
|
why: 'the shared e2e library of every back end (R47, R48), a devDependency of every back end: the warm stack behind toxiproxy, the network-edge fakes, the Nest boot, the typed useTestWorld handle, useSandbox for contract specs and the outage lock. Kafka is real own infra (the one digest-pinned apache/kafka KRaft image, a broker listener and proxy per slot, slot-prefixed topics, consumer groups and client ids) and Postgres is schema-per-context (a schema and a login role per context in a shared database). Each worker has its own data slot (<ns>_w<k>) with its own databases, Keycloak realm, leased Redis DB, bucket/collection/topic prefixes, fakes host, toxiproxy proxies and outage lock; a run provisions min(jest workers, workers cap, default 2) slots, and state file protocol 2 pairs exactly with @starci/jest-preset (TEST_WORLD_PAIR_MISMATCH otherwise). A modules world boots real peer apps beside its modules, world.apps.<name>.during(fn) is the outage of a peer app, w.keycloak.clientSecret(client) answers the run-generated secret of a confidential realm client, world.keycloak.events/sessions read the realm user events and live sessions, world.infra.postgresql.connection(name) takes one database connection down while the others serve, and world.resolve and scope.resolve take any Nest token. Every path a declaration names (`stack`, seeds, the realm, Dockerfiles) resolves from the app root (the directory of hfs.json); the globalSetup registers the path aliases as TypeScript resolves them. Its CLI main is exported at ./cli for starci app stack.'
|
|
78
78
|
'@starci/cli':
|
|
79
|
-
version: 1.
|
|
79
|
+
version: 1.7.1
|
|
80
80
|
group: starci
|
|
81
81
|
install: registry
|
|
82
82
|
side: both
|
|
83
83
|
source: packages/cli/package.json
|
|
84
84
|
why: 'the only StarCi command package; product repositories install it for starci app, while @starci/hfs remains its pinned transitive implementation.'
|
|
85
85
|
'@starci/hfs':
|
|
86
|
-
version: 4.
|
|
86
|
+
version: 4.7.1
|
|
87
87
|
group: starci
|
|
88
88
|
install: registry
|
|
89
89
|
side: both
|
|
@@ -466,6 +466,11 @@ pins:
|
|
|
466
466
|
group: framework
|
|
467
467
|
side: be
|
|
468
468
|
why: 'the GraphQL transport of every back end (be.transport.graphql) that the front end calls; the ecommerce-app resolution on 2026-10-13. Stays on 13.x for alpha.18: the npm-audit advisory fixed by 14.0.3 (semver major) is a documented accepted exception, upgrade deferred to alpha.19 (owner ruling graphql-13-advisory-accepted-until-a19, 2026-10-16).'
|
|
469
|
+
'@graphql-tools/utils':
|
|
470
|
+
version: 12.1.0
|
|
471
|
+
group: framework
|
|
472
|
+
side: be
|
|
473
|
+
why: 'an npm override of every app root (and of the examples that serve GraphQL), never a declared dependency: @nestjs/graphql 13.x pulls @graphql-tools/utils <=12.0.0, which npm audit --audit-level=high reports (GHSA-7mx3-vvmw-hjmv, prototype pollution in mergeDeep). 12.1.0 is a fixed release on the same major and the lowest one every declared range accepts (@graphql-tools/schema and /merge, used at runtime, require ^12.1.0; NIVO v0.2.1 6a1d8e80 overrides 12.0.4, below that range), so the advisory closes without the breaking @nestjs/graphql 14. DROP this pin and the override when @nestjs/graphql 14 is adopted (owner ruling graphql-tools-utils-override, 2026-10-18).'
|
|
469
474
|
'@nestjs/apollo':
|
|
470
475
|
version: 13.4.5
|
|
471
476
|
group: framework
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
schema: starci/ci-standard@1
|
|
2
|
+
id: ci-standard
|
|
3
|
+
title: The one StarCi CI standard
|
|
4
|
+
|
|
5
|
+
# OWNER RULING ci-standard (2026-10-18, modules/kernel/owner-rulings.yaml): ".claude defines ONE standard CI ruleset, its own CI and the examples follow it, and apps
|
|
6
|
+
# inherit it." This file is the one place that standard is written. Each item names what it requires, the workflows it covers and what enforces it:
|
|
7
|
+
# app-ci the managed .github/workflows/ci.yml of an app (rendered by `starci app sync` from packages/hfs/templates/app/ci-workflows*; the examples are the
|
|
8
|
+
# reference, and their rendered CI runs green on GitHub through the candidate flow as part of every release proof: the runtime's examples.yml)
|
|
9
|
+
# runtime the runtime repository's own workflows (.github/workflows/*.yml), checked by the same rules, not rendered
|
|
10
|
+
# Enforcers: rule R301 CI_STANDARD (scripts/hfs/rules/ci-standard.mjs over app-ci in the app check and the example apps, scripts/hfs/runtime-rules/runtime-ci-standard.mjs
|
|
11
|
+
# over the runtime's own workflows) checks every item marked `check: ci-standard`; the other items are owned by the rule or render named in `by`.
|
|
12
|
+
# Apps get the standard only through `starci app sync`: the managed CI files are sync targets, and `starci app sync --check` / the app check refuse drift.
|
|
13
|
+
|
|
14
|
+
# The jobs of the managed app CI (job ids, in this order), per edition; `sonar` exists when the Sonar service is enabled.
|
|
15
|
+
jobs:
|
|
16
|
+
full: {required: [fast, lint, test, coverage, build], optional: [sonar]}
|
|
17
|
+
lite: {required: [fast, lint, test, coverage, build], optional: [db-types, sonar]}
|
|
18
|
+
|
|
19
|
+
# The per-job settings the managed CI renders when .starcistacks/application-stacks.yaml ci.jobs leaves them out (ci.jobs.<job>.timeoutMinutes,
|
|
20
|
+
# ci.jobs.test.shards). Sized from a real run (NIVO v0.2.3 on the 2-vCPU GitHub runner, 2026-10-18): the be lint with the Sonar import took 11.5 minutes and the
|
|
21
|
+
# fe lint more than 8 in one 20-minute job, and ~900 unit suites ran at ~4 s each (670 of 904 in 45 minutes). One lint job per side, and 6 shards of ~150 suites
|
|
22
|
+
# (~10 minutes each plus the install) leave each job well inside its timeout.
|
|
23
|
+
defaults:
|
|
24
|
+
shards: 6
|
|
25
|
+
timeoutMinutes: {fast: 20, lint: 30, test: 25, coverage: 20, build: 30, sonar: 20, db-types: 30}
|
|
26
|
+
|
|
27
|
+
# The steps that need a raised Node heap on the GitHub runner, matched on their `run` text, and the least heap they must declare in NODE_OPTIONS.
|
|
28
|
+
heap:
|
|
29
|
+
minMiB: 6144
|
|
30
|
+
steps: ['npm run lint', 'app lint', 'app check --coverage-from']
|
|
31
|
+
|
|
32
|
+
items:
|
|
33
|
+
- id: candidate-trigger
|
|
34
|
+
title: the release CI runs on the release candidate ref
|
|
35
|
+
requires: "ci.yml runs on push of branches ['release/candidate-*'] and tags ['v*'] (and workflow_dispatch); the release flows wait for the run of the release sha there"
|
|
36
|
+
covers: [app-ci, runtime]
|
|
37
|
+
by: [R221 CI_TRIGGERS_RELEASE_ONLY, R251 HFS_APP_WORKFLOW_TRIGGER]
|
|
38
|
+
- id: job-shape
|
|
39
|
+
title: the job shape fast / lint / test / coverage / build / sonar
|
|
40
|
+
requires: "the managed CI holds exactly the jobs of `jobs` for its edition; the unit run is a step of `test`; the merge, the coverage proof and the Codecov upload are steps of `coverage`, which alone holds the OIDC id-token"
|
|
41
|
+
covers: [app-ci]
|
|
42
|
+
check: ci-standard
|
|
43
|
+
- id: full-history-base-ref
|
|
44
|
+
title: full history and the lint base STARCI_BASE_REF
|
|
45
|
+
requires: "every actions/checkout of the managed CI fetches the full history (fetch-depth: 0), and the lint job writes STARCI_BASE_REF to GITHUB_ENV before it lints"
|
|
46
|
+
covers: [app-ci]
|
|
47
|
+
check: ci-standard
|
|
48
|
+
- id: heap-per-step
|
|
49
|
+
title: a raised heap on every heavy step
|
|
50
|
+
requires: "every step whose run matches heap.steps declares NODE_OPTIONS with --max-old-space-size of at least heap.minMiB, on the step itself"
|
|
51
|
+
covers: [app-ci, runtime]
|
|
52
|
+
check: ci-standard
|
|
53
|
+
- id: sonar-lint-per-side
|
|
54
|
+
title: the Sonar lint runs per side
|
|
55
|
+
requires: "a lint with a Sonar import (--sonar) names its side (--side be|fe); the reports are merged into reports/lint.sonar.json afterwards; no combined lint with Sonar"
|
|
56
|
+
covers: [app-ci, runtime]
|
|
57
|
+
check: ci-standard
|
|
58
|
+
- id: test-sharding
|
|
59
|
+
title: the unit run in shards, merged once
|
|
60
|
+
requires: "the test job is a matrix over shard (ci.jobs.test.shards, default defaults.shards); each shard runs npm test with --shard=<i>/<N> and without the per-file threshold, uploads its lcov, and the coverage job merges them (starci app coverage merge) before the coverage proof proves 100 per file"
|
|
61
|
+
covers: [app-ci]
|
|
62
|
+
check: ci-standard
|
|
63
|
+
- id: lint-parallel
|
|
64
|
+
title: lint each side in its own job
|
|
65
|
+
requires: "the lint job is a matrix over side [be, fe]; each side lints (with its own Sonar report when a token is set) in parallel, and the sonar job merges the reports"
|
|
66
|
+
covers: [app-ci]
|
|
67
|
+
check: ci-standard
|
|
68
|
+
- id: cache-saved-always
|
|
69
|
+
title: caches keyed by the lockfile and saved always
|
|
70
|
+
requires: "no actions/cache key holds the commit sha (a cache saved only on a green run never warms); the jest and build caches restore by lockfile hash (restore-keys) and save with actions/cache/save under if: always()"
|
|
71
|
+
covers: [app-ci]
|
|
72
|
+
check: ci-standard
|
|
73
|
+
- id: fe-build-env
|
|
74
|
+
title: each fe app builds with its own env
|
|
75
|
+
requires: "the build job builds every fe app with that app's own public build values (static NEXT_PUBLIC_* from the declaration), never a shared env"
|
|
76
|
+
covers: [app-ci]
|
|
77
|
+
by: [render packages/hfs/sync/ci-commands.mjs, R300 FE_PUBLIC_BUILD_ARG_MISSING]
|
|
78
|
+
- id: optional-providers
|
|
79
|
+
title: optional providers probe, then skip with a notice
|
|
80
|
+
requires: "an optional Codecov or Sonar step (continue-on-error) is preceded in its job by its availability probe (id codecov-availability / sonar-availability); a strict provider fails the job"
|
|
81
|
+
covers: [app-ci]
|
|
82
|
+
check: ci-standard
|
|
83
|
+
- id: timeouts
|
|
84
|
+
title: a timeout on every job, configurable per job
|
|
85
|
+
requires: "every job declares timeout-minutes; the managed CI renders ci.jobs.<job>.timeoutMinutes of the manifest, else defaults.timeoutMinutes"
|
|
86
|
+
covers: [app-ci, runtime]
|
|
87
|
+
check: ci-standard
|
|
88
|
+
- id: sha-pinned-actions
|
|
89
|
+
title: every action pinned to a commit sha
|
|
90
|
+
requires: "every remote `uses:` is pinned to a 40-hex commit sha (a docker:// action to a sha256 digest); a local ./ action is allowed"
|
|
91
|
+
covers: [app-ci, runtime]
|
|
92
|
+
check: ci-standard
|
|
93
|
+
by: [R251 HFS_DEPLOY_WORKFLOW (app workflows)]
|
|
94
|
+
- id: prettier-stable
|
|
95
|
+
title: the rendered CI is prettier's output
|
|
96
|
+
requires: "every rendered managed workflow is byte-identical to prettier's output with the canon config"
|
|
97
|
+
covers: [app-ci]
|
|
98
|
+
by: [self-check rendered-prettier, render packages/hfs/sync]
|
|
99
|
+
- id: context-validity
|
|
100
|
+
title: expressions use only the contexts available at their key
|
|
101
|
+
requires: "GitHub Actions expressions read only contexts available at their workflow key"
|
|
102
|
+
covers: [app-ci, runtime]
|
|
103
|
+
by: [R299 HFS_WORKFLOW_CONTEXT]
|
|
@@ -3558,7 +3558,7 @@ rules:
|
|
|
3558
3558
|
- {kind: hfs, id: lite-secret-custody, at: scripts/hfs/rules/supabase-secrets.mjs}
|
|
3559
3559
|
- id: R221
|
|
3560
3560
|
code: "CI_TRIGGERS_RELEASE_ONLY"
|
|
3561
|
-
law: "The only workflow triggers are a push of release tags, a person dispatching it and, in the runtime's own workflows (`.github/workflows/` at the root), a push to `main`: every tracked workflow declares an `on` that holds only `push` filtered to `tags: ['v*']` (and, in the runtime's own workflows, `
|
|
3561
|
+
law: "The only workflow triggers are a push of release tags, a push of release candidate branches `release/candidate-*`, a person dispatching it and, in the runtime's own workflows (`.github/workflows/` at the root), a push to `main`: every tracked workflow declares an `on` that holds only `push` filtered to `tags: ['v*']` and/or `branches` naming only `release/candidate-*` (and, in the runtime's own workflows, `main`; no paths or ignore filter beside them) and `workflow_dispatch`. Every `ci.yml`, and the runtime's `examples.yml`, must name `release/candidate-*`: `starci release cut` and `starci app release push` push the exact release sha there and move main and the tag only when that run is green (owner ruling candidate-ci, 2026-10-18). A push to any other branch, a pull_request, a schedule, a workflow_call or any other event refuses the workflow."
|
|
3562
3562
|
scope: runtime
|
|
3563
3563
|
editions: [lite, full]
|
|
3564
3564
|
docs: [docs/git-governance.md#r221]
|
|
@@ -4026,7 +4026,7 @@ rules:
|
|
|
4026
4026
|
- {path: packages/hfs/templates/app/ci-workflows-lite/github/workflows/db-deploy.yml, edition: lite}
|
|
4027
4027
|
- {path: packages/hfs/templates/app/ci-workflows/github/workflows/ci.yml, edition: full}
|
|
4028
4028
|
witness: {status: unreachable, reason: "owner:no-runtime-specs (2026-10-17): the mutation witness was a deleted runtime spec or fixture", date: "2026-10-17"}
|
|
4029
|
-
law: "In lite and full apps, deployment jobs declare a protected environment, explicit permissions and concurrency, materialize sealed secrets through SOPS_AGE_KEY or starci app secret materialize, and reference only the age key and transport SSH key/known_hosts secrets. They never embed a dotenv blob or write a dotenv file. Every remote workflow action uses a 40-hex commit SHA (local actions are allowed and container actions require a full sha256 digest); supply-chain reports each unpinned uses reference at its source line, including disabled jobs and steps; installed tools are checksum or digest verified, or supplied by a pinned action. App workflows start only on release tags [v*] or workflow_dispatch, never on
|
|
4029
|
+
law: "In lite and full apps, deployment jobs declare a protected environment, explicit permissions and concurrency, materialize sealed secrets through SOPS_AGE_KEY or starci app secret materialize, and reference only the age key and transport SSH key/known_hosts secrets. They never embed a dotenv blob or write a dotenv file. Every remote workflow action uses a 40-hex commit SHA (local actions are allowed and container actions require a full sha256 digest); supply-chain reports each unpinned uses reference at its source line, including disabled jobs and steps; installed tools are checksum or digest verified, or supplied by a pinned action. App workflows start only on release tags [v*], the release candidate branches release/candidate-* (required in ci.yml: starci app release push waits for that run, owner ruling candidate-ci 2026-10-18) or workflow_dispatch, never on another branch push."
|
|
4030
4030
|
kinds: [check]
|
|
4031
4031
|
gates: [settle, land, ci]
|
|
4032
4032
|
failureCodes: ["HFS_DEPLOY_WORKFLOW", "HFS_WORKFLOW_PIN", "HFS_CI_TOOL_INSTALL", "HFS_APP_WORKFLOW_TRIGGER"]
|
|
@@ -4967,3 +4967,20 @@ rules:
|
|
|
4967
4967
|
- {path: packages/hfs/templates/app/skeleton/.starcistacks, edition: full}
|
|
4968
4968
|
specNa: "owner:no-runtime-specs (2026-10-17): the runtime has no specs of its own; this code is enforced by runtime code and its spec proof was a deleted runtime spec"
|
|
4969
4969
|
witness: {status: unreachable, reason: "owner:no-runtime-specs (2026-10-17): external smoke fixtures", date: "2026-10-17"}
|
|
4970
|
+
- id: R301
|
|
4971
|
+
code: "CI_STANDARD"
|
|
4972
|
+
law: "The managed app CI, the example apps (the reference) and the runtime repository's own workflows follow the ONE CI standard of knowledge/hfs/ci-standard.yaml (owner ruling ci-standard, 2026-10-18): the job shape fast/test/build(/sonar) with the unit run and the Codecov upload in test alone, full history with STARCI_BASE_REF written before the lint, NODE_OPTIONS --max-old-space-size=6144 on every heavy step, the Sonar lint per side, optional providers probed before they skip, a timeout on every job and every action pinned to a commit sha; the other items (candidate trigger R221, per-fe-app build env, prettier-stable renders, R299 contexts) are owned by the rules the standard names. Apps get the standard only through starci app sync."
|
|
4973
|
+
kinds: [check]
|
|
4974
|
+
gates: [settle, land, ci]
|
|
4975
|
+
failureCodes: ["CI_STANDARD"]
|
|
4976
|
+
enforcers:
|
|
4977
|
+
- {kind: hfs, id: ci-standard, at: scripts/hfs/rules/ci-standard.mjs}
|
|
4978
|
+
- {kind: runtime, id: ci-standard-runtime, at: scripts/hfs/runtime-rules/runtime-ci-standard.mjs}
|
|
4979
|
+
editions: [lite, full]
|
|
4980
|
+
docs:
|
|
4981
|
+
- {path: docs/application-stacks.md#r301, editions: [lite, full]}
|
|
4982
|
+
exemplifiedBy:
|
|
4983
|
+
- {path: packages/hfs/templates/app/ci-workflows-lite/github/workflows/ci.yml, edition: lite}
|
|
4984
|
+
- {path: packages/hfs/templates/app/ci-workflows/github/workflows/ci.yml, edition: full}
|
|
4985
|
+
specNa: "owner:no-runtime-specs (2026-10-17): the runtime has no specs of its own; the scaffold e2e (e2e/hfs-scaffold-app.spec.mjs: a fresh scaffold has no finding) and the example apps prove it"
|
|
4986
|
+
witness: {status: unreachable, reason: "owner:no-runtime-specs (2026-10-17): the runtime has no spec fixtures", date: "2026-10-18"}
|