@starci/hfs 4.5.1 → 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.
Files changed (44) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/ci/cli.mjs +8 -4
  3. package/ci/local.mjs +146 -0
  4. package/ci/vars.mjs +22 -5
  5. package/ci/verify.mjs +16 -2
  6. package/lint/run.mjs +3 -4
  7. package/package.json +1 -1
  8. package/release/candidate-ci.mjs +111 -0
  9. package/release/gate.mjs +4 -5
  10. package/release/push.mjs +10 -1
  11. package/runtime/knowledge/hfs/canon-pins.yaml +9 -4
  12. package/runtime/knowledge/hfs/ci-standard.yaml +103 -0
  13. package/runtime/knowledge/hfs/rules.yaml +19 -2
  14. package/runtime/knowledge/sonar-gate.yaml +2 -2
  15. package/runtime/modules/kernel/failure-codes.yaml +10 -0
  16. package/runtime/modules/schemas/application-stacks.schema.yaml +56 -0
  17. package/runtime/packages/cli/src/catalog.generated.mjs +121 -36
  18. package/runtime/scripts/hfs/ci-declaration.mjs +10 -1
  19. package/runtime/scripts/hfs/lcov-merge.mjs +89 -0
  20. package/runtime/scripts/hfs/rules/a15-findings.mjs +3 -0
  21. package/runtime/scripts/hfs/rules/ci-standard.mjs +202 -0
  22. package/runtime/scripts/hfs/rules/coverage-below.mjs +1 -1
  23. package/runtime/scripts/hfs/rules/deploy-workflow.mjs +14 -5
  24. package/runtime/scripts/hfs/rules/secrets.mjs +8 -10
  25. package/runtime/scripts/hfs/rules/workflow-contexts.mjs +1 -1
  26. package/runtime/scripts/lib/secret-patterns.mjs +77 -0
  27. package/scaffold/app.mjs +5 -4
  28. package/src/main.mjs +4 -2
  29. package/sync/ci-commands.mjs +81 -27
  30. package/sync/hygiene.mjs +4 -1
  31. package/sync/index.mjs +14 -11
  32. package/sync/quality-services.mjs +7 -5
  33. package/sync/readme-badges.mjs +18 -2
  34. package/sync/stack-images.mjs +5 -0
  35. package/templates/app/ci-workflows/build-cache-save.yml +8 -0
  36. package/templates/app/ci-workflows/github/workflows/ci.yml +52 -12
  37. package/templates/app/ci-workflows/install-steps.yml +3 -2
  38. package/templates/app/ci-workflows/jest-cache-save.yml +6 -0
  39. package/templates/app/ci-workflows/jest-cache.yml +4 -4
  40. package/templates/app/ci-workflows-lite/github/workflows/ci.yml +53 -14
  41. package/templates/app/hooks/husky/pre-push +4 -2
  42. package/templates/app/skeleton/.github/dependabot.yml +1 -2
  43. package/templates/app/skeleton-lite/.github/dependabot.yml +1 -2
  44. package/release/ci-red.mjs +0 -70
@@ -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, `branches: [main]`; no paths or ignore filter beside them) and `workflow_dispatch`. The examples and the hfs app templates (so every scaffolded app inherits it) take the tag and the dispatch only: an app's CI runs once per release, on its tag. A push to any other branch, a pull_request, a schedule, a workflow_call or any other event refuses the workflow."
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 a branch push."
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"}
@@ -41,8 +41,8 @@ newCode:
41
41
  minReviewedPercent: 100
42
42
  unreviewedMax: 0
43
43
  # The whole code, not only the new code. Every finding of the HFS canon reaches Sonar through ONE entry, `starci app lint`
