@starci/hfs 4.7.11 → 4.7.13

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 (54) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/package.json +1 -1
  3. package/release/gate.mjs +388 -378
  4. package/release/lint-report.mjs +52 -0
  5. package/runtime/engine/digest.mjs +16 -0
  6. package/runtime/knowledge/hfs/canon-pins.yaml +4 -4
  7. package/runtime/knowledge/hfs/ci-standard.yaml +1 -1
  8. package/runtime/packages/cli/src/catalog.generated.mjs +7 -4
  9. package/runtime/scripts/api/fs/is-link-like.mjs +1 -0
  10. package/runtime/scripts/api/sops/lib.mjs +5 -1
  11. package/runtime/scripts/hfs/architecture/canon-clones.mjs +1 -1
  12. package/runtime/scripts/hfs/architecture/i18n-keys.mjs +6 -4
  13. package/runtime/scripts/hfs/architecture/tiers.mjs +1 -2
  14. package/runtime/scripts/hfs/architecture/transport-owner.mjs +1 -1
  15. package/runtime/scripts/hfs/ci-declaration.mjs +5 -2
  16. package/runtime/scripts/hfs/db-baseline-verb.mjs +5 -2
  17. package/runtime/scripts/hfs/db-plan-verb.mjs +4 -5
  18. package/runtime/scripts/hfs/doc-commands.mjs +2 -1
  19. package/runtime/scripts/hfs/rule-catalog.mjs +8 -4
  20. package/runtime/scripts/hfs/rules/coverage-below.mjs +5 -3
  21. package/runtime/scripts/hfs/rules/database-access.mjs +1 -1
  22. package/runtime/scripts/hfs/rules/database-migrations.mjs +13 -3
  23. package/runtime/scripts/hfs/rules/database-plpgsql.mjs +1 -1
  24. package/runtime/scripts/hfs/rules/database-queues.mjs +2 -2
  25. package/runtime/scripts/hfs/rules/deploy-scan.mjs +50 -6
  26. package/runtime/scripts/hfs/rules/deploy-workflow.mjs +11 -1
  27. package/runtime/scripts/hfs/rules/doc-fresh.mjs +19 -4
  28. package/runtime/scripts/hfs/rules/docker-health.mjs +4 -1
  29. package/runtime/scripts/hfs/rules/env-parity.mjs +8 -7
  30. package/runtime/scripts/hfs/rules/env-value-shape.mjs +2 -4
  31. package/runtime/scripts/hfs/rules/fe-build-args.mjs +1 -1
  32. package/runtime/scripts/hfs/rules/frontend-tree.mjs +1 -1
  33. package/runtime/scripts/hfs/rules/release-provenance.mjs +9 -6
  34. package/runtime/scripts/hfs/rules/script-hygiene.mjs +23 -7
  35. package/runtime/scripts/hfs/rules/stack-tooling.mjs +5 -2
  36. package/runtime/scripts/hfs/rules/stacks-release.mjs +1 -1
  37. package/runtime/scripts/hfs/rules/stacks-swarm.mjs +6 -6
  38. package/runtime/scripts/hfs/rules/supply-chain.mjs +4 -3
  39. package/runtime/scripts/hfs/rules/unused-symbol-properties.mjs +1 -1
  40. package/runtime/scripts/hfs/rules/unused-symbol.mjs +8 -4
  41. package/runtime/scripts/hfs/rules/workflow-contexts.mjs +1 -1
  42. package/runtime/scripts/hfs/sealed-members.mjs +1 -1
  43. package/runtime/scripts/hfs/secret-bootstrap-crypto.mjs +2 -2
  44. package/runtime/scripts/hfs/secret-files.mjs +1 -1
  45. package/runtime/scripts/hfs/secret-sops.mjs +1 -1
  46. package/runtime/scripts/hfs/stack-deploy-config.mjs +5 -2
  47. package/runtime/scripts/lib/env.mjs +2 -1
  48. package/runtime/scripts/lib/path-key.mjs +3 -1
  49. package/runtime/scripts/lib/roots.mjs +3 -3
  50. package/runtime/scripts/lib/secret-patterns.mjs +2 -2
  51. package/runtime/scripts/lib/swarm-stack.mjs +4 -2
  52. package/runtime/scripts/lib/ts-analysis.mjs +8 -4
  53. package/sync/ci-commands.mjs +275 -275
  54. package/templates/app/ci-workflows-lite/github/workflows/ci.yml +2 -0
@@ -1,9 +1,13 @@
1
1
  /** The expression under the wrappers selected by an AST check's contract. */
