@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.
Files changed (31) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/ci/cli.mjs +2 -0
  3. package/ci/local.mjs +148 -0
  4. package/coverage/cli.mjs +49 -0
  5. package/package.json +2 -1
  6. package/release/candidate-ci.mjs +111 -0
  7. package/release/gate.mjs +4 -5
  8. package/release/push.mjs +10 -1
  9. package/runtime/knowledge/hfs/canon-pins.yaml +9 -4
  10. package/runtime/knowledge/hfs/ci-standard.yaml +103 -0
  11. package/runtime/knowledge/hfs/rules.yaml +19 -2
  12. package/runtime/modules/kernel/failure-codes.yaml +10 -0
  13. package/runtime/modules/schemas/application-stacks.schema.yaml +24 -0
  14. package/runtime/packages/cli/src/catalog.generated.mjs +123 -30
  15. package/runtime/scripts/hfs/lcov-merge.mjs +134 -0
  16. package/runtime/scripts/hfs/rules/a15-findings.mjs +3 -0
  17. package/runtime/scripts/hfs/rules/ci-standard.mjs +202 -0
  18. package/runtime/scripts/hfs/rules/coverage-below.mjs +1 -1
  19. package/runtime/scripts/hfs/rules/deploy-workflow.mjs +14 -5
  20. package/runtime/scripts/hfs/rules/workflow-contexts.mjs +1 -1
  21. package/scaffold/app.mjs +3 -2
  22. package/src/main.mjs +4 -2
  23. package/sync/ci-commands.mjs +42 -25
  24. package/templates/app/ci-workflows/build-cache-save.yml +8 -0
  25. package/templates/app/ci-workflows/github/workflows/ci.yml +49 -9
  26. package/templates/app/ci-workflows/install-steps.yml +3 -2
  27. package/templates/app/ci-workflows/jest-cache-save.yml +6 -0
  28. package/templates/app/ci-workflows/jest-cache.yml +4 -4
  29. package/templates/app/ci-workflows-lite/github/workflows/ci.yml +50 -10
  30. package/templates/app/hooks/husky/pre-push +4 -2
  31. package/release/ci-red.mjs +0 -102
@@ -1,8 +1,10 @@
1
1
  {{header}}
2
2
  name: ci
3
3
 
4
+ # The release candidate ref first (starci app release push waits for this run of the release sha before the branch and the tag move), then the tag.
4
5
  on:
5
6
  push:
7
+ branches: ["release/candidate-*"]
6
8
  tags: ["v*"]
7
9
  workflow_dispatch:
8
10
 
@@ -14,24 +16,58 @@ concurrency:
14
16
  group: ci-${{ github.ref }}
15
17
  cancel-in-progress: false
16
18
 
19
+ # The job shape of the StarCi CI standard (knowledge/hfs/ci-standard.yaml): fast, lint per side, the unit run in shards, one coverage merge, build,
20
+ # sonar, and the database types of the lite edition. Timeouts and the shard count come from ci.jobs of .starcistacks/application-stacks.yaml (defaults in the standard).
17
21
  jobs:
18
22
  fast:
19
- timeout-minutes: 20
23
+ timeout-minutes: {{timeoutFast}}
20
24
  runs-on: ubuntu-latest
21
- {{sonarEnv}}
22
25
  steps:
23
26
  {{> app/ci-workflows/install-steps.yml}}
24
- {{> app/ci-workflows/lint-base.yml}}
25
27
  - name: production dependency audit
26
28
  run: {{auditCommand}}
27
- {{ciLintSteps}}
28
29
  - name: format
29
30
  run: npm run format:check
30
31
  - name: typecheck
31
32
  run: npm run typecheck
32
33
 
34
+ lint:
35
+ timeout-minutes: {{timeoutLint}}
36
+ runs-on: ubuntu-latest
37
+ {{sonarEnv}}
38
+ strategy:
39
+ fail-fast: false
40
+ matrix:
41
+ side: [be, fe]
42
+ steps:
43
+ {{> app/ci-workflows/install-steps.yml}}
44
+ {{> app/ci-workflows/lint-base.yml}}
45
+ {{ciLintSteps}}
46
+
33
47
  test:
34
- timeout-minutes: 45
48
+ timeout-minutes: {{timeoutTest}}
49
+ runs-on: ubuntu-latest
50
+ strategy:
51
+ fail-fast: false
52
+ matrix:
53
+ shard: {{testShards}}
54
+ steps:
55
+ {{> app/ci-workflows/install-steps.yml}}
56
+ {{> app/ci-workflows/jest-cache.yml}}
57
+ # Each shard measures without the per-file threshold (a shard loads only its share of the specs, and a small suite may leave a shard empty);
58
+ # the coverage job proves 100 per file on the merged report.
59
+ - name: unit
60
+ run: npm test -- --ci --forceExit --cacheDirectory .jest-cache --shard=${{ matrix.shard }}/{{testShardTotal}} --passWithNoTests --coverageThreshold={}
61
+ {{> app/ci-workflows/jest-cache-save.yml}}
62
+ - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
63
+ if: always() && hashFiles('{{lcovReport}}') != ''
64
+ with:
65
+ name: coverage-shard-${{ matrix.shard }}
66
+ path: {{lcovReport}}
67
+
68
+ coverage:
69
+ needs: test
70
+ timeout-minutes: {{timeoutCoverage}}
35
71
  runs-on: ubuntu-latest
36
72
  # Codecov OIDC authentication requires a job-scoped identity token.
37
73
  permissions:
@@ -39,9 +75,12 @@ jobs:
39
75
  id-token: write
40
76
  steps:
41
77
  {{> app/ci-workflows/install-steps.yml}}
42
- {{> app/ci-workflows/jest-cache.yml}}
43
- - name: unit
44
- run: npm test -- --ci --forceExit --cacheDirectory .jest-cache
78
+ - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
79
+ with:
80
+ pattern: coverage-shard-*
81
+ path: {{lcovShardsDir}}
82
+ - name: merge coverage
83
+ run: npx starci app coverage merge --out {{lcovReport}} {{lcovShardsDir}}
45
84
  - name: coverage proof
46
85
  run: {{ciCoverageProof}}
47
86
  env:
@@ -54,19 +93,20 @@ jobs:
54
93
  {{codecovSteps}}
55
94
 
56
95
  build:
57
- timeout-minutes: 30
96
+ timeout-minutes: {{timeoutBuild}}
58
97
  runs-on: ubuntu-latest
59
98
  steps:
60
99
  {{> app/ci-workflows/install-steps.yml}}
61
100
  - name: build be
62
101
  run: npm run build:be
63
102
  {{ciBuildSteps}}
103
+ {{> app/ci-workflows/build-cache-save.yml}}
64
104
  - name: database migration checks
65
105
  run: {{ciDatabaseLint}}
66
106
 
67
107
  {{sonarJob}}
68
108
  db-types:
69
- timeout-minutes: 30
109
+ timeout-minutes: {{timeoutDbTypes}}
70
110
  runs-on: ubuntu-latest
71
111
  steps:
72
112
  {{> app/ci-workflows/install-steps.yml}}
@@ -4,6 +4,8 @@
4
4
  # refs/backup/* allowed: the coordinator backup namespace
5
5
  # refs/heads/main, refs/tags/v* allowed only when HEAD carries an annotated tag v[0-9]* and the release cut recorded the
6
6
  # full suite of HEAD: <git common dir>/starci-release/<HEAD sha>.l4.json names that head and tag
7
+ # refs/heads/release/candidate-* the release candidate (starci app release push sends the release sha there first and moves main
8
+ # and the tag only when its ci.yml run is green): allowed under the same release gate, never deleted
7
9
  # any other ref, a delete refused
8
10
  common=$(git rev-parse --git-common-dir)
9
11
  case "$common" in /* | [A-Za-z]:*) ;; *) common="$(pwd)/$common" ;; esac
@@ -12,7 +14,7 @@ record="$common/starci-release/$head.l4.json"
12
14
  status=0
13
15
  gate=""
14
16
  refuse() {
15
- echo "RIGHTS_PUSH_NOT_RELEASE: $1 - the allowed path is the release cut (main and v* tags) or refs/backup/* (backups)" >&2
17
+ echo "RIGHTS_PUSH_NOT_RELEASE: $1 - the allowed path is the release cut (main, v* tags and the release/candidate-* ref) or refs/backup/* (backups)" >&2
16
18
  status=1
17
19
  }
18
20
  # gate: the verdict of the release gate for HEAD, computed once: "ok" or the reason it is closed.
@@ -33,7 +35,7 @@ release_gate() {
33
35
  while read -r local_ref local_sha remote_ref remote_sha; do
34
36
  case "$remote_ref" in
35
37
  refs/backup/*) ;;
36
- refs/heads/main | refs/tags/v*)
38
+ refs/heads/main | refs/tags/v* | refs/heads/release/candidate-*)
37
39
  case "$local_sha" in
38
40
  *[!0]*) ;;
39
41
  *) refuse "refusing to delete $remote_ref"; continue ;;
@@ -1,102 +0,0 @@
1
- // ci-red.mjs - the latest completed default-branch CI is a release precondition; unreadable GitHub warns, a red run needs the owner's SHA-bound override.
2
- import fs from 'node:fs';
3
- import path from 'node:path';
4
- import { refusal } from '../stack/errors.mjs';
5
-
6
- const FIELDS = 'databaseId,headSha,status,conclusion,url,event,createdAt';
7
- const SHA = /^[a-f0-9]{40}$/u;
8
- const RED = new Set(['failure', 'cancelled', 'timed_out', 'startup_failure', 'action_required']);
9
-
10
- /** Every process uses the supplied seam; an unavailable executable is an unreadable result. */
11
- function run(spawn, cwd, command, args) {
12
- try { return spawn(command, args, { cwd }); } catch { return { status: null }; }
13
- }
14
-
15
- function runsOf(result) {
16
- if (result.error || result.status !== 0) return null;
17
- try {
18
- const rows = JSON.parse(result.stdout);
19
- return Array.isArray(rows) ? rows : null;
20
- } catch { return null; }
21
- }
22
-
23
- /** Resolve each available default-branch reference once, including HEAD in detached release worktrees. */
24
- function referenceTips(spawn, cwd, defaultBranch) {
25
- const refs = [`origin/${defaultBranch}`, defaultBranch, 'HEAD'];
26
- return refs.flatMap((name) => {
27
- const result = run(spawn, cwd, 'git', ['rev-parse', '--verify', '--quiet', `${name}^{commit}`]);
28
- const tip = String(result.stdout ?? '').trim();
29
- return !result.error && result.status === 0 && SHA.test(tip) ? [{ name, tip }] : [];
30
- });
31
- }
32
-
33
- /** Equality proves reachability directly; ancestry is unknown only when every available probe errors. */
34
- function pushAncestry(spawn, cwd, row, refs) {
35
- if (refs.some((ref) => ref.tip === row.headSha)) return { candidate: true, unknown: false };
36
- let known = false;
37
- for (const ref of refs) {
38
- const result = run(spawn, cwd, 'git', ['merge-base', '--is-ancestor', row.headSha, ref.tip]);
39
- if (!result.error && result.status === 0) return { candidate: true, unknown: false };
40
- if (!result.error && result.status === 1) known = true;
41
- }
42
- return { candidate: false, unknown: !known };
43
- }
44
-
45
- /** Branch rows remain candidates; push rows must reach a resolved reference, and each databaseId counts once. */
46
- function candidateRuns(spawn, cwd, branch, pushes, refs) {
47
- const candidates = branch.filter((row) => row && SHA.test(row.headSha));
48
- let ancestryUnknown = refs.length === 0;
49
- for (const row of pushes) {
50
- if (!row || !SHA.test(row.headSha) || row.event !== 'push') continue;
51
- const ancestry = pushAncestry(spawn, cwd, row, refs);
52
- if (ancestry.candidate) candidates.push(row);
53
- if (ancestry.unknown) ancestryUnknown = true;
54
- }
55
- const unique = new Map();
56
- for (const row of candidates) if (!unique.has(row.databaseId)) unique.set(row.databaseId, row);
57
- return { candidates: [...unique.values()], ancestryUnknown };
58
- }
59
-
60
- /** Reads branch runs plus push/tag runs whose commits are reachable from the default branch; the newest completed run decides, even if a newer run is pending. */
61
- export function latestDefaultBranchCi({ spawn, cwd, defaultBranch = 'main' }) {
62
- const refs = referenceTips(spawn, cwd, defaultBranch);
63
- const unknown = (why) => ({ state: 'unknown', why: `${why} (refs: ${refs.map((ref) => ref.name).join(', ') || 'none'})` });
64
- const args = ['run', 'list', '--workflow', 'ci.yml'];
65
- const tail = ['--limit', '20', '--json', FIELDS];
66
- const branch = runsOf(run(spawn, cwd, 'gh', [...args, '--branch', defaultBranch, ...tail]));
67
- if (!branch) return unknown('gh branch runs unavailable (missing gh, authentication, or unreadable response)');
68
- const pushes = runsOf(run(spawn, cwd, 'gh', [...args, '--event', 'push', ...tail]));
69
- if (!pushes) return unknown('gh push runs unavailable (missing gh, authentication, or unreadable response)');
70
- const { candidates, ancestryUnknown } = candidateRuns(spawn, cwd, branch, pushes, refs);
71
- const completed = candidates.filter((row) => row.status === 'completed' && Number.isFinite(Date.parse(row.createdAt)));
72
- completed.sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt) || Number(b.databaseId) - Number(a.databaseId));
73
- const latest = completed[0];
74
- if (!latest) return unknown(ancestryUnknown ? 'cannot establish push run ancestry on the default branch' : 'no completed CI run on the default branch');
75
- if (latest.conclusion === 'success') return { state: 'green', run: latest };
76
- if (RED.has(latest.conclusion)) return { state: 'red', run: latest };
77
- return { ...unknown('latest completed CI run has no success or red conclusion'), run: latest };
78
- }
79
-
80
- /** Only a complete starci/ci-override@1 record for this exact commit can excuse a red run. */
81
- function overrideOf(ctx, sha) {
82
- const common = run(ctx.spawn, ctx.cwd, 'git', ['rev-parse', '--git-common-dir']);
83
- if (common.error || common.status !== 0 || !String(common.stdout ?? '').trim()) return null;
84
- const file = path.join(path.resolve(ctx.cwd, String(common.stdout).trim()), 'starci-release', `${sha}.ci-override.json`);
85
- try {
86
- const record = JSON.parse(fs.readFileSync(file, 'utf8'));
87
- 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;
88
- return record;
89
- } catch { return null; }
90
- }
91
-
92
- /** Warns on unknown CI and refuses red CI unless the owner left a valid override beside the release records. */
93
- export function requireGreenCi(ctx, ci) {
94
- if (ci.state === 'unknown') {
95
- ctx.stderr(`RELEASE_CI_UNKNOWN: ${ci.why}\n`);
96
- return [];
97
- }
98
- if (ci.state !== 'red') return [];
99
- const record = overrideOf(ctx, ci.run.headSha);
100
- if (record) return [`RELEASE_CI_RED overridden by the owner: ${record.reason}`];
101
- throw refusal('RELEASE_CI_RED', `CI run ${ci.run.url} for ${ci.run.headSha} concluded ${ci.run.conclusion}; the owner must use starci release ci-override --sha ${ci.run.headSha} --reason <text>`);
102
- }