44
- # (packages/hfs, contract changes hfs-sonar-import and hfs-lint-entry), which writes ONE file, reports/lint.sonar.json
45
- # (`starci app lint --sonar reports/lint.sonar.json`), named by sonar.externalIssuesReportPaths; the gate holds the open-issue count of
44
+ # (packages/hfs, contract changes hfs-sonar-import and hfs-lint-entry). CI runs it per side with --side and --sonar,
45
+ # then merges rules by id and joins issues into ONE file, reports/lint.sonar.json, named by sonar.externalIssuesReportPaths; the gate holds the open-issue count of
46
46
  # the whole project at zero. The file carries three engines:
47
47
  # starci-hfs the repository findings of `starci app check` (managed files, tree, pins, CI, contracts; the rule id is the finding code)
48
48
  # eslint ESLint's json over the repository (the BE and FE canon plugins alike, per-file and project-graph rules)
@@ -1701,6 +1701,16 @@ CI_SECRET_SEALED:
1701
1701
  owner: op-retry
1702
1702
  kind: check-finding
1703
1703
 
1704
+ CI_STANDARD:
1705
+ title: "A workflow breaks the StarCi CI standard"
1706
+ title_vi: "Workflow vi phạm chuẩn CI của StarCi"
1707
+ meaning_vi: "Workflow CI (của app, của ví dụ hoặc của runtime) không đáp ứng một mục của knowledge/hfs/ci-standard.yaml; mục bị vi phạm được nêu trong ngoặc vuông."
1708
+ causes_vi:
1709
+ - "Vi phạm R301: job shape, lịch sử đầy đủ và STARCI_BASE_REF, heap cho bước nặng, lint Sonar theo từng phía, probe nhà cung cấp tùy chọn, timeout hoặc ghim action theo sha."
1710
+ nextStep_vi: "App: chạy starci app sync --write để render lại CI chuẩn (không sửa tay); runtime: sửa workflow theo mục được nêu."
1711
+ owner: op-retry
1712
+ kind: check-finding
1713
+
1704
1714
  CI_TRIGGERS_RELEASE_ONLY:
1705
1715
  title: "Workflow trigger other than a release tag, a person or the runtime's main"
1706
1716
  title_vi: "Workflow có trigger không phải tag phát hành, người chạy tay hay main của runtime"
@@ -152,6 +152,13 @@ $defs:
152
152
  optional:
153
153
  description: Skip an unreachable quality provider with a notice; SonarQube defaults true, SonarCloud and Codecov false.
154
154
  type: boolean
155
+ badgeToken:
156
+ description: >-
157
+ codecov and sonar only: a read-only, public-safe badge token (Codecov graphing token, Sonar project badge token)
158
+ that the README badge of a private repository needs. It is public configuration rendered into README.md, never a
159
+ secret; a CI token or any write credential never goes here.
160
+ type: string
161
+ pattern: '^[A-Za-z0-9_-]{6,128}$'
155
162
  ecosystems: {type: array, items: {enum: [npm, docker, github-actions]}}
156
163
  schedule:
157
164
  type: object
@@ -275,8 +282,30 @@ $defs:
275
282
  repository: {type: string, pattern: '^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$'}
276
283
  defaultBranch: {type: string, minLength: 1, default: main}
277
284
  workflow: {type: string, pattern: '^[A-Za-z0-9_.-]+\.ya?ml$', default: ci.yml}
285
+ visibility:
286
+ description: The GitHub repository visibility; private READMEs need services.<id>.badgeToken for live badges. Omitted means unknown.
287
+ enum: [public, private]
278
288
  secrets: {type: array, items: {$ref: '#/$defs/ciSecret'}}
279
289
  vars: {type: array, items: {$ref: '#/$defs/ciVariable'}}
290
+ jobs:
291
+ description: >-
292
+ Per-job settings of the managed CI (the CI standard, knowledge/hfs/ci-standard.yaml defaults): timeoutMinutes per job and the number of unit-run shards.
293
+ Omitted settings take the standard's defaults; starci app sync renders them.
294
+ type: object
295
+ additionalProperties: false
296
+ properties:
297
+ fast: {$ref: '#/$defs/ciJob'}
298
+ lint: {$ref: '#/$defs/ciJob'}
299
+ test:
300
+ type: object
301
+ additionalProperties: false
302
+ properties:
303
+ timeoutMinutes: {type: integer, minimum: 5, maximum: 360}
304
+ shards: {type: integer, minimum: 1, maximum: 20}
305
+ coverage: {$ref: '#/$defs/ciJob'}
306
+ build: {$ref: '#/$defs/ciJob'}
307
+ sonar: {$ref: '#/$defs/ciJob'}
308
+ db-types: {$ref: '#/$defs/ciJob'}
280
309
  dependabot:
281
310
  type: object
282
311
  additionalProperties: false
@@ -290,6 +319,11 @@ $defs:
290
319
  properties:
291
320
  name: {$ref: '#/$defs/ciName'}
292
321
  purpose: {type: string}
322
+ ciJob:
323
+ type: object
324
+ additionalProperties: false
325
+ properties:
326
+ timeoutMinutes: {type: integer, minimum: 5, maximum: 360}
293
327
  ciSecret:
294
328
  anyOf:
295
329
  - type: object
@@ -310,6 +344,9 @@ $defs:
310
344
  purpose: {type: string}
311
345
  optional: {type: boolean}
312
346
  ciVariable:
347
+ description: >-
348
+ A public CI variable either carries its literal value, or declares ownerValue true (no value) when only the owner
349
+ may supply it; var sync skips an owner-supplied value and ci verify checks that it exists by name.
313
350
  anyOf:
314
351
  - type: object
315
352
  additionalProperties: false
@@ -330,6 +367,25 @@ $defs:
330
367
  value: {type: string}
331
368
  purpose: {type: string}
332
369
  optional: {type: boolean}
370
+ - type: object
371
+ additionalProperties: false
372
+ required: [name, scope, ownerValue]
373
+ properties:
374
+ name: {$ref: '#/$defs/ciName'}
375
+ scope: {const: repository}
376
+ ownerValue: {const: true}
377
+ purpose: {type: string}
378
+ optional: {type: boolean}
379
+ - type: object
380
+ additionalProperties: false
381
+ required: [name, scope, ownerValue, environment]
382
+ properties:
383
+ name: {$ref: '#/$defs/ciName'}
384
+ scope: {const: environment}
385
+ environment: {type: string, pattern: '^[a-z][a-z0-9-]*$'}
386
+ ownerValue: {const: true}
387
+ purpose: {type: string}
388
+ optional: {type: boolean}
333
389
  serviceStack:
334
390
  description: >-
335
391
  Which stack defines a local service - either a project-owned .starcistacks stack
@@ -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": "d230745d3aa5cfe65f75cb5fb2872924b935f1b4610c0b72db2f729572386404",
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 and verify their custody",
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
@@ -224,22 +225,37 @@ export const CATALOG = {
224
225
  {
225
226
  "name": "value",
226
227
  "type": "string",
227
- "summary": "public variable value that must agree with the manifest"
228
+ "summary": "public variable value that must agree with the manifest; var set ownerValue requires stdin instead"
228
229
  },
229
230
  {
230
231
  "name": "execute",
231
232
  "type": "boolean",
232
- "summary": "apply the variable sync plan"
233
+ "summary": "apply the variable sync plan; var sync skips ownerValue variables"
233
234
  },
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 verification clean",
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 RELEASE_CI_RED and RELEASE_SONAR_UNAVAILABLE"
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 checks the newest completed ci.yml run on the default branch, including reachable push/tag runs;",
645
- "failure, cancelled or timed_out refuses RELEASE_CI_RED with its URL and SHA;",
646
- "unreadable GitHub or no run warns RELEASE_CI_UNKNOWN and continues",
647
- "only the owner's starci release ci-override --sha <sha> --reason <text> record can override red CI; there is no --no-ci-check flag",
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"
@@ -7317,12 +7387,12 @@ export const CATALOG = {
7317
7387
  {
7318
7388
  "name": "dev",
7319
7389
  "type": "boolean",
7320
- "summary": "save the packages as development dependencies"
7390
+ "summary": "save the CLI and explicit packages as development dependencies"
7321
7391
  },
7322
7392
  {
7323
7393
  "name": "canon",
7324
7394
  "type": "boolean",
7325
- "summary": "install the target CLI and all of its exact StarCi canon pins"
7395
+ "summary": "install the target CLI and move its declared exact StarCi canon pins"
7326
7396
  }
7327
7397
  ],
7328
7398
  "exit": {
@@ -7353,10 +7423,12 @@ export const CATALOG = {
7353
7423
  "bare package names resolve only from knowledge/hfs/canon-pins.yaml and never through a registry lookup",
7354
7424
  "explicit versions use exact x.y.z form and must agree with the canon pin when one exists, except the target CLI version on a canon upgrade",
7355
7425
  "naming @starci/cli or passing --canon installs the exact target CLI first,",
7356
- "then reads its installed HFS runtime canon-pins.yaml and installs every pinned @starci package in the same host lock",
7426
+ "then reads its installed HFS runtime canon-pins.yaml and, in the same host lock,",
7427
+ "moves each other pinned @starci package only where a root or workspace package.json declares it, in its own section",
7357
7428
  "--canon without a CLI positional uses the running runtime's exact CLI pin as the target",
7358
7429
  "target pins never fall back to the running runtime when the target pin file is missing or inconsistent",
7359
- "each target StarCi pin is reported as moved or refused with a reason; a failed canon phase exits nonzero even when the first CLI install succeeded",
7430
+ "each target StarCi pin is reported as moved (with its workspace), skipped when no package.json declares it, or refused with a reason",
7431
+ "a failed canon phase exits nonzero even when the first CLI install succeeded",
7360
7432
  "installs always use --save-exact and report the package.json and package-lock.json diff stat"
7361
7433
  ],
7362
7434
  "runtimeSide": "host"
@@ -7721,6 +7793,11 @@ export const CATALOG = {
7721
7793
  "name": "keep",
7722
7794
  "type": "boolean",
7723
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)"
7724
7801
  }
7725
7802
  ],