2
2
  export function unwrapExpression(ts, expression, { awaitExpressions = false, typeAssertions = true, nonNullExpressions = true, satisfiesExpressions = true } = {}) {
3
- while (expression && (ts.isParenthesizedExpression(expression) || ts.isAsExpression(expression)
4
- || (typeAssertions && ts.isTypeAssertionExpression(expression)) || (nonNullExpressions && ts.isNonNullExpression(expression))
5
- || (satisfiesExpressions && (ts.isSatisfiesExpression?.(expression) ?? false))
6
- || (awaitExpressions && ts.isAwaitExpression(expression)))) expression = expression.expression;
3
+ while (expression) {
4
+ const wrapper = ts.isParenthesizedExpression(expression) || ts.isAsExpression(expression)
5
+ || (typeAssertions && ts.isTypeAssertionExpression(expression)) || (nonNullExpressions && ts.isNonNullExpression(expression))
6
+ || (satisfiesExpressions && (ts.isSatisfiesExpression?.(expression) ?? false))
7
+ || (awaitExpressions && ts.isAwaitExpression(expression));
8
+ if (!wrapper) break;
9
+ expression = expression.expression;
10
+ }
7
11
  return expression;
8
12
  }
9
13
 
@@ -1,275 +1,275 @@
1
- import fs from 'node:fs';
2
- import { parseYaml } from '../runtime/engine/yaml.mjs';
3
- import { refusal } from '../stack/errors.mjs';
4
- import { byCodeUnit } from '../report/order.mjs';
5
-
6
- // One command vocabulary for managed CI rendering and the runtime coverage gate.
7
- const exampleCommand = (source, command) => source?.runtimeExample
8
- ? 'node node_modules/@starci/hfs/runtime/scripts/hfs/example-secrets.mjs --run . -- ' + command
9
- : command;
10
-
11
- const UPLOAD_ARTIFACT = 'actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2';
12
- const DOWNLOAD_ARTIFACT = 'actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0';
13
- const SONAR_CONDITION = "${{ !cancelled() && startsWith(github.ref, 'refs/tags/v') && env.SONAR_TOKEN != '' }}";
14
-
15
- /** Optional uploads require reachable upload and authentication endpoints without exposing identity tokens. */
16
- function codecovProbe(codecov, condition) {
17
- if (!codecov.optional) return [];
18
- return [
19
- ' - name: Codecov availability',
20
- ' id: codecov-availability',
21
- ` if: ${condition}`,
22
- ' env:',
23
- ` CODECOV_AUTH: ${codecov.auth}`,
24
- ' run: |',
25
- " node --input-type=module <<'NODE'",
26
- " import fs from 'node:fs';",
27
- ' let available = false;',
28
- ' try {',
29
- " const upload = await fetch('https://ingest.codecov.io/upload/v4', { signal: AbortSignal.timeout(10000) });",
30
- ' available = upload.status < 500;',
31
- " if (available && process.env.CODECOV_AUTH === 'oidc') {",
32
- " const url = new URL(process.env.ACTIONS_ID_TOKEN_REQUEST_URL);",
33
- " url.searchParams.set('audience', 'https://codecov.io');",
34
- " const identity = await fetch(url, { headers: { Authorization: 'Bearer ' + process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN }, signal: AbortSignal.timeout(10000) });",
35
- ' available = identity.ok && Boolean((await identity.json()).value);',
36
- ' }',
37
- ' } catch { available = false; }',
38
- " fs.appendFileSync(process.env.GITHUB_OUTPUT, 'up=' + available + '\\n');",
39
- " if (!available) console.log('::notice::codecov upload skipped: upload endpoint or OIDC token unavailable (services.codecov.optional)');",
40
- ' NODE',
41
- ];
42
- }
43
-
44
- /** Optional Sonar only runs analysis when the provider reports UP within ten seconds. */
45
- function sonarProbe(sonar) {
46
- if (!sonar.optional) return [];
47
- return [
48
- ' - name: Sonar availability',
49
- ' id: sonar-availability',
50
- ` if: ${SONAR_CONDITION}`,
51
- ' run: |',
52
- ` if curl -fsS --max-time 10 "$SONAR_HOST_URL/api/system/status" | node -e "let s='';process.stdin.on('data',c=>s+=c);process.stdin.on('end',()=>{try{process.exit(JSON.parse(s).status==='UP'?0:1)}catch{process.exit(1)}})"; then`,
53
- ' echo "up=true" >> "$GITHUB_OUTPUT"',
54
- ' else',
55
- ' echo "up=false" >> "$GITHUB_OUTPUT"',
56
- ' echo "::notice::sonar skipped: $SONAR_HOST_URL not reachable (services.sonar.optional)"',
57
- ' fi',
58
- ];
59
- }
60
-
61
- /** Codecov reports missing coverage without failing twice; uploads retain the release-tag condition and required authentication. */
62
- function codecovSteps(lcovReport, codecov) {
63
- if (codecov.enabled === false || codecov.wiring === 'not-used') return [
64
- ' - name: coverage upload',
65
- ' run: echo "codecov disabled in .starcistacks/application-stacks.yaml"',
66
- ].join('\n');
67
- const condition = `\${{ !cancelled() && startsWith(github.ref, 'refs/tags/v') && hashFiles('${lcovReport}') != '' }}`;
68
- const uploadCondition = codecov.optional ? condition.replace(' }}', " && steps.codecov-availability.outputs.up == 'true' }}") : condition;
69
- return [
70
- ' - name: coverage upload skipped',
71
- ` if: \${{ !cancelled() && hashFiles('${lcovReport}') == '' }}`,
72
- ' run: |',
73
- ` echo "::notice::codecov upload skipped: no coverage file at ${lcovReport} (the test step failed or produced none)"`,
74
- ' exit 0',
75
- ...codecovProbe(codecov, condition),
76
- ' - name: coverage upload',
77
- ...(codecov.optional ? [' id: codecov-upload', ' continue-on-error: true'] : []),
78
- ' uses: codecov/codecov-action@0fb7174895f61a3b6b78fc075e0cd60383518dac # v5.5.5',
79
- ` if: ${uploadCondition}`,
80
- ' with:',
81
- codecov.auth === 'token' ? ` token: \${{ secrets.${codecov.token} }}` : ' use_oidc: true',
82
- ` files: ${lcovReport}`,
83
- ' disable_search: true',
84
- ` fail_ci_if_error: ${!codecov.optional}`,
85
- ...(codecov.optional ? [
86
- ' - name: Codecov upload unavailable',
87
- " if: ${{ !cancelled() && steps.codecov-upload.outcome == 'failure' }}",
88
- ' run: |',
89
- ' echo "::notice::codecov upload skipped: upload unavailable (services.codecov.optional)"',
90
- ] : []),
91
- ].join('\n');
92
- }
93
-
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, timeout) {
96
- if (!sonar) return '';
97
- const condition = sonar.optional ? SONAR_CONDITION.replace(' }}', " && steps.sonar-availability.outputs.up == 'true' }}") : SONAR_CONDITION;
98
- const host = sonar.provider === 'sonarcloud' ? 'https://sonarcloud.io' : `\${{ vars.${sonar.hostVar} }}`;
99
- const header = [
100
- ' sonar:',
101
- ' needs: [lint, coverage]',
102
- ` timeout-minutes: ${timeout}`,
103
- ' runs-on: ubuntu-latest',
104
- ' env:',
105
- ` SONAR_TOKEN: \${{ secrets.${sonar.token} }}`,
106
- ` SONAR_HOST_URL: ${host}`,
107
- ' steps:',
108
- ].join('\n');
109
- const steps = [
110
- ...sonarProbe(sonar),
111
- ` - uses: ${DOWNLOAD_ARTIFACT}`,
112
- ` if: ${condition}`,
113
- ' with:',
114
- ` name: ${lcovArtifact}`,
115
- ` path: ${lcovReport.split('/').slice(0, -1).join('/') || '.'}`,
116
- ` - uses: ${DOWNLOAD_ARTIFACT}`,
117
- ` if: ${condition}`,
118
- ' with:',
119
- ' pattern: sonar-lint-*',
120
- ' path: reports',
121
- ' merge-multiple: true',
122
- ...sonarMergeSteps(condition),
123
- ' - uses: SonarSource/sonarqube-scan-action@c7ee0f9df90b7aa20e8dcf9695dcfe2e7da5b4f2 # v7.2.1',
124
- ` if: ${condition}`,
125
- ' - uses: SonarSource/sonarqube-quality-gate-action@7a5fffe8e523c40e0c740b6bc2712ab503e52efa # v1.2.1',
126
- ` if: ${condition}`,
127
- ' timeout-minutes: 10',
128
- ].join('\n');
129
- // Variable strings are inserted after render() expands partials, so resolve Sonar's shared install partial before insertion.
130
- const read = name => fs.readFileSync(new URL('../templates/app/ci-workflows/' + name, import.meta.url), 'utf8').replaceAll('\r\n', '\n');
131
- return read('sonar-steps.yml')
132
- .replace('{{> app/ci-workflows/install-steps.yml}}', () => read('install-steps.yml').trimEnd())
133
- .replaceAll('{{nodeMajor}}', nodeMajor)
134
- .replace('{{sonarJobHeader}}', () => header)
135
- .replace('{{sonarJobSteps}}', () => steps)
136
- .trimEnd() + '\n';
137
- }
138
-
139
- /** The app source reader is be-relative; the authored manifest lives one directory above that side. */
140
- function buildDeclaration(source) {
141
- if (!source?.read) return {};
142
- try {
143
- return parseYaml(source.read('../.starcistacks/application-stacks.yaml')) ?? {};
144
- } catch (error) {
145
- if (error.code === 'ENOENT') return {};
146
- throw error;
147
- }
148
- }
149
-
150
- /** Static CI values are strings with valid environment names; expressions cannot turn public config into runtime secret reads. */
151
- function mergeBuildValues(values, entries) {
152
- for (const [key, value] of Object.entries(entries ?? {})) {
153
- if (!/^[A-Z][A-Z0-9_]*$/u.test(key) || typeof value !== 'string' || value.includes('${{')) {
154
- throw refusal('STACK_BUILD_ARG_INVALID', 'CI build values must map environment names to literal strings');
155
- }
156
- if (values.has(key) && values.get(key) !== value) {
157
- throw refusal('STACK_BUILD_ARG_INVALID', 'CI build values disagree for ' + key);
158
- }
159
- values.set(key, value);
160
- }
161
- }
162
-
163
- /** Image values own their keys; the build-fe gate supplies only missing keys, and sealed members stay unopened. */
164
- function imageBuildEnv(image, gates) {
165
- const values = new Map();
166
- mergeBuildValues(values, image?.buildArgs?.values);
167
- const fallback = new Map();
168
- for (const gate of gates.filter(entry => entry.name === 'build-fe')) mergeBuildValues(fallback, gate.env);
169
- for (const [key, value] of fallback) if (!values.has(key)) values.set(key, value);
170
- if (!values.size) return '';
171
- return [' env:', ...[...values.keys()].sort(byCodeUnit).map(key => ' ' + key + ': ' + JSON.stringify(values.get(key)))].join('\n');
172
- }
173
-
174
- /** Multiple deployed front ends build separately through the installed turbo (a run step has no node_modules/.bin on PATH), so each fe/apps/<app> and its dependencies see its own public values. */
175
- function frontendBuild(source, app) {
176
- const declaration = buildDeclaration(source);
177
- const environments = Object.keys(source?.stack?.imageEnvironments ?? {});
178
- const environment = environments.length > 1 ? 'production' : environments[0];
179
- const images = Object.values(declaration.environments?.[environment]?.deploy?.images ?? {});
180
- const frontend = images.filter(image => app?.sides.fe.apps.some(entry => entry.name === image.app));
181
- const gates = declaration.release?.gates ?? [];
182
- const singleEnv = imageBuildEnv(frontend[0], gates);
183
- if (frontend.length <= 1) return { env: singleEnv, steps: [' - name: build fe', ' run: npm run build:fe', singleEnv].filter(Boolean).join('\n') };
184
- const steps = frontend.map(image => [
185
- ' - name: build fe ' + image.app,
186
- ' run: npm run codegen --silent && npx --no-install turbo run build --filter=./fe/apps/' + image.app,
187
- imageBuildEnv(image, gates),
188
- ].filter(Boolean).join('\n')).join('\n');
189
- return { env: singleEnv, steps };
190
- }
191
-
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) {
194
- return [
195
- ' - name: merge Sonar lint reports',
196
- ` if: ${condition}`,
197
- ' run: |',
198
- " node --input-type=module <<'NODE'",
199
- " import fs from 'node:fs';",
200
- " const files = ['reports/lint.be.sonar.json', 'reports/lint.fe.sonar.json'];",
201
- " const reports = files.filter(file => fs.existsSync(file)).map(file => JSON.parse(fs.readFileSync(file, 'utf8')));",
202
- ' const rules = [...new Map(reports.flatMap(report => report.rules).map(rule => [rule.id, rule])).values()];',
203
- ' const issues = reports.flatMap(report => report.issues);',
204
- " fs.mkdirSync('reports', { recursive: true });",
205
- " fs.writeFileSync('reports/lint.sonar.json', JSON.stringify({ rules, issues }, null, 2) + '\\n');",
206
- ' NODE',
207
- ];
208
- }
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, heap } = parseYaml(fs.readFileSync(STANDARD, 'utf8'));
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
- nodeOptions: `--max-old-space-size=${heap.minMiB}`,
219
- timeoutFast: timeout('fast'), timeoutLint: timeout('lint'), timeoutTest: timeout('test'), timeoutCoverage: timeout('coverage'),
220
- timeoutBuild: timeout('build'), timeoutSonar: timeout('sonar'), timeoutDbTypes: timeout('db-types'),
221
- testShards: `[${Array.from({ length: shards }, (_, index) => index + 1).join(', ')}]`, testShardTotal: String(shards),
222
- };
223
- }
224
-
225
- /** An omitted quality declaration preserves hosted Sonar, required OIDC Codecov, and the high production dependency audit. */
226
- export function ciWorkflowCommands(source, lcovReport = 'be/coverage/lcov.info', quality = undefined, { nodeMajor = '22', app } = {}) {
227
- const configuredSonar = quality === undefined ? { token: 'SONAR_TOKEN', hostVar: 'SONAR_HOST_URL' } : quality.sonar;
228
- const sonar = configuredSonar?.wiring === 'not-used' || configuredSonar?.mode === 'disabled' ? null : configuredSonar;
229
- const codecov = quality?.codecov ?? { enabled: true, auth: 'oidc' };
230
- const audit = quality?.audit ?? { level: 'high', omitDev: true };
231
- const lcovArtifact = 'coverage-lcov';
232
- const build = frontendBuild(source, app);
233
- const settings = ciJobSettings(buildDeclaration(source));
234
- const side = '${{ matrix.side }}';
235
- return {
236
- ...settings,
237
- lcovShardsDir: lcovReport.split('/').slice(0, -1).concat('shards').join('/'),
238
- // The istanbul JSON a shard writes beside the lcov (STARCI_COVERAGE_SHARD=1): what the coverage job merges.
239
- shardCoverageReport: lcovReport.split('/').slice(0, -1).concat('coverage-final.json').join('/'),
240
- ciBuildEnv: build.env,
241
- ciBuildSteps: build.steps,
242
- ciLint: exampleCommand(source, 'npm run lint'),
243
- ciLintSonar: exampleCommand(source, 'npm run lint -- --sonar reports/lint.sonar.json'),
244
- ciCoverageProof: 'npm run codegen --silent && ' + exampleCommand(source, 'npx starci app check --coverage-from ' + lcovReport),
245
- ciDatabaseLint: exampleCommand(source, 'npm run db:lint'),
246
- auditCommand: `npm audit${audit.omitDev ? ' --omit=dev' : ''} --audit-level=${audit.level}`,
247
- sonarTokenSecret: sonar?.token ?? 'SONAR_TOKEN',
248
- sonarHostVar: sonar?.hostVar ?? 'SONAR_HOST_URL',
249
- sonarEnv: sonar ? ` env:\n SONAR_TOKEN: \${{ secrets.${sonar.token} }}` : '',
250
- // 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.
251
- ciLintSteps: [
252
- ' - name: lint',
253
- ...(sonar ? [" if: env.SONAR_TOKEN == ''"] : []),
254
- ` run: ${exampleCommand(source, 'npm run lint -- --side ' + side)}`,
255
- ' env:',
256
- ` NODE_OPTIONS: ${settings.nodeOptions}`,
257
- ...(sonar ? [
258
- ' - name: lint with Sonar import',
259
- " if: env.SONAR_TOKEN != ''",
260
- ` run: ${exampleCommand(source, 'npm run lint -- --side ' + side + ' --sonar reports/lint.' + side + '.sonar.json')}`,
261
- ' env:',
262
- ` NODE_OPTIONS: ${settings.nodeOptions}`,
263
- ` - uses: ${UPLOAD_ARTIFACT}`,
264
- // Inside an if: expression the matrix value is an argument of format(), never a nested ${{ }} (actionlint: a nested expression makes the condition always true).
265
- " if: always() && env.SONAR_TOKEN != '' && hashFiles(format('reports/lint.{0}.sonar.json', matrix.side)) != ''",
266
- ' with:',
267
- ` name: sonar-lint-${side}`,
268
- ` path: reports/lint.${side}.sonar.json`,
269
- ] : []),
270
- ].join('\n'),
271
- codecovSteps: codecovSteps(lcovReport, codecov),
272
- sonarJob: sonarJob(sonar, lcovReport, lcovArtifact, nodeMajor, settings.timeoutSonar),
273
- lcovArtifact,
274
- };
275
- }
1
+ import fs from 'node:fs';
2
+ import { parseYaml } from '../runtime/engine/yaml.mjs';
3
+ import { refusal } from '../stack/errors.mjs';
4
+ import { byCodeUnit } from '../report/order.mjs';
5
+
6
+ // One command vocabulary for managed CI rendering and the runtime coverage gate.
7
+ const exampleCommand = (source, command) => source?.runtimeExample
8
+ ? 'node node_modules/@starci/hfs/runtime/scripts/hfs/example-secrets.mjs --run . -- ' + command
9
+ : command;
10
+
11
+ const UPLOAD_ARTIFACT = 'actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2';
12
+ const DOWNLOAD_ARTIFACT = 'actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0';
13
+ const SONAR_CONDITION = "${{ !cancelled() && startsWith(github.ref, 'refs/tags/v') && env.SONAR_TOKEN != '' }}";
14
+
15
+ /** Optional uploads require reachable upload and authentication endpoints without exposing identity tokens. */
16
+ function codecovProbe(codecov, condition) {
17
+ if (!codecov.optional) return [];
18
+ return [
19
+ ' - name: Codecov availability',
20
+ ' id: codecov-availability',
21
+ ` if: ${condition}`,
22
+ ' env:',
23
+ ` CODECOV_AUTH: ${codecov.auth}`,
24
+ ' run: |',
25
+ " node --input-type=module <<'NODE'",
26
+ " import fs from 'node:fs';",
27
+ ' let available = false;',
28
+ ' try {',
29
+ " const upload = await fetch('https://ingest.codecov.io/upload/v4', { signal: AbortSignal.timeout(10000) });",
30
+ ' available = upload.status < 500;',
31
+ " if (available && process.env.CODECOV_AUTH === 'oidc') {",
32
+ " const url = new URL(process.env.ACTIONS_ID_TOKEN_REQUEST_URL);",
33
+ " url.searchParams.set('audience', 'https://codecov.io');",
34
+ " const identity = await fetch(url, { headers: { Authorization: 'Bearer ' + process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN }, signal: AbortSignal.timeout(10000) });",
35
+ ' available = identity.ok && Boolean((await identity.json()).value);',
36
+ ' }',
37
+ ' } catch { available = false; }',
38
+ " fs.appendFileSync(process.env.GITHUB_OUTPUT, 'up=' + available + '\\n');",
39
+ " if (!available) console.log('::notice::codecov upload skipped: upload endpoint or OIDC token unavailable (services.codecov.optional)');",
40
+ ' NODE',
41
+ ];
42
+ }
43
+
44
+ /** Optional Sonar only runs analysis when the provider reports UP within ten seconds. */
45
+ function sonarProbe(sonar) {
46
+ if (!sonar.optional) return [];
47
+ return [
48
+ ' - name: Sonar availability',
49
+ ' id: sonar-availability',
50
+ ` if: ${SONAR_CONDITION}`,
51
+ ' run: |',
52
+ ` if curl -fsS --max-time 10 "$SONAR_HOST_URL/api/system/status" | node -e "let s='';process.stdin.on('data',c=>s+=c);process.stdin.on('end',()=>{try{process.exit(JSON.parse(s).status==='UP'?0:1)}catch{process.exit(1)}})"; then`,
53
+ ' echo "up=true" >> "$GITHUB_OUTPUT"',
54
+ ' else',
55
+ ' echo "up=false" >> "$GITHUB_OUTPUT"',
56
+ ' echo "::notice::sonar skipped: $SONAR_HOST_URL not reachable (services.sonar.optional)"',
57
+ ' fi',
58
+ ];
59
+ }
60
+
61
+ /** Codecov reports missing coverage without failing twice; uploads retain the release-tag condition and required authentication. */
62
+ function codecovSteps(lcovReport, codecov) {
63
+ if (codecov.enabled === false || codecov.wiring === 'not-used') return [
64
+ ' - name: coverage upload',
65
+ ' run: echo "codecov disabled in .starcistacks/application-stacks.yaml"',
66
+ ].join('\n');
67
+ const condition = `\${{ !cancelled() && startsWith(github.ref, 'refs/tags/v') && hashFiles('${lcovReport}') != '' }}`;
68
+ const uploadCondition = codecov.optional ? condition.replace(' }}', " && steps.codecov-availability.outputs.up == 'true' }}") : condition;
69
+ return [
70
+ ' - name: coverage upload skipped',
71
+ ` if: \${{ !cancelled() && hashFiles('${lcovReport}') == '' }}`,
72
+ ' run: |',
73
+ ` echo "::notice::codecov upload skipped: no coverage file at ${lcovReport} (the test step failed or produced none)"`,
74
+ ' exit 0',
75
+ ...codecovProbe(codecov, condition),
76
+ ' - name: coverage upload',
77
+ ...(codecov.optional ? [' id: codecov-upload', ' continue-on-error: true'] : []),
78
+ ' uses: codecov/codecov-action@0fb7174895f61a3b6b78fc075e0cd60383518dac # v5.5.5',
79
+ ` if: ${uploadCondition}`,
80
+ ' with:',
81
+ codecov.auth === 'token' ? ` token: \${{ secrets.${codecov.token} }}` : ' use_oidc: true',
82
+ ` files: ${lcovReport}`,
83
+ ' disable_search: true',
84
+ ` fail_ci_if_error: ${!codecov.optional}`,
85
+ ...(codecov.optional ? [
86
+ ' - name: Codecov upload unavailable',
87
+ " if: ${{ !cancelled() && steps.codecov-upload.outcome == 'failure' }}",
88
+ ' run: |',
89
+ ' echo "::notice::codecov upload skipped: upload unavailable (services.codecov.optional)"',
90
+ ] : []),
91
+ ].join('\n');
92
+ }
93
+
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, timeout) {
96
+ if (!sonar) return '';
97
+ const condition = sonar.optional ? SONAR_CONDITION.replace(' }}', " && steps.sonar-availability.outputs.up == 'true' }}") : SONAR_CONDITION;
98
+ const host = sonar.provider === 'sonarcloud' ? 'https://sonarcloud.io' : `\${{ vars.${sonar.hostVar} }}`;
99
+ const header = [
100
+ ' sonar:',
101
+ ' needs: [lint, coverage]',
102
+ ` timeout-minutes: ${timeout}`,
103
+ ' runs-on: ubuntu-latest',
104
+ ' env:',
105
+ ` SONAR_TOKEN: \${{ secrets.${sonar.token} }}`,
106
+ ` SONAR_HOST_URL: ${host}`,
107
+ ' steps:',
108
+ ].join('\n');
109
+ const steps = [
110
+ ...sonarProbe(sonar),
111
+ ` - uses: ${DOWNLOAD_ARTIFACT}`,
112
+ ` if: ${condition}`,
113
+ ' with:',
114
+ ` name: ${lcovArtifact}`,
115
+ ` path: ${lcovReport.split('/').slice(0, -1).join('/') || '.'}`,
116
+ ` - uses: ${DOWNLOAD_ARTIFACT}`,
117
+ ` if: ${condition}`,
118
+ ' with:',
119
+ ' pattern: sonar-lint-*',
120
+ ' path: reports',
121
+ ' merge-multiple: true',
122
+ ...sonarMergeSteps(condition),
123
+ ' - uses: SonarSource/sonarqube-scan-action@c7ee0f9df90b7aa20e8dcf9695dcfe2e7da5b4f2 # v7.2.1',
124
+ ` if: ${condition}`,
125
+ ' - uses: SonarSource/sonarqube-quality-gate-action@7a5fffe8e523c40e0c740b6bc2712ab503e52efa # v1.2.1',
126
+ ` if: ${condition}`,
127
+ ' timeout-minutes: 10',
128
+ ].join('\n');
129
+ // Variable strings are inserted after render() expands partials, so resolve Sonar's shared install partial before insertion.
130
+ const read = name => fs.readFileSync(new URL('../templates/app/ci-workflows/' + name, import.meta.url), 'utf8').replaceAll('\r\n', '\n');
131
+ return read('sonar-steps.yml')
132
+ .replace('{{> app/ci-workflows/install-steps.yml}}', () => read('install-steps.yml').trimEnd())
133
+ .replaceAll('{{nodeMajor}}', nodeMajor)
134
+ .replace('{{sonarJobHeader}}', () => header)
135
+ .replace('{{sonarJobSteps}}', () => steps)
136
+ .trimEnd() + '\n';
137
+ }
138
+
139
+ /** The app source reader is be-relative; the authored manifest lives one directory above that side. */
140
+ function buildDeclaration(source) {
141
+ if (!source?.read) return {};
142
+ try {
143
+ return parseYaml(source.read('../.starcistacks/application-stacks.yaml')) ?? {};
144
+ } catch (error) {
145
+ if (error.code === 'ENOENT') return {};
146
+ throw error;
147
+ }
148
+ }
149
+
150
+ /** Static CI values are strings with valid environment names; expressions cannot turn public config into runtime secret reads. */
151
+ function mergeBuildValues(values, entries) {
152
+ for (const [key, value] of Object.entries(entries ?? {})) {
153
+ if (!/^[A-Z][A-Z0-9_]*$/u.test(key) || typeof value !== 'string' || value.includes('${{')) {
154
+ throw refusal('STACK_BUILD_ARG_INVALID', 'CI build values must map environment names to literal strings');
155
+ }
156
+ if (values.has(key) && values.get(key) !== value) {
157
+ throw refusal('STACK_BUILD_ARG_INVALID', 'CI build values disagree for ' + key);
158
+ }
159
+ values.set(key, value);
160
+ }
161
+ }
162
+
163
+ /** Image values own their keys; the build-fe gate supplies only missing keys, and sealed members stay unopened. */
164
+ function imageBuildEnv(image, gates) {
165
+ const values = new Map();
166
+ mergeBuildValues(values, image?.buildArgs?.values);
167
+ const fallback = new Map();
168
+ for (const gate of gates.filter(entry => entry.name === 'build-fe')) mergeBuildValues(fallback, gate.env);
169
+ for (const [key, value] of fallback) if (!values.has(key)) values.set(key, value);
170
+ if (!values.size) return '';
171
+ return [' env:', ...[...values.keys()].sort(byCodeUnit).map(key => ' ' + key + ': ' + JSON.stringify(values.get(key)))].join('\n');
172
+ }
173
+
174
+ /** Multiple deployed front ends build separately through the installed turbo (a run step has no node_modules/.bin on PATH), so each fe/apps/<app> and its dependencies see its own public values. */
175
+ function frontendBuild(source, app) {
176
+ const declaration = buildDeclaration(source);
177
+ const environments = Object.keys(source?.stack?.imageEnvironments ?? {});
178
+ const environment = environments.length > 1 ? 'production' : environments[0];
179
+ const images = Object.values(declaration.environments?.[environment]?.deploy?.images ?? {});
180
+ const frontend = images.filter(image => app?.sides.fe.apps.some(entry => entry.name === image.app));
181
+ const gates = declaration.release?.gates ?? [];
182
+ const singleEnv = imageBuildEnv(frontend[0], gates);
183
+ if (frontend.length <= 1) return { env: singleEnv, steps: [' - name: build fe', ' run: npm run build:fe', singleEnv].filter(Boolean).join('\n') };
184
+ const steps = frontend.map(image => [
185
+ ' - name: build fe ' + image.app,
186
+ ' run: npm run codegen --silent && npx --no-install turbo run build --filter=./fe/apps/' + image.app,
187
+ imageBuildEnv(image, gates),
188
+ ].filter(Boolean).join('\n')).join('\n');
189
+ return { env: singleEnv, steps };
190
+ }
191
+
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. The local release gate writes the same file with mergeSonarReports (release/lint-report.mjs): keep both identical. */
193
+ function sonarMergeSteps(condition) {
194
+ return [
195
+ ' - name: merge Sonar lint reports',
196
+ ` if: ${condition}`,
197
+ ' run: |',
198
+ " node --input-type=module <<'NODE'",
199
+ " import fs from 'node:fs';",
200
+ " const files = ['reports/lint.be.sonar.json', 'reports/lint.fe.sonar.json'];",
201
+ " const reports = files.filter(file => fs.existsSync(file)).map(file => JSON.parse(fs.readFileSync(file, 'utf8')));",
202
+ ' const rules = [...new Map(reports.flatMap(report => report.rules).map(rule => [rule.id, rule])).values()];',
203
+ ' const issues = reports.flatMap(report => report.issues);',
204
+ " fs.mkdirSync('reports', { recursive: true });",
205
+ " fs.writeFileSync('reports/lint.sonar.json', JSON.stringify({ rules, issues }, null, 2) + '\\n');",
206
+ ' NODE',
207
+ ];
208
+ }
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, heap } = parseYaml(fs.readFileSync(STANDARD, 'utf8'));
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
+ nodeOptions: `--max-old-space-size=${heap.minMiB}`,
219
+ timeoutFast: timeout('fast'), timeoutLint: timeout('lint'), timeoutTest: timeout('test'), timeoutCoverage: timeout('coverage'),
220
+ timeoutBuild: timeout('build'), timeoutSonar: timeout('sonar'), timeoutDbTypes: timeout('db-types'),
221
+ testShards: `[${Array.from({ length: shards }, (_, index) => index + 1).join(', ')}]`, testShardTotal: String(shards),
222
+ };
223
+ }
224
+
225
+ /** An omitted quality declaration preserves hosted Sonar, required OIDC Codecov, and the high production dependency audit. */
226
+ export function ciWorkflowCommands(source, lcovReport = 'be/coverage/lcov.info', quality = undefined, { nodeMajor = '22', app } = {}) {
227
+ const configuredSonar = quality === undefined ? { token: 'SONAR_TOKEN', hostVar: 'SONAR_HOST_URL' } : quality.sonar;
228
+ const sonar = configuredSonar?.wiring === 'not-used' || configuredSonar?.mode === 'disabled' ? null : configuredSonar;
229
+ const codecov = quality?.codecov ?? { enabled: true, auth: 'oidc' };
230
+ const audit = quality?.audit ?? { level: 'high', omitDev: true };
231
+ const lcovArtifact = 'coverage-lcov';
232
+ const build = frontendBuild(source, app);
233
+ const settings = ciJobSettings(buildDeclaration(source));
234
+ const side = '${{ matrix.side }}';
235
+ return {
236
+ ...settings,
237
+ lcovShardsDir: lcovReport.split('/').slice(0, -1).concat('shards').join('/'),
238
+ // The istanbul JSON a shard writes beside the lcov (STARCI_COVERAGE_SHARD=1): what the coverage job merges.
239
+ shardCoverageReport: lcovReport.split('/').slice(0, -1).concat('coverage-final.json').join('/'),
240
+ ciBuildEnv: build.env,
241
+ ciBuildSteps: build.steps,
242
+ ciLint: exampleCommand(source, 'npm run lint'),
243
+ ciLintSonar: exampleCommand(source, 'npm run lint -- --sonar reports/lint.sonar.json'),
244
+ ciCoverageProof: 'npm run codegen --silent && ' + exampleCommand(source, 'npx starci app check --coverage-from ' + lcovReport),
245
+ ciDatabaseLint: exampleCommand(source, 'npm run db:lint'),
246
+ auditCommand: `npm audit${audit.omitDev ? ' --omit=dev' : ''} --audit-level=${audit.level}`,
247
+ sonarTokenSecret: sonar?.token ?? 'SONAR_TOKEN',
248
+ sonarHostVar: sonar?.hostVar ?? 'SONAR_HOST_URL',
249
+ sonarEnv: sonar ? ` env:\n SONAR_TOKEN: \${{ secrets.${sonar.token} }}` : '',
250
+ // 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.
251
+ ciLintSteps: [
252
+ ' - name: lint',
253
+ ...(sonar ? [" if: env.SONAR_TOKEN == ''"] : []),
254
+ ` run: ${exampleCommand(source, 'npm run lint -- --side ' + side)}`,
255
+ ' env:',
256
+ ` NODE_OPTIONS: ${settings.nodeOptions}`,
257
+ ...(sonar ? [
258
+ ' - name: lint with Sonar import',
259
+ " if: env.SONAR_TOKEN != ''",
260
+ ` run: ${exampleCommand(source, 'npm run lint -- --side ' + side + ' --sonar reports/lint.' + side + '.sonar.json')}`,
261
+ ' env:',
262
+ ` NODE_OPTIONS: ${settings.nodeOptions}`,
263
+ ` - uses: ${UPLOAD_ARTIFACT}`,
264
+ // Inside an if: expression the matrix value is an argument of format(), never a nested ${{ }} (actionlint: a nested expression makes the condition always true).
265
+ " if: always() && env.SONAR_TOKEN != '' && hashFiles(format('reports/lint.{0}.sonar.json', matrix.side)) != ''",
266
+ ' with:',
267
+ ` name: sonar-lint-${side}`,
268
+ ` path: reports/lint.${side}.sonar.json`,
269
+ ] : []),
270
+ ].join('\n'),
271
+ codecovSteps: codecovSteps(lcovReport, codecov),
272
+ sonarJob: sonarJob(sonar, lcovReport, lcovArtifact, nodeMajor, settings.timeoutSonar),
273
+ lcovArtifact,
274
+ };
275
+ }
@@ -107,6 +107,8 @@ jobs:
107
107
  {{> app/ci-workflows/build-cache-save.yml}}
108
108
  - name: database migration checks
109
109
  run: {{ciDatabaseLint}}
110
+ env:
111
+ NODE_OPTIONS: {{nodeOptions}}
110
112
 
111
113
  {{sonarJob}}
112
114
  db-types: