@starci/hfs 4.6.0 → 4.7.0
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 +6 -0
- package/ci/cli.mjs +2 -0
- package/ci/local.mjs +146 -0
- package/package.json +1 -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 +113 -30
- package/runtime/scripts/hfs/lcov-merge.mjs +89 -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 +41 -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
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// GENERATED by scripts/cli/gen-catalog.mjs — do not edit
|
|
2
2
|
export const CATALOG = {
|
|
3
3
|
"schema": "starci/cli-catalog@1",
|
|
4
|
-
"hash": "
|
|
4
|
+
"hash": "441c47efec9ac314fb4ffd8b203fd6469aac65710ca9cd12aace0c032c2c537f",
|
|
5
5
|
"global": [
|
|
6
6
|
{
|
|
7
7
|
"name": "json",
|
|
@@ -177,7 +177,7 @@ export const CATALOG = {
|
|
|
177
177
|
"runtimeSide": "host"
|
|
178
178
|
},
|
|
179
179
|
"ci": {
|
|
180
|
-
"summary": "set declared GitHub CI secrets and variables
|
|
180
|
+
"summary": "set declared GitHub CI secrets and variables, verify their custody, or run the managed CI locally",
|
|
181
181
|
"owner": "@starci/hfs",
|
|
182
182
|
"positional": [
|
|
183
183
|
{
|
|
@@ -185,7 +185,8 @@ export const CATALOG = {
|
|
|
185
185
|
"enum": [
|
|
186
186
|
"secret",
|
|
187
187
|
"var",
|
|
188
|
-
"verify"
|
|
188
|
+
"verify",
|
|
189
|
+
"local"
|
|
189
190
|
],
|
|
190
191
|
"required": true,
|
|
191
192
|
"variadic": false
|
|
@@ -234,12 +235,27 @@ export const CATALOG = {
|
|
|
234
235
|
{
|
|
235
236
|
"name": "json",
|
|
236
237
|
"type": "boolean",
|
|
237
|
-
"summary": "print verification findings as JSON"
|
|
238
|
+
"summary": "print verification findings (or the local plan and results) as JSON"
|
|
239
|
+
},
|
|
240
|
+
{
|
|
241
|
+
"name": "jobs",
|
|
242
|
+
"type": "string",
|
|
243
|
+
"summary": "local: the jobs to run, comma separated (default fast,lint,test,coverage,build; sonar and db-types only when named)"
|
|
244
|
+
},
|
|
245
|
+
{
|
|
246
|
+
"name": "parallel",
|
|
247
|
+
"type": "string",
|
|
248
|
+
"summary": "local: job instances run at once (default half the CPUs, at most 4)"
|
|
249
|
+
},
|
|
250
|
+
{
|
|
251
|
+
"name": "dry-run",
|
|
252
|
+
"type": "boolean",
|
|
253
|
+
"summary": "local: print which steps would run and which only GitHub can run"
|
|
238
254
|
}
|
|
239
255
|
],
|
|
240
256
|
"exit": {
|
|
241
|
-
"0": "action completed or
|
|
242
|
-
"1": "verification findings",
|
|
257
|
+
"0": "action completed, verification clean or every local job green",
|
|
258
|
+
"1": "verification findings or a local job red",
|
|
243
259
|
"2": "refusal or bad usage"
|
|
244
260
|
},
|
|
245
261
|
"json": "flag",
|
|
@@ -250,13 +266,66 @@ export const CATALOG = {
|
|
|
250
266
|
"starci app ci var set SONAR_HOST_URL",
|
|
251
267
|
"starci app ci var sync",
|
|
252
268
|
"starci app ci var sync --execute",
|
|
253
|
-
"starci app ci verify --json"
|
|
269
|
+
"starci app ci verify --json",
|
|
270
|
+
"starci app ci local --dry-run",
|
|
271
|
+
"starci app ci local --jobs lint,test --parallel 4"
|
|
272
|
+
],
|
|
273
|
+
"editions": [
|
|
274
|
+
"full",
|
|
275
|
+
"lite"
|
|
276
|
+
],
|
|
277
|
+
"impl": null,
|
|
278
|
+
"conventions": [
|
|
279
|
+
"local reads .github/workflows/ci.yml and runs every job instance (matrix values substituted) on this machine, in parallel, a job after its needs",
|
|
280
|
+
"it skips what only GitHub runs (uses: actions, npm ci, the lint baseline, a step whose if: needs a Sonar token, a tag ref or step outputs) and lists it",
|
|
281
|
+
"the unit shards write be/coverage/shards/shard-<i>, which the coverage job merges; each instance logs to a temp directory"
|
|
282
|
+
],
|
|
283
|
+
"runtimeSide": "host"
|
|
284
|
+
},
|
|
285
|
+
"coverage": {
|
|
286
|
+
"summary": "merge the lcov reports of the sharded unit run into one report",
|
|
287
|
+
"owner": "@starci/hfs",
|
|
288
|
+
"positional": [
|
|
289
|
+
{
|
|
290
|
+
"name": "action",
|
|
291
|
+
"enum": [
|
|
292
|
+
"merge"
|
|
293
|
+
],
|
|
294
|
+
"required": true,
|
|
295
|
+
"variadic": false
|
|
296
|
+
}
|
|
297
|
+
],
|
|
298
|
+
"flags": [
|
|
299
|
+
{
|
|
300
|
+
"name": "out",
|
|
301
|
+
"type": "string",
|
|
302
|
+
"summary": "the merged lcov to write (the managed CI writes be/coverage/lcov.info)"
|
|
303
|
+
},
|
|
304
|
+
{
|
|
305
|
+
"name": "cwd",
|
|
306
|
+
"type": "string",
|
|
307
|
+
"summary": "the app root (default the current directory)"
|
|
308
|
+
}
|
|
309
|
+
],
|
|
310
|
+
"exit": {
|
|
311
|
+
"0": "merged",
|
|
312
|
+
"2": "bad usage or no report found"
|
|
313
|
+
},
|
|
314
|
+
"json": "none",
|
|
315
|
+
"examples": [
|
|
316
|
+
"starci app coverage merge --out be/coverage/lcov.info be/coverage/shards"
|
|
254
317
|
],
|
|
255
318
|
"editions": [
|
|
256
319
|
"full",
|
|
257
320
|
"lite"
|
|
258
321
|
],
|
|
259
322
|
"impl": null,
|
|
323
|
+
"conventions": [
|
|
324
|
+
"the CI standard shards the unit run (npm test -- --shard=i/N); each shard writes an lcov over the whole coverage scope",
|
|
325
|
+
"merge sums every line, function and branch hit by identity across the reports and writes one record per source file",
|
|
326
|
+
"a path is an lcov file or a directory searched for every lcov.info below it; no report found refuses (exit 2), never an empty report",
|
|
327
|
+
"the coverage proof (starci app check --coverage-from), the Codecov upload and the Sonar import read the merged report"
|
|
328
|
+
],
|
|
260
329
|
"runtimeSide": "host"
|
|
261
330
|
},
|
|
262
331
|
"db": {
|
|
@@ -618,8 +687,8 @@ export const CATALOG = {
|
|
|
618
687
|
],
|
|
619
688
|
"exit": {
|
|
620
689
|
"0": "gate green or push done or nothing to do",
|
|
621
|
-
"1": "a gate is red or the push was refused by the hook",
|
|
622
|
-
"2": "refusal or bad usage including
|
|
690
|
+
"1": "a gate is red, the candidate CI of the sha is not green, or the push was refused by the hook",
|
|
691
|
+
"2": "refusal or bad usage including RELEASE_SONAR_UNAVAILABLE"
|
|
623
692
|
},
|
|
624
693
|
"json": "flag",
|
|
625
694
|
"examples": [
|
|
@@ -641,10 +710,11 @@ export const CATALOG = {
|
|
|
641
710
|
"declared release.gates gets audit appended if missing",
|
|
642
711
|
"--sonar appends @sonar/scan@4.3.0 against services.sonar.host (host.public for a host mapping), plus organization for sonarcloud;",
|
|
643
712
|
"disabled or unavailable Sonar or an unset token refuses RELEASE_SONAR_UNAVAILABLE",
|
|
644
|
-
"gate
|
|
645
|
-
"
|
|
646
|
-
"
|
|
647
|
-
"
|
|
713
|
+
"OWNER RULING candidate-ci (2026-10-18): the gate no longer reads the default-branch CI; the verdict is the candidate run of the sha",
|
|
714
|
+
"push --execute sends HEAD to refs/heads/release/candidate-<version> first and waits for its ci.yml run (it triggers on release/candidate-*)",
|
|
715
|
+
"green moves the branch and the tag in one atomic push; red refuses RELEASE_CANDIDATE_CI_RED naming the failing jobs",
|
|
716
|
+
"no run, or no end in time, refuses RELEASE_CANDIDATE_CI_TIMEOUT; unreadable GitHub refuses RELEASE_CI_UNKNOWN; nothing reaches the branch",
|
|
717
|
+
"only the owner's starci release ci-override --sha <sha> --reason <text> record lets a red candidate through; there is no --no-ci-check flag",
|
|
648
718
|
"gate refuses a dirty tree, HEAD other than --source, a moved HEAD and a tree dirtied by the gates; record and tag only when all gates are green",
|
|
649
719
|
"a gate with summary jest is red on failed tests and on a coverage threshold miss even when the process exits 0",
|
|
650
720
|
"push needs the annotated tag and L4 record of HEAD, runs git push --dry-run --atomic first and never uses --no-verify"
|
|
@@ -7723,6 +7793,11 @@ export const CATALOG = {
|
|
|
7723
7793
|
"name": "keep",
|
|
7724
7794
|
"type": "boolean",
|
|
7725
7795
|
"summary": "keep and print the temporary application path"
|
|
7796
|
+
},
|
|
7797
|
+
{
|
|
7798
|
+
"name": "source",
|
|
7799
|
+
"type": "boolean",
|
|
7800
|
+
"summary": "scaffold with this checkout and install the @starci scope from its packed tarballs (the pre-publish form release scaffold-e2e runs)"
|
|
7726
7801
|
}
|
|
7727
7802
|
],
|
|
7728
7803
|
"exit": {
|
|
@@ -7733,7 +7808,8 @@ export const CATALOG = {
|
|
|
7733
7808
|
"json": "none",
|
|
7734
7809
|
"examples": [
|
|
7735
7810
|
"starci release app-installs",
|
|
7736
|
-
"starci release app-installs --keep"
|
|
7811
|
+
"starci release app-installs --keep",
|
|
7812
|
+
"starci release app-installs --source"
|
|
7737
7813
|
],
|
|
7738
7814
|
"editions": [
|
|
7739
7815
|
"full"
|
|
@@ -7778,7 +7854,7 @@ export const CATALOG = {
|
|
|
7778
7854
|
"runtimeSide": "tree"
|
|
7779
7855
|
},
|
|
7780
7856
|
"ci-override": {
|
|
7781
|
-
"summary": "the owner accepts red CI for one SHA and records who and why",
|
|
7857
|
+
"summary": "the owner accepts a red candidate CI for one SHA (emergency path) and records who and why",
|
|
7782
7858
|
"owner": "runtime",
|
|
7783
7859
|
"flags": [
|
|
7784
7860
|
{
|
|
@@ -7813,14 +7889,17 @@ export const CATALOG = {
|
|
|
7813
7889
|
"script": "scripts/supervisor/release-ci-override-cli.mjs"
|
|
7814
7890
|
},
|
|
7815
7891
|
"conventions": [
|
|
7816
|
-
"OWNER RULING 2026-10-
|
|
7817
|
-
"
|
|
7892
|
+
"OWNER RULING candidate-ci (2026-10-18): a release reaches main only when the candidate CI of its own sha is green;",
|
|
7893
|
+
"this verb is the one documented EMERGENCY path for a red candidate.",
|
|
7894
|
+
"The override is owner-only, records who and why, and agents cannot run it (OWNER RULING alpha20-ci-override-owner-only).",
|
|
7818
7895
|
"Every bound role is refused by the command guard and by this script. The owner must use a terminal and type the first 12 SHA characters exactly.",
|
|
7819
7896
|
"Writes <git-common-dir>/starci-release/<sha>.ci-override.json, schema starci/ci-override@1,",
|
|
7820
7897
|
"with sha, reason, by (git user.name and OS user), at, and runUrl (empty if GitHub is unavailable).",
|
|
7821
|
-
"
|
|
7822
|
-
"
|
|
7823
|
-
"
|
|
7898
|
+
"release cut and app release push read that record for the exact candidate sha only; the release record logs its use.",
|
|
7899
|
+
"For the runtime it also appends ci-override-<sha12> under rulings in modules/kernel/owner-rulings.yaml as the audit trail.",
|
|
7900
|
+
"App repositories receive only the record.",
|
|
7901
|
+
"An override applies only to its exact SHA. It never permits a release whose CI is unknown or timed out.",
|
|
7902
|
+
"Existing ruling IDs with another reason refuse."
|
|
7824
7903
|
],
|
|
7825
7904
|
"runtimeSide": "tree"
|
|
7826
7905
|
},
|
|
@@ -7942,8 +8021,8 @@ export const CATALOG = {
|
|
|
7942
8021
|
}
|
|
7943
8022
|
],
|
|
7944
8023
|
"exit": {
|
|
7945
|
-
"0": "the release was
|
|
7946
|
-
"1": "refused (
|
|
8024
|
+
"0": "the candidate CI of the release sha was green, then main plus its tag were pushed atomically, or --plan found nothing missing",
|
|
8025
|
+
"1": "refused (RELEASE_CANDIDATE_CI_RED, RELEASE_CANDIDATE_CI_TIMEOUT, RELEASE_CI_UNKNOWN, dirty tree, no or wrong tag, release notes incomplete, version not moved past the remote main, suite red, main moved, secret scan) and nothing was pushed",
|
|
7947
8026
|
"2": "bad usage"
|
|
7948
8027
|
},
|
|
7949
8028
|
"json": "flag",
|
|
@@ -7960,14 +8039,18 @@ export const CATALOG = {
|
|
|
7960
8039
|
"script": "scripts/supervisor/release-cut-cli.mjs"
|
|
7961
8040
|
},
|
|
7962
8041
|
"conventions": [
|
|
7963
|
-
"OWNER RULING 2026-10-
|
|
7964
|
-
"the
|
|
7965
|
-
"
|
|
7966
|
-
"
|
|
7967
|
-
"
|
|
7968
|
-
"The
|
|
7969
|
-
"
|
|
7970
|
-
"
|
|
8042
|
+
"OWNER RULING candidate-ci (2026-10-18): never again have main receive a release whose CI is red, with the owner confirming afterwards.",
|
|
8043
|
+
"Before the suite, including --plan, the workflows the candidate CI waits for (release-cut.yaml candidate.workflows) must be readable,",
|
|
8044
|
+
"else RELEASE_CI_UNKNOWN. The latest run on main is no longer read: a red main needs no owner override before the next release.",
|
|
8045
|
+
"After the suite and the secret scan the exact HEAD is pushed to refs/heads/release/candidate-<version>;",
|
|
8046
|
+
"a re-cut of the same version force-moves only that ref, never main.",
|
|
8047
|
+
"The cut waits for every required workflow run of that sha (pollSeconds, graceMinutes, waitMinutes in release-cut.yaml).",
|
|
8048
|
+
"Red refuses RELEASE_CANDIDATE_CI_RED with the run URLs and the failing jobs and steps; nothing reaches main.",
|
|
8049
|
+
"No run within the grace, or no end within the wait, refuses RELEASE_CANDIDATE_CI_TIMEOUT.",
|
|
8050
|
+
"A red candidate keeps its ref for diagnosis; after a cut the candidate ref is left on the remote, naming the released sha.",
|
|
8051
|
+
"Only on green is the annotated tag created and main plus the tag pushed atomically; the release record logs the candidate verdict.",
|
|
8052
|
+
"Emergency path, owner-only: starci release ci-override --sha <HEAD> --reason <text> lets a red candidate through.",
|
|
8053
|
+
"The release record logs its use."
|
|
7971
8054
|
],
|
|
7972
8055
|
"runtimeSide": "tree"
|
|
7973
8056
|
},
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// lcov-merge.mjs - the lcov reports of the sharded unit run merged into one (`starci app coverage merge`, the coverage job of the managed CI). Each shard of
|
|
2
|
+
// `npm test -- --shard=i/N` writes its own lcov over the whole collectCoverageFrom scope (a file no test of that shard loads is a record of zeros), so the
|
|
3
|
+
// merge sums every line, function and branch hit by its identity across the shards and writes ONE record per source file, with the summaries recomputed:
|
|
4
|
+
// the coverage proof (`starci app check --coverage-from`), the Codecov upload and the Sonar import then read the one be/coverage/lcov.info they always read.
|
|
5
|
+
// Pure over the texts; the file walk is lcovFilesUnder.
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { byCodeUnit } from '../lib/list.mjs';
|
|
9
|
+
import { addHits as add } from './rules/coverage-below.mjs';
|
|
10
|
+
|
|
11
|
+
const count = (value) => (/^\d+$/.test(value) ? Number(value) : 0);
|
|
12
|
+
|
|
13
|
+
function recordOf(records, source) {
|
|
14
|
+
if (!records.has(source)) records.set(source, { lines: new Map(), fnLines: new Map(), fnHits: new Map(), branches: new Map() });
|
|
15
|
+
return records.get(source);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Function data: FN:<line>[,<end>],<name> declares, FNDA:<hits>,<name> counts (a name may hold commas). */
|
|
19
|
+
function functionLine(record, tag, value) {
|
|
20
|
+
const at = value.indexOf(',');
|
|
21
|
+
if (at < 0) return;
|
|
22
|
+
if (tag === 'FN') {
|
|
23
|
+
let name = value.slice(at + 1);
|
|
24
|
+
let line = value.slice(0, at);
|
|
25
|
+
if (/^\d+,/.test(name)) { line = `${line},${name.slice(0, name.indexOf(','))}`; name = name.slice(name.indexOf(',') + 1); }
|
|
26
|
+
if (!record.fnLines.has(name)) record.fnLines.set(name, line);
|
|
27
|
+
if (!record.fnHits.has(name)) record.fnHits.set(name, 0);
|
|
28
|
+
} else add(record.fnHits, value.slice(at + 1), count(value.slice(0, at)));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function dataLine(record, tag, value) {
|
|
32
|
+
if (tag === 'DA') {
|
|
33
|
+
const [line, hits] = value.split(',');
|
|
34
|
+
add(record.lines, Number(line), count(hits ?? ''));
|
|
35
|
+
} else if (tag === 'FN' || tag === 'FNDA') functionLine(record, tag, value);
|
|
36
|
+
else if (tag === 'BRDA') {
|
|
37
|
+
const parts = value.split(',');
|
|
38
|
+
if (parts.length === 4) add(record.branches, parts.slice(0, 3).join(','), parts[3] === '-' ? 0 : count(parts[3]));
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Every record of every text, merged by source file (SF) and identity. */
|
|
43
|
+
function parseAll(texts) {
|
|
44
|
+
const records = new Map();
|
|
45
|
+
for (const text of texts) {
|
|
46
|
+
let current = null;
|
|
47
|
+
for (const raw of String(text).replace(/^/, '').split(/\r?\n/)) {
|
|
48
|
+
const line = raw.trim();
|
|
49
|
+
if (line === 'end_of_record') { current = null; continue; }
|
|
50
|
+
const colon = line.indexOf(':');
|
|
51
|
+
if (colon < 0) continue;
|
|
52
|
+
const tag = line.slice(0, colon);
|
|
53
|
+
const value = line.slice(colon + 1);
|
|
54
|
+
if (tag === 'SF') current = recordOf(records, value);
|
|
55
|
+
else if (current) dataLine(current, tag, value);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return records;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const branchOrder = (a, b) => a.split(',').map(Number).reduce((order, part, index) => order || part - Number(b.split(',')[index]), 0);
|
|
62
|
+
|
|
63
|
+
function serialize(source, record) {
|
|
64
|
+
const out = ['TN:', `SF:${source}`];
|
|
65
|
+
const functions = [...record.fnLines.keys()].sort((a, b) => Number(record.fnLines.get(a).split(',')[0]) - Number(record.fnLines.get(b).split(',')[0]) || byCodeUnit(a, b));
|
|
66
|
+
for (const name of functions) out.push(`FN:${record.fnLines.get(name)},${name}`);
|
|
67
|
+
for (const name of functions) out.push(`FNDA:${record.fnHits.get(name) ?? 0},${name}`);
|
|
68
|
+
out.push(`FNF:${functions.length}`, `FNH:${functions.filter((name) => (record.fnHits.get(name) ?? 0) > 0).length}`);
|
|
69
|
+
const lines = [...record.lines.keys()].sort((a, b) => a - b);
|
|
70
|
+
for (const line of lines) out.push(`DA:${line},${record.lines.get(line)}`);
|
|
71
|
+
out.push(`LF:${lines.length}`, `LH:${lines.filter((line) => record.lines.get(line) > 0).length}`);
|
|
72
|
+
const branches = [...record.branches.keys()].sort(branchOrder);
|
|
73
|
+
for (const branch of branches) out.push(`BRDA:${branch},${record.branches.get(branch)}`);
|
|
74
|
+
out.push(`BRF:${branches.length}`, `BRH:${branches.filter((branch) => record.branches.get(branch) > 0).length}`, 'end_of_record');
|
|
75
|
+
return out.join('\n');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** One lcov text of the merged `texts`: a record per source file, sorted by path, hits summed by line, function name and branch identity. */
|
|
79
|
+
export function mergeLcov(texts) {
|
|
80
|
+
const records = parseAll(texts);
|
|
81
|
+
return [...records.keys()].sort(byCodeUnit).map((source) => serialize(source, records.get(source))).join('\n') + (records.size ? '\n' : '');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The lcov files a path names: the file itself, or every `lcov.info` below a directory (sorted). */
|
|
85
|
+
export function lcovFilesUnder(target) {
|
|
86
|
+
if (!fs.statSync(target).isDirectory()) return [target];
|
|
87
|
+
return fs.readdirSync(target, { recursive: true, withFileTypes: true }).filter((entry) => entry.isFile() && entry.name === 'lcov.info')
|
|
88
|
+
.map((entry) => path.join(entry.parentPath ?? entry.path, entry.name)).sort(byCodeUnit);
|
|
89
|
+
}
|
|
@@ -34,6 +34,7 @@ import { stackToolingFindings } from './stack-tooling.mjs';
|
|
|
34
34
|
import { stacksDeployFindings } from './stacks-deploy.mjs';
|
|
35
35
|
import { ciCustodyFindings } from './ci-custody.mjs';
|
|
36
36
|
import { workflowContextsFindings } from './workflow-contexts.mjs';
|
|
37
|
+
import { ciStandardAppFindings } from './ci-standard.mjs';
|
|
37
38
|
import { feBuildArgsFindings } from './fe-build-args.mjs';
|
|
38
39
|
// finders-import-end
|
|
39
40
|
|
|
@@ -42,6 +43,7 @@ export const A15_CODES = [
|
|
|
42
43
|
// codes-begin
|
|
43
44
|
'FE_PUBLIC_BUILD_ARG_MISSING',
|
|
44
45
|
'HFS_WORKFLOW_CONTEXT',
|
|
46
|
+
'CI_STANDARD',
|
|
45
47
|
'CI_SECRET_SEALED', 'APP_SECRET_IN_CI', 'CI_DECLARATION_DRIFT',
|
|
46
48
|
'BACKUP_PROOF_MISSING', 'BACKUP_UNDECLARED', 'BE_APP_BOOT_PROOF_MISSING', 'BE_APP_ENV_REQUIRED', 'BE_APP_MODULE_LIST_INLINE', 'BE_LITE_PROOF_LANE_MISSING', 'DB_API_EXPOSED', 'DB_ARCHIVE_MUTATED', 'DB_BASELINE_SHAPE', 'DB_CRON_OWNED', 'DB_EXTENSION_ALLOWED', 'DB_MIGRATION_DESTRUCTIVE', 'DB_PGMQ_DECLARED', 'DB_VAULT_WRITE', 'ENV_DUPLICATE', 'ENV_EXAMPLE_DRIFT', 'ENV_PUBLIC_SEALED', 'ENV_REQUIRED_UNSET', 'ENV_SEALED_UNREAD', 'ENV_VALUE_SHAPE', 'FE_STACK_CORE_MISSING', 'FE_STACK_UNLISTED', 'HFS_AGE_KEY_CUSTODY', 'HFS_APP_LEXICON', 'HFS_APP_WORKFLOW_TRIGGER', 'HFS_ASSET_KIND', 'HFS_ASSET_OVERSIZE', 'HFS_AUDIT_STEP_MISSING', 'HFS_CANON_COPY_DRIFT', 'HFS_CI_TOOL_INSTALL', 'HFS_COVERAGE_BELOW', 'HFS_DB_DEPLOY_UNGUARDED', 'HFS_DEPENDABOT_MISSING', 'HFS_DEPLOY_COMPOSE_ON_VPS', 'HFS_DEPLOY_NO_LOGIC', 'HFS_DEPLOY_SCHEDULER', 'HFS_DEPLOY_WORKFLOW', 'HFS_DOC_COMMAND_UNKNOWN', 'HFS_DOC_ENV_UNKNOWN', 'HFS_DOC_PATH_MISSING', 'HFS_DOC_RULE_UNKNOWN', 'HFS_FEATURE_FILE_BUDGET', 'HFS_HOST_CRON', 'HFS_RELEASE_TAG_MISMATCH', 'HFS_SBOM_MISSING', 'HFS_SCHEDULE_UNDECLARED', 'HFS_SCRIPT_PLAINTEXT_LOAD', 'HFS_SCRIPT_XTRACE', 'HFS_SECRET_OUTSIDE_ENC', 'HFS_SECRET_PLAINTEXT_FILE', 'HFS_SEED_PII', 'HFS_SERVICE_ROLE_SCOPE', 'HFS_UNUSED_SYMBOL', 'HFS_WORKFLOW_PIN', 'STACKS_DEPLOY_CONFIG_INVALID', 'STACKS_DNS_UNDECLARED', 'STACKS_HEALTHCHECK_MISSING', 'STACKS_HOST_UNROUTED', 'STACKS_INGRESS_UNDECLARED', 'STACKS_INGRESS_UNKNOWN', 'STACKS_LITE_SELFHOSTED_DB', 'STACKS_MODEL_UNAVAILABLE', 'STACKS_PLACEMENT_MISSING', 'STACKS_PORT_PUBLISHED', 'STACKS_REALTIME_SCALED', 'STACKS_RELEASE_UNDECLARED', 'STACKS_SECOND_INGRESS', 'STACKS_SECRET_ENV_LITERAL', 'STACKS_SECRET_NOT_EXTERNAL', 'STACKS_SECRET_UNVERSIONED', 'STACKS_SIDECAR_DB_UNSUPPORTED', 'STACKS_SIDECAR_VOLUME_UNDECLARED', 'STACKS_TLS_UNDECLARED', 'STACKS_UPDATE_POLICY_MISSING', 'STACK_TOOLING_REIMPLEMENTED',
|
|
47
49
|
// codes-end
|
|
@@ -51,6 +53,7 @@ const FINDERS = [
|
|
|
51
53
|
// finders-begin
|
|
52
54
|
feBuildArgsFindings,
|
|
53
55
|
workflowContextsFindings,
|
|
56
|
+
ciStandardAppFindings,
|
|
54
57
|
featureFileFindings,
|
|
55
58
|
lexiconFindings,
|
|
56
59
|
assetsFindings,
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
// ci-standard.mjs - R301 CI_STANDARD: the one StarCi CI standard (knowledge/hfs/ci-standard.yaml, OWNER RULING ci-standard 2026-10-18: ".claude defines ONE standard CI
|
|
2
|
+
// ruleset, its own CI and the examples follow it, and apps inherit it"). This file checks the items the standard marks `check: ci-standard`:
|
|
3
|
+
// job-shape the managed ci.yml holds exactly the edition's jobs; the unit run is in `test`, the proof and the Codecov upload in `coverage` (the one OIDC job)
|
|
4
|
+
// full-history-base-ref every actions/checkout fetches the full history; a job that lints writes STARCI_BASE_REF before it lints
|
|
5
|
+
// heap-per-step every heavy step (heap.steps) declares NODE_OPTIONS --max-old-space-size >= heap.minMiB on the step
|
|
6
|
+
// sonar-lint-per-side a lint with --sonar names --side
|
|
7
|
+
// optional-providers an optional (continue-on-error) Codecov or Sonar step follows its availability probe in the same job
|
|
8
|
+
// test-sharding the test job is a shard matrix; each shard runs --shard=i/N without the per-file threshold; the coverage job merges, then proves
|
|
9
|
+
// lint-parallel the lint job is a matrix over side [be, fe]
|
|
10
|
+
// cache-saved-always no cache keyed by the commit sha; actions/cache/save runs under if: always()
|
|
11
|
+
// timeouts every job declares timeout-minutes
|
|
12
|
+
// sha-pinned-actions every remote `uses:` names a 40-hex commit sha (docker:// a sha256 digest; ./ local actions allowed)
|
|
13
|
+
// `ciStandardFindings({doc, file, scope})` is the pure core: scope `app-ci` (an app's managed .github/workflows/ci.yml: the example apps and every app through the
|
|
14
|
+
// app check) or `runtime` (the runtime repository's own workflows, scripts/hfs/runtime-rules/runtime-ci-standard.mjs), each checking the items whose `covers` names it.
|
|
15
|
+
import fs from 'node:fs';
|
|
16
|
+
import { parseYaml } from '../../../engine/yaml.mjs';
|
|
17
|
+
import { parseWorkflow } from '../runtime-rules/workflow-source.mjs';
|
|
18
|
+
import { found } from './read.mjs';
|
|
19
|
+
import { appWorkflowFindings } from './app-workflow-findings.mjs';
|
|
20
|
+
import { isPlainObject as record } from '../../../engine/plain-object.mjs';
|
|
21
|
+
import { byCodeUnit } from '../../lib/list.mjs';
|
|
22
|
+
|
|
23
|
+
export const CI_STANDARD = 'CI_STANDARD';
|
|
24
|
+
const STANDARD_FILE = new URL('../../../knowledge/hfs/ci-standard.yaml', import.meta.url);
|
|
25
|
+
const APP_CI = '.github/workflows/ci.yml';
|
|
26
|
+
const SHA_PIN = /@[0-9a-f]{40}$/;
|
|
27
|
+
const DIGEST_PIN = /@sha256:[0-9a-f]{64}$/;
|
|
28
|
+
|
|
29
|
+
let cached = null;
|
|
30
|
+
/** The standard (knowledge/hfs/ci-standard.yaml), read once. */
|
|
31
|
+
function ciStandard() {
|
|
32
|
+
if (!cached) {
|
|
33
|
+
const doc = parseYaml(fs.readFileSync(STANDARD_FILE, 'utf8'));
|
|
34
|
+
if (doc?.schema !== 'starci/ci-standard@1' || !Array.isArray(doc.items)) throw new Error('knowledge/hfs/ci-standard.yaml is not a starci/ci-standard@1 standard');
|
|
35
|
+
cached = doc;
|
|
36
|
+
}
|
|
37
|
+
return cached;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const jobsOf = (doc) => (record(doc?.jobs) ? Object.entries(doc.jobs).filter(([, job]) => record(job)) : []);
|
|
41
|
+
const stepsOf = (job) => (Array.isArray(job.steps) ? job.steps.filter(record) : []);
|
|
42
|
+
const usesOf = (step) => String(step.uses ?? '').trim();
|
|
43
|
+
const runOf = (step) => (typeof step.run === 'string' ? step.run : '');
|
|
44
|
+
|
|
45
|
+
/** The items of the standard this file checks for `scope`: their ids. */
|
|
46
|
+
const checkedItems = (standard, scope) => new Set(standard.items.filter((item) => item.check === 'ci-standard' && item.covers.includes(scope)).map((item) => item.id));
|
|
47
|
+
|
|
48
|
+
function jobShape(doc, edition, standard, say) {
|
|
49
|
+
const shape = standard.jobs[edition] ?? standard.jobs.full;
|
|
50
|
+
const ids = jobsOf(doc).map(([id]) => id);
|
|
51
|
+
const out = [];
|
|
52
|
+
const missing = shape.required.filter((id) => !ids.includes(id));
|
|
53
|
+
const extra = ids.filter((id) => !shape.required.includes(id) && !shape.optional.includes(id));
|
|
54
|
+
const gaps = [missing.length ? 'missing ' + missing.join(', ') : '', extra.length ? 'not in the standard ' + extra.join(', ') : ''].filter(Boolean).join('; ');
|
|
55
|
+
if (gaps) out.push(say('job-shape', `the jobs must be ${shape.required.join(', ')} (optional ${shape.optional.join(', ')}); ${gaps}`));
|
|
56
|
+
const withStep = (match) => jobsOf(doc).filter(([, job]) => stepsOf(job).some((step) => match(step))).map(([id]) => id);
|
|
57
|
+
const unit = withStep((step) => step.name === 'unit');
|
|
58
|
+
const upload = withStep((step) => usesOf(step).startsWith('codecov/codecov-action@'));
|
|
59
|
+
const proof = withStep((step) => /app check --coverage-from/.test(runOf(step)));
|
|
60
|
+
if (unit.join() !== 'test') out.push(say('job-shape', `the unit run must be a step of the test job alone, not of ${unit.join(', ') || 'no job'}`));
|
|
61
|
+
if (proof.join() !== 'coverage') out.push(say('job-shape', `the coverage proof must be a step of the coverage job alone, not of ${proof.join(', ') || 'no job'}`));
|
|
62
|
+
if (upload.length && upload.join() !== 'coverage') out.push(say('job-shape', `the Codecov upload must be a step of the coverage job alone, not of ${upload.join(', ')}`));
|
|
63
|
+
const oidc = jobsOf(doc).filter(([, job]) => job.permissions?.['id-token'] === 'write').map(([id]) => id);
|
|
64
|
+
if (upload.length && oidc.join() !== 'coverage') out.push(say('job-shape', `only the coverage job holds the OIDC id-token (id-token: write), not ${oidc.join(', ') || 'no job'}`));
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function testSharding(doc, say) {
|
|
69
|
+
const test = record(doc.jobs?.test) ? doc.jobs.test : null;
|
|
70
|
+
if (!test) return [];
|
|
71
|
+
const out = [];
|
|
72
|
+
const shards = test.strategy?.matrix?.shard;
|
|
73
|
+
if (!Array.isArray(shards) || !shards.length) out.push(say('test-sharding', 'the test job must be a matrix over shard (ci.jobs.test.shards)'));
|
|
74
|
+
const unit = stepsOf(test).find((step) => step.name === 'unit');
|
|
75
|
+
const total = /--shard=\$\{\{\s*matrix\.shard\s*\}\}\/(\d+)/.exec(unit ? runOf(unit) : '');
|
|
76
|
+
if (unit && !total) out.push(say('test-sharding', 'the unit run must pass --shard=${{ matrix.shard }}/<N>'));
|
|
77
|
+
if (total && Array.isArray(shards) && Number(total[1]) !== shards.length) out.push(say('test-sharding', `the unit run's shard total ${total[1]} must equal the ${shards.length} matrix shards`));
|
|
78
|
+
if (unit && !runOf(unit).includes('--coverageThreshold={}')) out.push(say('test-sharding', 'a shard measures without the per-file threshold (--coverageThreshold={}); the coverage job proves it on the merged report'));
|
|
79
|
+
const coverage = record(doc.jobs?.coverage) ? stepsOf(doc.jobs.coverage) : [];
|
|
80
|
+
const merge = coverage.findIndex((step) => /app coverage merge\b/.test(runOf(step)));
|
|
81
|
+
const proof = coverage.findIndex((step) => /app check --coverage-from/.test(runOf(step)));
|
|
82
|
+
if (merge < 0 || (proof >= 0 && merge > proof)) out.push(say('test-sharding', 'the coverage job must merge the shard reports (starci app coverage merge) before the coverage proof'));
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function lintParallel(doc, say) {
|
|
87
|
+
const sides = record(doc.jobs?.lint) ? doc.jobs.lint.strategy?.matrix?.side : null;
|
|
88
|
+
return Array.isArray(sides) && sides.map(String).sort(byCodeUnit).join() === 'be,fe' ? [] : [say('lint-parallel', 'the lint job must be a matrix over side [be, fe]')];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function cacheSavedAlways(doc, say) {
|
|
92
|
+
const out = [];
|
|
93
|
+
for (const [id, job] of jobsOf(doc)) {
|
|
94
|
+
for (const step of stepsOf(job)) {
|
|
95
|
+
const uses = usesOf(step);
|
|
96
|
+
if (/^actions\/cache(?:\/restore)?@/.test(uses) && String(step.with?.key ?? '').includes('github.sha')) out.push(say('cache-saved-always', `job ${id}: a cache keyed by the commit sha never warms; key it by the lockfile hash (and the run) with restore-keys`));
|
|
97
|
+
if (uses.startsWith('actions/cache/save@') && !/^(?:\$\{\{\s*)?always\(\)/.test(String(step.if ?? '').trim())) out.push(say('cache-saved-always', `job ${id}: actions/cache/save runs under if: always(), so a red run still warms the cache`));
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return out;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function fullHistory(doc, say) {
|
|
104
|
+
const out = [];
|
|
105
|
+
for (const [id, job] of jobsOf(doc)) {
|
|
106
|
+
for (const step of stepsOf(job)) {
|
|
107
|
+
if (usesOf(step).startsWith('actions/checkout@') && String(step.with?.['fetch-depth']) !== '0') out.push(say('full-history-base-ref', `job ${id}: actions/checkout must fetch the full history (fetch-depth: 0)`));
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
for (const [id, job] of jobsOf(doc)) {
|
|
111
|
+
const steps = stepsOf(job);
|
|
112
|
+
const base = steps.findIndex((step) => /STARCI_BASE_REF=.*>>\s*"?\$GITHUB_ENV"?/.test(runOf(step)));
|
|
113
|
+
const lint = steps.findIndex((step) => /\bnpm run lint\b|\bstarci app lint\b/.test(runOf(step)));
|
|
114
|
+
if (lint >= 0 && (base < 0 || base > lint)) out.push(say('full-history-base-ref', `job ${id} must write STARCI_BASE_REF to GITHUB_ENV before its first lint step`));
|
|
115
|
+
}
|
|
116
|
+
return out;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function heapPerStep(doc, standard, say) {
|
|
120
|
+
const out = [];
|
|
121
|
+
for (const [id, job] of jobsOf(doc)) {
|
|
122
|
+
for (const step of stepsOf(job)) {
|
|
123
|
+
const run = runOf(step);
|
|
124
|
+
if (!standard.heap.steps.some((needle) => run.includes(needle))) continue;
|
|
125
|
+
const heap = /--max-old-space-size=(\d+)/.exec(String(step.env?.NODE_OPTIONS ?? ''));
|
|
126
|
+
if (!heap || Number(heap[1]) < standard.heap.minMiB) out.push(say('heap-per-step', `job ${id}, step ${step.name ?? run.split('\n')[0].slice(0, 60)}: a heavy step declares NODE_OPTIONS --max-old-space-size=${standard.heap.minMiB} (or more) on the step`));
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return out;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function sonarLintPerSide(doc, say) {
|
|
133
|
+
const out = [];
|
|
134
|
+
for (const [id, job] of jobsOf(doc)) {
|
|
135
|
+
for (const step of stepsOf(job)) {
|
|
136
|
+
const run = runOf(step);
|
|
137
|
+
if (/\b(?:npm run lint|starci(?:\.mjs)?["']?\s+app lint|app lint)\b/.test(run) && /\s--sonar\b/.test(run) && !/\s--side\s+(?:be\b|fe\b|\$\{\{\s*matrix\.side\s*\}\})/.test(run)) {
|
|
138
|
+
out.push(say('sonar-lint-per-side', `job ${id}, step ${step.name ?? 'lint'}: a lint with a Sonar import must name its side (--side be|fe) and the reports are merged afterwards; a combined lint with Sonar breaks the standard`));
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return out;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function optionalProviders(doc, say) {
|
|
146
|
+
const out = [];
|
|
147
|
+
const probes = [['codecov/codecov-action@', 'codecov-availability'], ['SonarSource/sonarqube-scan-action@', 'sonar-availability']];
|
|
148
|
+
for (const [id, job] of jobsOf(doc)) {
|
|
149
|
+
const steps = stepsOf(job);
|
|
150
|
+
steps.forEach((step, index) => {
|
|
151
|
+
const probe = probes.find(([prefix]) => usesOf(step).startsWith(prefix));
|
|
152
|
+
if (!probe || step['continue-on-error'] !== true) return;
|
|
153
|
+
if (!steps.slice(0, index).some((earlier) => earlier.id === probe[1])) out.push(say('optional-providers', `job ${id}: the optional ${usesOf(step).split('@')[0]} step must follow its availability probe (id ${probe[1]})`));
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
return out;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function timeouts(doc, say) {
|
|
160
|
+
return jobsOf(doc).filter(([, job]) => !Number.isFinite(Number(job['timeout-minutes'])) || job['timeout-minutes'] === null || job['timeout-minutes'] === '')
|
|
161
|
+
.filter(([, job]) => job.uses === undefined).map(([id]) => say('timeouts', `job ${id} must declare timeout-minutes`));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function shaPinned(doc, say) {
|
|
165
|
+
const out = [];
|
|
166
|
+
for (const [id, job] of jobsOf(doc)) {
|
|
167
|
+
for (const uses of [job.uses, ...stepsOf(job).map((step) => step.uses)].filter((value) => typeof value === 'string')) {
|
|
168
|
+
const ref = uses.trim();
|
|
169
|
+
if (ref.startsWith('./')) continue;
|
|
170
|
+
const pinned = ref.startsWith('docker://') ? DIGEST_PIN.test(ref) : SHA_PIN.test(ref);
|
|
171
|
+
if (!pinned) out.push(say('sha-pinned-actions', `job ${id}: ${ref} must be pinned to a commit sha (a docker:// action to a sha256 digest)`));
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return out;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** The R301 findings of one workflow document: scope `app-ci` (with the app's edition) or `runtime`. Pure over the document and the standard. */
|
|
178
|
+
export function ciStandardFindings({ doc, file, scope, edition = 'full', standard = ciStandard() }) {
|
|
179
|
+
if (!record(doc)) return [];
|
|
180
|
+
const items = checkedItems(standard, scope);
|
|
181
|
+
const say = (item, why) => found(CI_STANDARD, file, `${CI_STANDARD} ${file}: [${item}] ${why} (knowledge/hfs/ci-standard.yaml)`, { item });
|
|
182
|
+
const checks = {
|
|
183
|
+
'job-shape': () => jobShape(doc, edition, standard, say),
|
|
184
|
+
'full-history-base-ref': () => fullHistory(doc, say),
|
|
185
|
+
'heap-per-step': () => heapPerStep(doc, standard, say),
|
|
186
|
+
'sonar-lint-per-side': () => sonarLintPerSide(doc, say),
|
|
187
|
+
'optional-providers': () => optionalProviders(doc, say),
|
|
188
|
+
'test-sharding': () => testSharding(doc, say),
|
|
189
|
+
'lint-parallel': () => lintParallel(doc, say),
|
|
190
|
+
'cache-saved-always': () => cacheSavedAlways(doc, say),
|
|
191
|
+
timeouts: () => timeouts(doc, say),
|
|
192
|
+
'sha-pinned-actions': () => shaPinned(doc, say),
|
|
193
|
+
};
|
|
194
|
+
return Object.entries(checks).filter(([id]) => items.has(id)).flatMap(([, check]) => check());
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** R301 over an app: its managed .github/workflows/ci.yml (the app check, and the runtime check over every example app). */
|
|
198
|
+
export function ciStandardAppFindings(ctx) {
|
|
199
|
+
const edition = ctx.repo?.edition === 'lite' ? 'lite' : 'full';
|
|
200
|
+
return appWorkflowFindings({ ...ctx, inScope: (file) => file === APP_CI && (ctx.inScope ?? (() => true))(file) },
|
|
201
|
+
({ path: file, text }) => ciStandardFindings({ doc: parseWorkflow(text), file, scope: 'app-ci', edition }), { dedupe: true });
|
|
202
|
+
}
|
|
@@ -18,7 +18,7 @@ const countOf = (value) => /^\d+$/.test(value) && Number.isSafeInteger(Number(va
|
|
|
18
18
|
const slash = (value) => path.posix.normalize(String(value).replaceAll('\\', '/'));
|
|
19
19
|
|
|
20
20
|
const emptyRecord = (source) => ({ source, lines: new Map(), functions: new Map(), branches: new Map(), summary: {}, problems: [] });
|
|
21
|
-
const addHits = (map, key, hits) => map.set(key, Math.min(Number.MAX_SAFE_INTEGER, (map.get(key) ?? 0) + hits));
|
|
21
|
+
export const addHits = (map, key, hits) => map.set(key, Math.min(Number.MAX_SAFE_INTEGER, (map.get(key) ?? 0) + hits));
|
|
22
22
|
const parseProblem = (record, line, tag) => record.problems.push(`invalid ${tag} at lcov line ${line}`);
|
|
23
23
|
|
|
24
24
|
function lineData(record, value, line) {
|
|
@@ -68,18 +68,27 @@ function hasAgeIdentity(job, doc) {
|
|
|
68
68
|
return steps.some((step) => strings(step.with).some((value) => reference.test(value)) || (typeof step.run === 'string' && reference.test(executable(step.run))));
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
-
|
|
71
|
+
/** The release candidate branches (OWNER RULING candidate-ci, 2026-10-18): `starci app release push` waits for the ci.yml run of the release sha there. */
|
|
72
|
+
const CANDIDATE_BRANCHES = 'release/candidate-*';
|
|
73
|
+
const pushFilterValid = (options) => record(options) && Object.keys(options).length > 0 && Object.keys(options).every((key) => key === 'tags' || key === 'branches')
|
|
74
|
+
&& (options.tags === undefined || (Array.isArray(options.tags) && options.tags.length === 1 && options.tags[0] === 'v*'))
|
|
75
|
+
&& (options.branches === undefined || (Array.isArray(options.branches) && options.branches.length === 1 && options.branches[0] === CANDIDATE_BRANCHES));
|
|
76
|
+
|
|
77
|
+
function triggerFindings(doc, refuse, file = '') {
|
|
72
78
|
const on = workflowEventOptions(doc.on);
|
|
73
79
|
if (!record(on) || !Object.keys(on).length) return [refuse(TRIGGER, 'declare release tags [v*] and/or workflow_dispatch')];
|
|
74
80
|
const findings = [];
|
|
81
|
+
if (/(^|\/)ci\.ya?ml$/.test(file) && !(record(on.push) && Array.isArray(on.push.branches) && on.push.branches.includes(CANDIDATE_BRANCHES))) {
|
|
82
|
+
findings.push(refuse(TRIGGER, `the release CI must run on push of branches ['${CANDIDATE_BRANCHES}']: starci app release push waits for that run (starci app sync renders it)`));
|
|
83
|
+
}
|
|
75
84
|
for (const [event, options] of Object.entries(on)) {
|
|
76
85
|
if (event === 'workflow_dispatch') continue;
|
|
77
86
|
if (event !== 'push') {
|
|
78
|
-
findings.push(refuse(TRIGGER, `trigger ${event} is forbidden; app CI starts only on release tags [v*] or workflow_dispatch`));
|
|
87
|
+
findings.push(refuse(TRIGGER, `trigger ${event} is forbidden; app CI starts only on release tags [v*], the release candidate branches or workflow_dispatch`));
|
|
79
88
|
continue;
|
|
80
89
|
}
|
|
81
|
-
if (!
|
|
82
|
-
findings.push(refuse(TRIGGER,
|
|
90
|
+
if (!pushFilterValid(options)) {
|
|
91
|
+
findings.push(refuse(TRIGGER, `push may contain only tags: [v*] and branches: ['${CANDIDATE_BRANCHES}']; other branch and path filters are forbidden`));
|
|
83
92
|
}
|
|
84
93
|
}
|
|
85
94
|
return findings;
|
|
@@ -197,7 +206,7 @@ function perWorkflow({ path: file, text }) {
|
|
|
197
206
|
const refuse = (code, message) => found(code, file, `${code} ${file}: ${message}`);
|
|
198
207
|
const doc = parseWorkflow(text);
|
|
199
208
|
if (!record(doc)) return [refuse(TRIGGER, 'the workflow must be valid YAML with a release-only on block'), ...(deployName(file) ? [refuse(DEPLOY, 'the deploy workflow must be valid YAML')] : [])];
|
|
200
|
-
return [...triggerFindings(doc, refuse), ...deploymentFindings(doc, file, refuse), ...toolFindings(doc, refuse)];
|
|
209
|
+
return [...triggerFindings(doc, refuse, file), ...deploymentFindings(doc, file, refuse), ...toolFindings(doc, refuse)];
|
|
201
210
|
}
|
|
202
211
|
|
|
203
212
|
/** Synchronous R251 findings over the tracked workflows of a lite/full app. */
|
|
@@ -75,7 +75,7 @@ function expressions(value) {
|
|
|
75
75
|
}
|
|
76
76
|
|
|
77
77
|
/** Source markers travel inside parsed scalar expressions, preserving flow maps, quoted keys and repeated values without matching comments. */
|
|
78
|
-
function workflowContextFindings({ path: file, text }) {
|
|
78
|
+
export function workflowContextFindings({ path: file, text }) {
|
|
79
79
|
const marked = text.replace(/\$\{\{/g, (match, offset) => `${match} __hfs_context_line_${text.slice(0, offset).split('\n').length}__ `);
|
|
80
80
|
const findings = [];
|
|
81
81
|
function inspect(value, keys) {
|