7726
7803
  "exit": {
@@ -7731,7 +7808,8 @@ export const CATALOG = {
7731
7808
  "json": "none",
7732
7809
  "examples": [
7733
7810
  "starci release app-installs",
7734
- "starci release app-installs --keep"
7811
+ "starci release app-installs --keep",
7812
+ "starci release app-installs --source"
7735
7813
  ],
7736
7814
  "editions": [
7737
7815
  "full"
@@ -7776,7 +7854,7 @@ export const CATALOG = {
7776
7854
  "runtimeSide": "tree"
7777
7855
  },
7778
7856
  "ci-override": {
7779
- "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",
7780
7858
  "owner": "runtime",
7781
7859
  "flags": [
7782
7860
  {
@@ -7811,14 +7889,17 @@ export const CATALOG = {
7811
7889
  "script": "scripts/supervisor/release-ci-override-cli.mjs"
7812
7890
  },
7813
7891
  "conventions": [
7814
- "OWNER RULING 2026-10-17 (chat to main, relayed): alpha.20 \"cut refuses when the latest CI on main is red, with a documented owner override verb\";",
7815
- "the override is owner-only, records who and why in the owner rulings, and agents cannot run it.",
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).",
7816
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.",
7817
7896
  "Writes <git-common-dir>/starci-release/<sha>.ci-override.json, schema starci/ci-override@1,",
7818
7897
  "with sha, reason, by (git user.name and OS user), at, and runUrl (empty if GitHub is unavailable).",
7819
- "For the runtime, appends ci-override-<sha12> under rulings before the top-level supersedes key in modules/kernel/owner-rulings.yaml.",
7820
- "Commit that entry into the tree being cut. App repositories receive only the record.",
7821
- "An override applies only to its exact SHA. It does not permit a cut when CI is unknown. Existing ruling IDs with another reason refuse."
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."
7822
7903
  ],
7823
7904
  "runtimeSide": "tree"
7824
7905
  },
@@ -7940,8 +8021,8 @@ export const CATALOG = {
7940
8021
  }
7941
8022
  ],
7942
8023
  "exit": {
7943
- "0": "the release was cut and main plus its tag were pushed atomically, or --plan found nothing missing",
7944
- "1": "refused (RELEASE_CI_RED, 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",
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",
7945
8026
  "2": "bad usage"
7946
8027
  },
7947
8028
  "json": "flag",
@@ -7958,14 +8039,18 @@ export const CATALOG = {
7958
8039
  "script": "scripts/supervisor/release-cut-cli.mjs"
7959
8040
  },
7960
8041
  "conventions": [
7961
- "OWNER RULING 2026-10-17 (chat to main, relayed): alpha.20 \"cut refuses when the latest CI on main is red, with a documented owner override verb\";",
7962
- "the override is owner-only, records who and why in the owner rulings, and agents cannot run it.",
7963
- "Before the suite, including --plan, read the newest completed CI workflow run on main.",
7964
- "Only success continues; every other conclusion refuses RELEASE_CI_RED and names the run URL and headSha.",
7965
- "Unreadable GitHub or no completed run refuses RELEASE_CI_UNKNOWN: a cut must not proceed blind, including under suite ci.",
7966
- "The owner may run starci release ci-override --sha <headSha> --reason <text>.",
7967
- "A red run requires both starci-release/<headSha>.ci-override.json in the git common dir and ci-override-<sha12>",
7968
- "in modules/kernel/owner-rulings.yaml of the tree being cut. Commit the ruling before cutting."
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."
7969
8054
  ],
7970
8055
  "runtimeSide": "tree"
7971
8056
  },
@@ -4,6 +4,14 @@ import { parseYaml } from '../../engine/yaml.mjs';
4
4
 
5
5
  const nameOf = (item) => typeof item === 'string' ? item : item?.name;
6
6
 
7
+ /** A public CI variable declares either a manifest value or an owner-supplied value, never both. */
8
+ function variableProblems(item, at, invalid) {
9
+ const hasValue = item != null && Object.hasOwn(item, 'value');
10
+ const hasOwnerValue = item != null && Object.hasOwn(item, 'ownerValue');
11
+ if (hasValue && hasOwnerValue) invalid(at, 'a variable has either a value or ownerValue');
12
+ else if (!hasValue && item?.ownerValue !== true) invalid(at, 'a variable needs a value or ownerValue: true');
13
+ }
14
+
7
15
  /** Each CI name belongs to one class and scope; declarations contain names and public configuration only. */
8
16
  export function readCiDeclaration(doc) {
9
17
  const problems = [], seen = new Set();
@@ -17,8 +25,9 @@ export function readCiDeclaration(doc) {
17
25
  if (!['repository', 'environment'].includes(scope)) invalid(`${where}.scope`, 'scope must be repository or environment');
18
26
  if (scope === 'environment' && (typeof item?.environment !== 'string' || !/^[a-z][a-z0-9-]*$/.test(item.environment))) invalid(`${where}.environment`, 'environment scope requires an environment name');
19
27
  if ((dependabot || scope !== 'environment') && item && Object.hasOwn(item, 'environment')) invalid(`${where}.environment`, 'only environment scope may name an environment');
28
+ if (variable) variableProblems(item, where, invalid);
20
29
  return { name, scope, environment: item?.environment ?? null, purpose: item?.purpose ?? null,
21
- optional: item?.optional === true, ...(variable ? { value: item?.value } : {}) };
30
+ optional: item?.optional === true, ...(variable ? { value: item?.value, ownerValue: item?.ownerValue === true } : {}) };
22
31
  });
23
32
  const ci = doc?.ci;
24
33
  const secrets = normalize(ci?.secrets, 'ci.secrets');