@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
@@ -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',
@@ -131,7 +133,7 @@ function sonarJob(sonar, lcovReport, lcovArtifact, nodeMajor) {
131
133
  .replaceAll('{{nodeMajor}}', nodeMajor)
132
134
  .replace('{{sonarJobHeader}}', () => header)
133
135
  .replace('{{sonarJobSteps}}', () => steps)
134
- .trimEnd();
136
+ .trimEnd() + '\n';
135
137
  }
136
138
 
137
139
  /** The app source reader is be-relative; the authored manifest lives one directory above that side. */
@@ -158,29 +160,82 @@ function mergeBuildValues(values, entries) {
158
160
  }
159
161
  }
160
162
 
161
- /** CI builds render only plain values from the images.yml environment plus the explicit build-fe gate; sealed members stay unopened. */
162
- function buildEnv(source) {
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) {
163
176
  const declaration = buildDeclaration(source);
164
177
  const environments = Object.keys(source?.stack?.imageEnvironments ?? {});
165
178
  const environment = environments.length > 1 ? 'production' : environments[0];
166
- const images = declaration.environments?.[environment]?.deploy?.images ?? {};
167
- const values = new Map();
168
- for (const image of Object.values(images)) mergeBuildValues(values, image?.buildArgs?.values);
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));
169
181
  const gates = declaration.release?.gates ?? [];
170
- for (const gate of gates.filter(entry => entry.name === 'build-fe')) mergeBuildValues(values, gate.env);
171
- if (!values.size) return '';
172
- return [' env:', ...[...values.keys()].sort(byCodeUnit).map(key => ' ' + key + ': ' + JSON.stringify(values.get(key)))].join('\n');
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 = 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
+ };
173
222
  }
174
223
 
175
224
  /** An omitted quality declaration preserves hosted Sonar, required OIDC Codecov, and the high production dependency audit. */
