@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/scaffold/app.mjs CHANGED
@@ -300,9 +300,10 @@ export function peerDependencies(pkg, pins, pairs = parseYaml(fs.readFileSync(PE
300
300
  /**
301
301
  * The fe-stack core packages one version of which the whole install resolves (npm `overrides`, at their canon pins): a
302
302
  * transitive dependency otherwise brings its own (zustand 4 under @xyflow/react, dayjs under typeorm, a hoisted newer zod
303
- * that breaks the zodResolver types), and @xyflow/react itself stays the grammar's one version.
303
+ * that breaks the zodResolver types), and @xyflow/react itself stays the grammar's one version. @graphql-tools/utils is the one security override: @nestjs/graphql 13.x
304
+ * pulls <=12.0.0 (GHSA-7mx3-vvmw-hjmv), and the pin's fixed 12.x closes the advisory until @nestjs/graphql 14 is adopted (then drop it; canon-pins.yaml says why).
304
305
  */
305
- const ROOT_OVERRIDES = Object.freeze(['@xyflow/react', 'dayjs', 'zod', 'zustand']);
306
+ const ROOT_OVERRIDES = Object.freeze(['@graphql-tools/utils', '@xyflow/react', 'dayjs', 'zod', 'zustand']);
306
307
 
307
308
  /**
308
309
  * The root package.json of a new app, the monorepo root: name, workspaces, npm, its edition's dependencies with their runtime
package/src/main.mjs CHANGED
@@ -29,6 +29,7 @@
29
29
  // starci app sync (--check | --write) [--cwd <dir>] the managed files of the root (scripts, husky, CI, .gitignore block, sonar, prettier) and
30
30
  // of each side (tsconfig, eslint, jest, stylelint); sync/cli.mjs
31
31
  // starci app hygiene the pre-commit guard: staged .starciwork and .starcistacks paths, and the secrets guard over every staged file (read from the index); sync/cli.mjs
32
+ // starci app coverage merge --out <lcov> <lcov-or-dir>... the lcov reports of the sharded unit run merged into one (coverage/cli.mjs; the managed CI coverage job)
32
33
  // starci app new service <dir> <name> [--inject <Decorator>=<module>:<Type> | <Class>=<module>]... [--cwd <dir>]
33
34
  // a back-end `<name>.service.ts` and its `<name>.service.spec.ts` skeleton (scaffold/service.mjs; <dir> is
34
35
  // relative to be/): the spec is built
@@ -89,7 +90,7 @@ import { dbPostCutoverMain } from '../db/post-cutover.mjs';
89
90
  import { dbDumpMain } from '../db/dump.mjs';
90
91
  import { dbResetMain } from '../db/reset.mjs';
91
92
 
92
- export const VERBS = Object.freeze(['scaffold', 'add', 'lint', 'sync', 'check', 'upgrade', 'stack', 'release', 'explain', 'emit', 'new', 'secret', 'ci', 'db', 'hygiene']);
93
+ export const VERBS = Object.freeze(['scaffold', 'add', 'lint', 'sync', 'check', 'upgrade', 'stack', 'release', 'explain', 'emit', 'new', 'secret', 'ci', 'db', 'hygiene', 'coverage']);
93
94
  const DATABASE_VERBS = new Map([['baseline', dbBaselineMain], ['restamp', dbRestampMain], ['plan', dbPlanMain], ['migrate', dbMigrateMain], ['post-cutover', dbPostCutoverMain], ['dump', dbDumpMain], ['reset', dbResetMain]]);
94
95
 
95
96
  const USAGE = `starci app scaffold <name> [--into <dir>] [--edition full|lite] [--registry-visibility public|private] [--cwd <dir>]
@@ -128,6 +129,7 @@ starci app ci var set <NAME> [--env E] [--value v] [--repo owner/name] [--cwd <d
128
129
  starci app ci var sync [--execute] [--repo owner/name] [--cwd <dir>]
129
130
  starci app ci verify [--json] [--repo owner/name] [--cwd <dir>]
130
131
  starci app hygiene [--cwd <dir>]
132
+ starci app coverage merge --out <lcov> <lcov-or-dir>... [--cwd <dir>]
131
133
  `;
132
134
  const PER_CODE_LIMIT = 25;
133
135
  const VALUE_FLAGS = new Set(['--base', '--inject', '--into', '--event', '--from', '--service', '--connection', '--owner', '--failed', '--done', '--coverage-from', '--registry-visibility']);
@@ -419,7 +421,7 @@ async function stackAppMain(argv, { cwd, edition, stackMain, stdout: out, stderr
419
421
  return run(argv, { cwd, out, err });
420
422
  }
421
423
 
422
- const DIRECT_VERBS = new Map([['ci', async (argv, io) => (await import('../ci/cli.mjs')).ciMain(argv, io)], ['lint', lintMain], ['secret', secretMain], ['db', databaseMain], ['upgrade', upgradeAppMain], ['stack', stackAppMain], ['release', releaseMain]]);
424
+ const DIRECT_VERBS = new Map([['ci', async (argv, io) => (await import('../ci/cli.mjs')).ciMain(argv, io)], ['lint', lintMain], ['secret', secretMain], ['db', databaseMain], ['upgrade', upgradeAppMain], ['stack', stackAppMain], ['release', releaseMain], ['coverage', async (argv, io) => (await import('../coverage/cli.mjs')).coverageMain(argv, io)]]);
423
425
  const PARSED_VERBS = new Map([['check', checkMain], ['emit', emitMain], ['new', newMain], ['add', addMain], ['scaffold', scaffoldMain], ['explain', explainMain]]);
424
426
 
425
427
  function dispatchMain(verb, argv, io) {
@@ -92,14 +92,14 @@ function codecovSteps(lcovReport, codecov) {
92
92
  }
93
93
 
94
94
  /** Sonar consumes the test coverage and fast lint artifacts only on release tags with a configured token. */
95
- function sonarJob(sonar, lcovReport, lcovArtifact, nodeMajor) {
95
+ function sonarJob(sonar, lcovReport, lcovArtifact, nodeMajor, timeout) {
96
96
  if (!sonar) return '';
97
97
  const condition = sonar.optional ? SONAR_CONDITION.replace(' }}', " && steps.sonar-availability.outputs.up == 'true' }}") : SONAR_CONDITION;
98
98
  const host = sonar.provider === 'sonarcloud' ? 'https://sonarcloud.io' : `\${{ vars.${sonar.hostVar} }}`;
99
99
  const header = [
100
100
  ' sonar:',
101
- ' needs: [fast, test]',
102
- ' timeout-minutes: 20',
101
+ ' needs: [lint, coverage]',
102
+ ` timeout-minutes: ${timeout}`,
103
103
  ' runs-on: ubuntu-latest',
104
104
  ' env:',
105
105
  ` SONAR_TOKEN: \${{ secrets.${sonar.token} }}`,
@@ -116,8 +116,10 @@ function sonarJob(sonar, lcovReport, lcovArtifact, nodeMajor) {
116
116
  ` - uses: ${DOWNLOAD_ARTIFACT}`,
117
117
  ` if: ${condition}`,
118
118
  ' with:',
119
- ' name: sonar-lint',
119
+ ' pattern: sonar-lint-*',
120
120
  ' path: reports',
121
+ ' merge-multiple: true',
122
+ ...sonarMergeSteps(condition),
121
123
  ' - uses: SonarSource/sonarqube-scan-action@c7ee0f9df90b7aa20e8dcf9695dcfe2e7da5b4f2 # v7.2.1',
122
124
  ` if: ${condition}`,
123
125
  ' - uses: SonarSource/sonarqube-quality-gate-action@7a5fffe8e523c40e0c740b6bc2712ab503e52efa # v1.2.1',
@@ -187,11 +189,11 @@ function frontendBuild(source, app) {
187
189
  return { env: singleEnv, steps };
188
190
  }
189
191
 
190
- /** Side lint reports join into the single Sonar artifact even when one side could not write its report. */
191
- function sonarMergeSteps() {
192
+ /** The per-side lint reports (one artifact per lint matrix side) join into the one reports/lint.sonar.json the Sonar import reads, even when one side wrote none. */
193
+ function sonarMergeSteps(condition) {
192
194
  return [
193
195
  ' - name: merge Sonar lint reports',
194
- " if: always() && env.SONAR_TOKEN != ''",
196
+ ` if: ${condition}`,
195
197
  ' run: |',
196
198
  " node --input-type=module <<'NODE'",
197
199
  " import fs from 'node:fs';",
@@ -205,6 +207,20 @@ function sonarMergeSteps() {
205
207
  ];
206
208
  }
207
209
 
210
+ const STANDARD = new URL('../runtime/knowledge/hfs/ci-standard.yaml', import.meta.url);
211
+ /** The per-job settings of the managed CI: ci.jobs.<job>.timeoutMinutes and ci.jobs.test.shards of the manifest over the defaults of the CI standard. */
212
+ function ciJobSettings(declaration) {
213
+ const defaults = parseYaml(fs.readFileSync(STANDARD, 'utf8')).defaults;
214
+ const jobs = declaration?.ci?.jobs ?? {};
215
+ const timeout = (job) => String(jobs[job]?.timeoutMinutes ?? defaults.timeoutMinutes[job]);
216
+ const shards = Number(jobs.test?.shards ?? defaults.shards);
217
+ return {
218
+ timeoutFast: timeout('fast'), timeoutLint: timeout('lint'), timeoutTest: timeout('test'), timeoutCoverage: timeout('coverage'),
219
+ timeoutBuild: timeout('build'), timeoutSonar: timeout('sonar'), timeoutDbTypes: timeout('db-types'),
220
+ testShards: `[${Array.from({ length: shards }, (_, index) => index + 1).join(', ')}]`, testShardTotal: String(shards),
221
+ };
222
+ }
223
+
208
224
  /** An omitted quality declaration preserves hosted Sonar, required OIDC Codecov, and the high production dependency audit. */
209
225
  export function ciWorkflowCommands(source, lcovReport = 'be/coverage/lcov.info', quality = undefined, { nodeMajor = '22', app } = {}) {
210
226
  const configuredSonar = quality === undefined ? { token: 'SONAR_TOKEN', hostVar: 'SONAR_HOST_URL' } : quality.sonar;
@@ -213,7 +229,11 @@ export function ciWorkflowCommands(source, lcovReport = 'be/coverage/lcov.info',
213
229
  const audit = quality?.audit ?? { level: 'high', omitDev: true };
214
230
  const lcovArtifact = 'coverage-lcov';
215
231
  const build = frontendBuild(source, app);
232
+ const settings = ciJobSettings(buildDeclaration(source));
233
+ const side = '${{ matrix.side }}';
216
234
  return {
235
+ ...settings,
236
+ lcovShardsDir: lcovReport.split('/').slice(0, -1).concat('shards').join('/'),
217
237
  ciBuildEnv: build.env,
218
238
  ciBuildSteps: build.steps,
219
239
  ciLint: exampleCommand(source, 'npm run lint'),
@@ -224,32 +244,28 @@ export function ciWorkflowCommands(source, lcovReport = 'be/coverage/lcov.info',
224
244
  sonarTokenSecret: sonar?.token ?? 'SONAR_TOKEN',
225
245
  sonarHostVar: sonar?.hostVar ?? 'SONAR_HOST_URL',
226
246
  sonarEnv: sonar ? ` env:\n SONAR_TOKEN: \${{ secrets.${sonar.token} }}` : '',
247
+ // One lint job per side (the lint matrix): with a Sonar token each side writes its own report, uploaded as sonar-lint-<side> and merged by the sonar job.
227
248
  ciLintSteps: [
228
- ...['be', 'fe'].flatMap((side) => [
229
- ' - name: lint ' + side,
230
- ...(sonar ? [" if: env.SONAR_TOKEN == ''"] : []),
231
- ` run: ${exampleCommand(source, 'npm run lint -- --side ' + side)}`,
249
+ ' - name: lint',
250
+ ...(sonar ? [" if: env.SONAR_TOKEN == ''"] : []),
251
+ ` run: ${exampleCommand(source, 'npm run lint -- --side ' + side)}`,
252
+ ' env:',
253
+ ' NODE_OPTIONS: --max-old-space-size=6144',
254
+ ...(sonar ? [
255
+ ' - name: lint with Sonar import',
256
+ " if: env.SONAR_TOKEN != ''",
257
+ ` run: ${exampleCommand(source, 'npm run lint -- --side ' + side + ' --sonar reports/lint.' + side + '.sonar.json')}`,
232
258
  ' env:',
233
259
  ' NODE_OPTIONS: --max-old-space-size=6144',
234
- ]),
235
- ...(sonar ? [
236
- ...['be', 'fe'].flatMap((side) => [
237
- ' - name: lint ' + side + ' with Sonar import',
238
- " if: env.SONAR_TOKEN != ''",
239
- ` run: ${exampleCommand(source, 'npm run lint -- --side ' + side + ' --sonar reports/lint.' + side + '.sonar.json')}`,
240
- ' env:',
241
- ' NODE_OPTIONS: --max-old-space-size=6144',
242
- ]),
243
- ...sonarMergeSteps(),
244
260
  ` - uses: ${UPLOAD_ARTIFACT}`,
245
- " if: always() && env.SONAR_TOKEN != '' && hashFiles('reports/lint.sonar.json') != ''",
261
+ ` if: always() && env.SONAR_TOKEN != '' && hashFiles('reports/lint.${side}.sonar.json') != ''`,
246
262
  ' with:',
247
- ' name: sonar-lint',
248
- ' path: reports/lint.sonar.json',
263
+ ` name: sonar-lint-${side}`,
264
+ ` path: reports/lint.${side}.sonar.json`,
249
265
  ] : []),
250
266
  ].join('\n'),
251
267
  codecovSteps: codecovSteps(lcovReport, codecov),
252
- sonarJob: sonarJob(sonar, lcovReport, lcovArtifact, nodeMajor),
268
+ sonarJob: sonarJob(sonar, lcovReport, lcovArtifact, nodeMajor, settings.timeoutSonar),
253
269
  lcovArtifact,
254
270
  };
255
271
  }
@@ -0,0 +1,8 @@
1
+ - name: build cache save
2
+ if: always()
3
+ uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
4
+ with:
5
+ path: |
6
+ .turbo
7
+ fe/apps/*/.next/cache
8
+ key: ${{ runner.os }}-node{{nodeMajor}}-build-${{ hashFiles('package-lock.json') }}-${{ github.run_id }}
@@ -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
+ # and sonar. 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,12 +93,13 @@ 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
 
65
105
  {{sonarJob}}
@@ -10,12 +10,13 @@
10
10
  with:
11
11
  path: node_modules
12
12
  key: ${{ runner.os }}-node{{nodeMajor}}-modules-${{ hashFiles('package-lock.json') }}
13
- - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
13
+ # The build cache is restored in every job and saved by the build job always (build-cache-save.yml), keyed by the lockfile and the run.
14
+ - uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
14
15
  with:
15
16
  path: |
16
17
  .turbo
17
18
  fe/apps/*/.next/cache
18
- key: ${{ runner.os }}-node{{nodeMajor}}-build-${{ hashFiles('package-lock.json') }}-${{ github.sha }}
19
+ key: ${{ runner.os }}-node{{nodeMajor}}-build-${{ hashFiles('package-lock.json') }}-${{ github.run_id }}
19
20
  restore-keys: |
20
21
  ${{ runner.os }}-node{{nodeMajor}}-build-${{ hashFiles('package-lock.json') }}-
21
22
  ${{ runner.os }}-node{{nodeMajor}}-build-
@@ -0,0 +1,6 @@
1
+ - name: Jest cache save
2
+ if: always()
3
+ uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
4
+ with:
5
+ path: .jest-cache
6
+ key: ${{ runner.os }}-node{{nodeMajor}}-jest-${{ hashFiles('package-lock.json') }}-shard${{ matrix.shard }}-${{ github.run_id }}
@@ -1,8 +1,8 @@
1
- - name: Jest cache
2
- uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
1
+ - name: Jest cache restore
2
+ uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
3
3
  with:
4
4
  path: .jest-cache
5
- key: ${{ runner.os }}-node{{nodeMajor}}-jest-${{ hashFiles('package-lock.json') }}-${{ github.sha }}
5
+ key: ${{ runner.os }}-node{{nodeMajor}}-jest-${{ hashFiles('package-lock.json') }}-shard${{ matrix.shard }}-${{ github.run_id }}
6
6
  restore-keys: |
7
+ ${{ runner.os }}-node{{nodeMajor}}-jest-${{ hashFiles('package-lock.json') }}-shard${{ matrix.shard }}-
7
8
  ${{ runner.os }}-node{{nodeMajor}}-jest-${{ hashFiles('package-lock.json') }}-
8
- ${{ runner.os }}-node{{nodeMajor}}-jest-
@@ -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
- }