176
- export function ciWorkflowCommands(source, lcovReport = 'be/coverage/lcov.info', quality = undefined, { nodeMajor = '22' } = {}) {
225
+ export function ciWorkflowCommands(source, lcovReport = 'be/coverage/lcov.info', quality = undefined, { nodeMajor = '22', app } = {}) {
177
226
  const configuredSonar = quality === undefined ? { token: 'SONAR_TOKEN', hostVar: 'SONAR_HOST_URL' } : quality.sonar;
178
227
  const sonar = configuredSonar?.wiring === 'not-used' || configuredSonar?.mode === 'disabled' ? null : configuredSonar;
179
228
  const codecov = quality?.codecov ?? { enabled: true, auth: 'oidc' };
180
229
  const audit = quality?.audit ?? { level: 'high', omitDev: true };
181
230
  const lcovArtifact = 'coverage-lcov';
231
+ const build = frontendBuild(source, app);
232
+ const settings = ciJobSettings(buildDeclaration(source));
233
+ const side = '${{ matrix.side }}';
182
234
  return {
183
- ciBuildEnv: buildEnv(source),
235
+ ...settings,
236
+ lcovShardsDir: lcovReport.split('/').slice(0, -1).concat('shards').join('/'),
237
+ ciBuildEnv: build.env,
238
+ ciBuildSteps: build.steps,
184
239
  ciLint: exampleCommand(source, 'npm run lint'),
185
240
  ciLintSonar: exampleCommand(source, 'npm run lint -- --sonar reports/lint.sonar.json'),
186
241
  ciCoverageProof: exampleCommand(source, 'npx starci app check --coverage-from ' + lcovReport),
@@ -189,29 +244,28 @@ export function ciWorkflowCommands(source, lcovReport = 'be/coverage/lcov.info',
189
244
  sonarTokenSecret: sonar?.token ?? 'SONAR_TOKEN',
190
245
  sonarHostVar: sonar?.hostVar ?? 'SONAR_HOST_URL',
191
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.
192
248
  ciLintSteps: [
193
- ...['be', 'fe'].flatMap((side) => [
194
- ' - name: lint ' + side,
195
- ...(sonar ? [" if: env.SONAR_TOKEN == ''"] : []),
196
- ` run: ${exampleCommand(source, 'npm run lint -- --side ' + side)}`,
197
- ' env:',
198
- ' NODE_OPTIONS: --max-old-space-size=6144',
199
- ]),
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',
200
254
  ...(sonar ? [
201
255
  ' - name: lint with Sonar import',
202
256
  " if: env.SONAR_TOKEN != ''",
203
- ` run: ${exampleCommand(source, 'npm run lint -- --sonar reports/lint.sonar.json')}`,
257
+ ` run: ${exampleCommand(source, 'npm run lint -- --side ' + side + ' --sonar reports/lint.' + side + '.sonar.json')}`,
204
258
  ' env:',
205
259
  ' NODE_OPTIONS: --max-old-space-size=6144',
206
260
  ` - uses: ${UPLOAD_ARTIFACT}`,
207
- " if: always() && env.SONAR_TOKEN != '' && hashFiles('reports/lint.sonar.json') != ''",
261
+ ` if: always() && env.SONAR_TOKEN != '' && hashFiles('reports/lint.${side}.sonar.json') != ''`,
208
262
  ' with:',
209
- ' name: sonar-lint',
210
- ' path: reports/lint.sonar.json',
263
+ ` name: sonar-lint-${side}`,
264
+ ` path: reports/lint.${side}.sonar.json`,
211
265
  ] : []),
212
266
  ].join('\n'),
213
267
  codecovSteps: codecovSteps(lcovReport, codecov),
214
- sonarJob: sonarJob(sonar, lcovReport, lcovArtifact, nodeMajor),
268
+ sonarJob: sonarJob(sonar, lcovReport, lcovArtifact, nodeMajor, settings.timeoutSonar),
215
269
  lcovArtifact,
216
270
  };
217
271
  }
package/sync/hygiene.mjs CHANGED
@@ -79,7 +79,10 @@ function stagedText(cwd, file) {
79
79
 
80
80
  /** The secret findings of `files` read from the index, as {file, code, message}; a file the name check already refused is not reported twice. */
81
81
  function secretGuardFindings(cwd, files, alreadyRefused = new Set()) {
82
- return files.filter(file => !alreadyRefused.has(file)).flatMap(file => secretFileFindings({ file, text: stagedText(cwd, file) }).map(finding => ({ file, code: finding.code, message: finding.message })));
82
+ return files.filter(file => !alreadyRefused.has(file)).flatMap(file => {
83
+ const manifestText = /(?:^|\/)README\.md$/.test(file) ? stagedText(cwd, path.posix.join(path.posix.dirname(file), '.starcistacks/application-stacks.yaml')) ?? undefined : undefined;
84
+ return secretFileFindings({ file, text: stagedText(cwd, file), manifestText }).map(finding => ({ file, code: finding.code, message: finding.message }));
85
+ });
83
86
  }
84
87
 
85
88
  /** Findings for `files`: the guarded trees, then the secret guard over every file. */
package/sync/index.mjs CHANGED
@@ -128,11 +128,12 @@ function readBundled(name) {
128
128
 
129
129
  /**
130
130
  * Fill `{{name}}` from vars and expand `{{> partial}}` lines. A partial line may be written as a line comment (`// {{> partial}}`)
131
- * so a JavaScript template stays valid source before rendering. An unknown name is an error, never an empty string.
131
+ * so a JavaScript template stays valid source before rendering. Empty variable-only lines disappear; an unknown name is an error.
132
132
  */
133
133
  export function render(text, vars, readTemplate = readBundled) {
134
134
  const withPartials = text.replace(/^(?:\/\/ )?\{\{> ([\w./-]+)\}\}\n/gm, (_, name) => trailingNewline(render(readTemplate(name), vars, readTemplate)));
135
- return withPartials.replace(/\{\{([A-Za-z]\w*)\}\}/g, (_, key) => {
135
+ const presentLines = withPartials.replace(/^[ \t]*\{\{([A-Za-z]\w*)\}\}[ \t]*(?:\n|$)/gm, (line, key) => vars[key] === '' ? '' : line);
136
+ return presentLines.replace(/\{\{([A-Za-z]\w*)\}\}/g, (_, key) => {
136
137
  if (!Object.hasOwn(vars, key)) throw new SyncError('HFS_SYNC_TEMPLATE_VARIABLE', `template names {{${key}}}, which sync does not provide`);
137
138
  return vars[key];
138
139
  });
@@ -169,7 +170,7 @@ const SOURCE_SKIPPED = new Set(['node_modules', 'dist', 'coverage']);
169
170
  * lite brand tree exists, so sync can preserve its absence. `hasImage` identifies authored optional Dockerfiles without reading
170
171
  * their content. An app with no be folder has no source files.
171
172
  */
172
- export function appSource(root, { parseYaml } = {}) {
173
+ export function appSource(root, { parseYaml, notices } = {}) {
173
174
  const files = [];
174
175
  const walk = dir => {
175
176
  let entries;
@@ -183,7 +184,7 @@ export function appSource(root, { parseYaml } = {}) {
183
184
  return { runtimeExample: isRuntimeExampleApp(root), files: files.sort(byCodeUnit), read: file => fs.readFileSync(path.join(root, 'be', file), 'utf8'),
184
185
  workPresent: fs.existsSync(path.join(root, '.starciwork')), hasImage: file => isFile(path.join(root, file), { followLinks: false }),
185
186
  stack: readDeclaredStack(root, { parseYaml }), quality: readDeclaredQuality(root, { parseYaml }),
186
- badges: readDeclaredBadges(root, { parseYaml }),
187
+ badges: readDeclaredBadges(root, { parseYaml, notices }),
187
188
  qualityDeclared: hasDeclaredServices(root, { parseYaml }) };
188
189
  }
189
190
 
@@ -323,7 +324,7 @@ export function variables(app, scope, presets, sonarKey, manifest = loadSlotMani
323
324
  sonarExclusions: [presets?.sonarExclusions, '**/.next/**', '**/node_modules/**', '**/src/messages/**', ...(lite ? ['supabase/types/**'] : [])].filter(Boolean).join(','),
324
325
  sonarSources: ['be/apps', 'be/src', 'fe/apps', ...(packages ? ['fe/packages'] : [])].join(','),
325
326
  lcovReport: LCOV_REPORT,
326
- ...ciWorkflowCommands(source, LCOV_REPORT, source.qualityDeclared === false ? undefined : source.quality),
327
+ ...ciWorkflowCommands(source, LCOV_REPORT, source.qualityDeclared === false ? undefined : source.quality, { app }),
327
328
  // The coverage scope is the same in both editions: lite measures exactly what full measures (ruling:R212/case-1, amended by ruling:R212/case-4).
328
329
  jestCoverage: scope === 'be' ? jestCoverageSource(jestCoverage(manifest, providers)) : '',
329
330
  coverageExclusions: scope === APP_SCOPE ? sonarCoverageExclusions(manifest, providers).join(',') : '',
@@ -370,7 +371,7 @@ export function renderTargets(hfs, presets, { sonarKey, readTemplate = readBundl
370
371
  const template = target.path === 'codecov.yml' && source?.quality?.codecov.enabled === false
371
372
  ? '{{header}}\n# Codecov is disabled in the manifest.\ncodecov:\n require_ci_to_pass: false\n'
372
373
  : readTemplate(target.template);
373
- const body = render(template, vars, readTemplate);
374
+ const body = trailingNewline(render(template, vars, readTemplate));
374
375
  const where = onSide(scope, target.path);
375
376
  if (target.mode === 'scripts') {
376
377
  const scripts = parseScripts(body);
@@ -429,8 +430,8 @@ function insertAfterTitle(existing, content) {
429
430
  return `${heading}\n${content}${existing.slice(end)}`;
430
431
  }
431
432
 
432
- /** Rewrite the missing or drifted targets; returns the paths written. */
433
- function writtenContent(target, existing) {
433
+ /** Apply exactly one target to its whole file while preserving content outside the managed block or scripts. */
434
+ export function writtenContent(target, existing) {
434
435
  if (target.mode === 'scripts') {
435
436
  let pkg = {};
436
437
  if (existing) {
@@ -477,10 +478,10 @@ export function loadHfs(root, manifest = loadSlotManifest()) {
477
478
  }
478
479
 
479
480
  /** The rendered targets of the app at `root`: its hfs.json, the Sonar key the be side's stack declaration names, the presets it installs. */
480
- export async function renderRepo(root, { presets, parseYaml, manifest = loadSlotManifest() } = {}) {
481
+ export async function renderRepo(root, { presets, parseYaml, notices, manifest = loadSlotManifest() } = {}) {
481
482
  const hfs = loadHfs(root, manifest);
482
483
  const sonarKey = await readDeclaredSonarKey(root, { parseYaml, fail: message => { throw new SyncError('HFS_SYNC_SONAR_KEY', message); } });
483
- return { hfs, targets: renderTargets(hfs, presets ?? await loadPresets(root), { sonarKey, manifest, source: appSource(root, { parseYaml }) }) };
484
+ return { hfs, targets: renderTargets(hfs, presets ?? await loadPresets(root), { sonarKey, manifest, source: appSource(root, { parseYaml, notices }) }) };
484
485
  }
485
486
 
486
487
  /** `starci app sync --check | --write [--cwd <dir>]` at the app root; returns 0 clean, 1 drift, 2 usage. */
@@ -516,7 +517,9 @@ export async function runSync(argv, { cwd = process.cwd(), out = line => process
516
517
  const { write, cwdAt } = options;
517
518
  const root = cwdAt >= 0 ? path.resolve(cwd, argv[cwdAt + 1]) : cwd;
518
519
  try {
519
- const { targets } = await renderRepo(root, { presets, parseYaml });
520
+ const notices = [];
521
+ const { targets } = await renderRepo(root, { presets, parseYaml, notices });
522
+ if (targets.some(target => target.path === 'README.md')) for (const notice of new Set(notices)) out(notice);
520
523
  if (write) {
521
524
  const written = writeTargets(root, targets);
522
525
  for (const file of written) out(`wrote ${file}`);
@@ -3,7 +3,7 @@ import path from 'node:path';
3
3
  import { parseYaml as bundledParseYaml } from '../runtime/engine/yaml.mjs';
4
4
  import { qualityServices } from '../runtime/scripts/hfs/ci-declaration.mjs';
5
5
  import { isRuntimeExampleApp } from '../runtime/scripts/lib/runtime-example.mjs';
6
- import { renderReadmeBadges } from './readme-badges.mjs';
6
+ import { badgeTokenNotices, renderReadmeBadges } from './readme-badges.mjs';
7
7
  import { DECLARATION } from './sonar-key.mjs';
8
8
 
9
9
  /** Apps without any service block retain the legacy CI defaults until they declare their services. */
@@ -35,8 +35,8 @@ export function sonarHostLine(sonar) {
35
35
 
36
36
  /** Dependabot groups minor and patch updates while majors remain per package; security updates have their own immediate group. */
37
37
  export function dependabotUpdates(dependabot) {
38
- if (!dependabot.ecosystems.length) return ' []';
39
- return dependabot.ecosystems.map(ecosystem => [
38
+ if (!dependabot.ecosystems.length) return ' []';
39
+ return '\n' + dependabot.ecosystems.map(ecosystem => [
40
40
  ` - package-ecosystem: ${ecosystem}`,
41
41
  ...(ecosystem === 'docker' ? [' directories:', ' - /be/apps/*', ' - /fe/apps/*'] : [' directory: /']),
42
42
  ' schedule:',
@@ -53,9 +53,11 @@ export function dependabotUpdates(dependabot) {
53
53
  }
54
54
 
55
55
  /** README badges use only public manifest fields and the package repository fallback. */
56
- export function readDeclaredBadges(root, { parseYaml = bundledParseYaml } = {}) {
56
+ export function readDeclaredBadges(root, { parseYaml = bundledParseYaml, notices } = {}) {
57
57
  let doc = {}, pkg = {};
58
58
  try { doc = parseYaml(fs.readFileSync(path.join(root, DECLARATION), 'utf8')); } catch { /* No declaration. */ }
59
59
  try { pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')); } catch { /* No repository fallback. */ }
60
- return renderReadmeBadges(doc, { repository: pkg.repository, example: isRuntimeExampleApp(root) });
60
+ const options = { repository: pkg.repository, example: isRuntimeExampleApp(root) };
61
+ notices?.push(...badgeTokenNotices(doc, options));
62
+ return renderReadmeBadges(doc, options);
61
63
  }
@@ -7,6 +7,9 @@ function githubRepository(repository) {
7
7
  return (match?.[1] ?? match?.[2] ?? '').replace(/\.git$/, '');
8
8
  }
9
9
 
10
+ /** Notice code (never an error) of a private repository badge rendered without its read-only badge token. */
11
+ const BADGE_TOKEN_NOTICE = 'README_BADGE_TOKEN_MISSING';
12
+
10
13
  const badge = (name, image, link) => `[![${name}](${image})](${link})`;
11
14
 
12
15
  /** Render live CI, coverage, quality gate and Dependabot badges only when a GitHub repository is declared or inferable; no network is used. */
@@ -20,7 +23,10 @@ export function renderReadmeBadges(doc, { repository, example = false } = {}) {
20
23
  const quality = qualityServices(doc, { example });
21
24
  const lines = [badge('CI', `${base}/actions/workflows/${workflow}/badge.svg?branch=${branch}`, `${base}/actions/workflows/${workflow}`)];
22
25
  if (quality.codecov.enabled) {
23
- const flag = quality.codecov.flag ? `?flag=${encodeURIComponent(quality.codecov.flag)}` : '';
26
+ const params = [];
27
+ if (quality.codecov.flag) params.push(`flag=${encodeURIComponent(quality.codecov.flag)}`);
28
+ if (doc?.services?.codecov?.badgeToken) params.push(`token=${encodeURIComponent(doc.services.codecov.badgeToken)}`);
29
+ const flag = params.length ? `?${params.join('&')}` : '';
24
30
  lines.push(badge('Codecov', `https://codecov.io/gh/${repo}/branch/${branch}/graph/badge.svg${flag}`, `https://codecov.io/gh/${repo}`));
25
31
  }
26
32
  const sonar = sonarBadge(doc, quality.sonar, example);
@@ -40,5 +46,15 @@ function sonarBadge(doc, sonar, example) {
40
46
  const organization = sonar.organization ?? doc?.ci?.vars?.find(entry => entry.name === sonar.organizationVar)?.value;
41
47
  const prefix = example && organization ? `${organization}_` : '';
42
48
  const key = encodeURIComponent(prefix + sonar.projectKey);
43
- return badge('Quality Gate', `${host}/api/project_badges/measure?project=${key}&metric=alert_status`, `${host}/dashboard?id=${key}`);
49
+ const token = doc?.services?.sonar?.badgeToken ? `&token=${encodeURIComponent(doc.services.sonar.badgeToken)}` : '';
50
+ return badge('Quality Gate', `${host}/api/project_badges/measure?project=${key}&metric=alert_status${token}`, `${host}/dashboard?id=${key}`);
51
+ }
52
+
53
+ /** Private repositories need public-safe badge tokens only for badges the renderer actually emits; examples never receive notices. */
54
+ export function badgeTokenNotices(doc, { example = false, repository } = {}) {
55
+ if (example || doc?.ci?.github?.visibility !== 'private') return [];
56
+ const rendered = renderReadmeBadges(doc, { example, repository });
57
+ return [['codecov', 'Codecov', 'Codecov'], ['sonar', 'Sonar', 'Quality Gate']]
58
+ .filter(([id, , label]) => !doc?.services?.[id]?.badgeToken && rendered.includes(`[![${label}](`))
59
+ .map(([id, name]) => `${BADGE_TOKEN_NOTICE} notice: the ${name} badge of a private repository renders "unknown" without services.${id}.badgeToken (a read-only badge token, not a CI secret)`);
44
60
  }
@@ -71,6 +71,11 @@ export function readDeclaredStack(root, { parseYaml = bundledParseYaml } = {}) {
71
71
  } catch {
72
72
  return { images: [], passEnv: [] };
73
73
  }
74
+ return declaredStack(declaration);
75
+ }
76
+
77
+ /** Parsed declarations share the exact image, environment and pass-env projection used by sync. */
78
+ export function declaredStack(declaration) {
74
79
  return { images: declaredStackImages(declaration), passEnv: declaredPassEnv(declaration), imageEnvironments: imageEnvironments(declaration) };
75
80
  }
76
81
 
@@ -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,11 +75,16 @@ 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}}
86
+ env:
87
+ NODE_OPTIONS: --max-old-space-size=6144
47
88
  - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
48
89
  if: always() && hashFiles('{{lcovReport}}') != ''
49
90
  with:
@@ -52,14 +93,13 @@ jobs:
52
93
  {{codecovSteps}}
53
94
 
54
95
  build:
55
- timeout-minutes: 30
96
+ timeout-minutes: {{timeoutBuild}}
56
97
  runs-on: ubuntu-latest
57
98
  steps:
58
99
  {{> app/ci-workflows/install-steps.yml}}
59
100
  - name: build be
60
101
  run: npm run build:be
61
- - name: build fe
62
- run: npm run build:fe
63
- {{ciBuildEnv}}
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,11 +75,16 @@ 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}}
86
+ env:
87
+ NODE_OPTIONS: --max-old-space-size=6144
47
88
  - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
48
89
  if: always() && hashFiles('{{lcovReport}}') != ''
49
90
  with:
@@ -52,22 +93,20 @@ jobs:
52
93
  {{codecovSteps}}
53
94
 
54
95
  build:
55
- timeout-minutes: 30
96
+ timeout-minutes: {{timeoutBuild}}
56
97
  runs-on: ubuntu-latest
57
98
  steps:
58
99
  {{> app/ci-workflows/install-steps.yml}}
59
100
  - name: build be
60
101
  run: npm run build:be
61
- - name: build fe
62
- run: npm run build:fe
63
- {{ciBuildEnv}}
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
-
69
108
  db-types:
70
- timeout-minutes: 30
109
+ timeout-minutes: {{timeoutDbTypes}}
71
110
  runs-on: ubuntu-latest
72
111
  steps:
73
112
  {{> app/ci-workflows/install-steps.yml}}