@starci/hfs 4.4.0 → 4.5.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 (113) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/README.md +1 -1
  3. package/ci/cli.mjs +64 -0
  4. package/ci/github.mjs +41 -0
  5. package/ci/secret.mjs +28 -0
  6. package/ci/vars.mjs +33 -0
  7. package/ci/verify.mjs +36 -0
  8. package/db/backup-receipt.mjs +129 -0
  9. package/db/dump.mjs +66 -0
  10. package/db/migrate.mjs +8 -5
  11. package/db/post-cutover.mjs +4 -4
  12. package/db/reset.mjs +125 -0
  13. package/emit/contracts.mjs +2 -2
  14. package/emit/db-types.mjs +100 -7
  15. package/lint/run.mjs +30 -8
  16. package/package.json +2 -1
  17. package/release/ci-red.mjs +70 -0
  18. package/release/gate.mjs +53 -13
  19. package/release/index.mjs +8 -6
  20. package/release/push.mjs +1 -1
  21. package/report/sonar.mjs +1 -1
  22. package/runtime/config.example.yaml +7 -6
  23. package/runtime/engine/config.mjs +10 -18
  24. package/runtime/engine/removed-vocabulary.mjs +0 -2
  25. package/runtime/knowledge/hfs/canon-pins.yaml +4 -4
  26. package/runtime/knowledge/hfs/rules.yaml +505 -971
  27. package/runtime/knowledge/hfs/slots.yaml +13 -24
  28. package/runtime/modules/kernel/failure-codes.yaml +60 -22
  29. package/runtime/modules/kernel/removed-vocabulary.yaml +30 -0
  30. package/runtime/modules/models/runtimes.yaml +4 -6
  31. package/runtime/modules/schemas/application-stacks.schema.yaml +131 -1
  32. package/runtime/packages/cli/src/catalog.generated.mjs +286 -398
  33. package/runtime/scripts/api/git/lib.mjs +3 -3
  34. package/runtime/scripts/api/sops/lib.mjs +1 -1
  35. package/runtime/scripts/hfs/app-wiring.mjs +2 -2
  36. package/runtime/scripts/hfs/architecture/config.mjs +1 -1
  37. package/runtime/scripts/hfs/architecture/index.mjs +1 -1
  38. package/runtime/scripts/hfs/architecture/supabase.mjs +1 -1
  39. package/runtime/scripts/hfs/architecture/tiers.mjs +1 -1
  40. package/runtime/scripts/hfs/architecture.mjs +1 -1
  41. package/runtime/scripts/hfs/ci-declaration.mjs +102 -0
  42. package/runtime/scripts/hfs/coverage-from.mjs +1 -1
  43. package/runtime/scripts/hfs/coverage-scope.mjs +1 -1
  44. package/runtime/scripts/hfs/example-secrets.mjs +1 -1
  45. package/runtime/scripts/hfs/manifest-shape.mjs +3 -3
  46. package/runtime/scripts/hfs/param-names.mjs +1 -1
  47. package/runtime/scripts/hfs/rule-catalog.mjs +3 -1
  48. package/runtime/scripts/hfs/rules/a15-findings.mjs +10 -1
  49. package/runtime/scripts/hfs/rules/app-workflow-findings.mjs +10 -0
  50. package/runtime/scripts/hfs/rules/assets.mjs +1 -1
  51. package/runtime/scripts/hfs/rules/ci-custody.mjs +104 -0
  52. package/runtime/scripts/hfs/rules/cli.mjs +1 -1
  53. package/runtime/scripts/hfs/rules/contract-compat.mjs +1 -1
  54. package/runtime/scripts/hfs/rules/contract.mjs +1 -1
  55. package/runtime/scripts/hfs/rules/deploy-workflow.mjs +6 -19
  56. package/runtime/scripts/hfs/rules/fe-build-args.mjs +142 -0
  57. package/runtime/scripts/hfs/rules/frontend-tree.mjs +2 -2
  58. package/runtime/scripts/hfs/rules/supabase-secrets.mjs +1 -1
  59. package/runtime/scripts/hfs/rules/supply-chain.mjs +40 -3
  60. package/runtime/scripts/hfs/rules/workflow-contexts.mjs +103 -0
  61. package/runtime/scripts/hfs/runtime-rules/workflow-source.mjs +2 -2
  62. package/runtime/scripts/hfs/slots.mjs +2 -2
  63. package/runtime/scripts/hfs/typescript-programs.mjs +0 -8
  64. package/runtime/scripts/lib/env.mjs +2 -2
  65. package/runtime/scripts/lib/git.mjs +1 -1
  66. package/runtime/scripts/lib/i18n.mjs +0 -2
  67. package/runtime/scripts/lib/language.mjs +1 -1
  68. package/runtime/scripts/lib/roots.mjs +2 -4
  69. package/runtime/scripts/lib/secret-patterns.mjs +2 -2
  70. package/scaffold/add-fe-overlay.mjs +0 -121
  71. package/scaffold/add-fe-page.mjs +0 -268
  72. package/scaffold/supabase-ports.mjs +1 -1
  73. package/src/main.mjs +17 -6
  74. package/stack/age-install.mjs +1 -1
  75. package/stack/cli.mjs +2 -2
  76. package/stack/context.mjs +14 -2
  77. package/stack/digest.mjs +1 -1
  78. package/stack/docker-transport.mjs +93 -15
  79. package/stack/edge-alias.mjs +1 -1
  80. package/stack/image-scan.mjs +1 -1
  81. package/stack/ingress-create.mjs +2 -2
  82. package/stack/ingress.mjs +3 -3
  83. package/stack/release-store.mjs +12 -7
  84. package/stack/remote.mjs +16 -8
  85. package/stack/sealed.mjs +5 -5
  86. package/stack/ssh-session.mjs +178 -0
  87. package/sync/ci-commands.mjs +103 -1
  88. package/sync/format.mjs +1 -1
  89. package/sync/hygiene.mjs +1 -1
  90. package/sync/index.mjs +43 -18
  91. package/sync/quality-services.mjs +61 -0
  92. package/sync/readme-badges.mjs +44 -0
  93. package/sync/stack-images.mjs +31 -5
  94. package/templates/app/ci-workflows/github/workflows/ci.yml +26 -24
  95. package/templates/app/ci-workflows/github/workflows/images.yml +24 -2
  96. package/templates/app/ci-workflows/github/workflows/stack-deploy.yml +8 -2
  97. package/templates/app/ci-workflows/install-steps.yml +21 -0
  98. package/templates/app/ci-workflows/sonar-steps.yml +3 -9
  99. package/templates/app/ci-workflows-lite/github/workflows/ci.yml +28 -35
  100. package/templates/app/ci-workflows-lite/github/workflows/db-deploy.yml +2 -1
  101. package/templates/app/ci-workflows-lite/github/workflows/images.yml +24 -2
  102. package/templates/app/ci-workflows-lite/github/workflows/stack-deploy.yml +8 -2
  103. package/templates/app/quality-config/codecov.yml +6 -6
  104. package/templates/app/quality-config/sonar-project.properties +1 -0
  105. package/templates/app/readme-badges/README.md +1 -0
  106. package/templates/app/skeleton/.github/dependabot.yml +1 -14
  107. package/templates/app/skeleton/.starcistacks/application-stacks.yaml +61 -3
  108. package/templates/app/skeleton/README.md +6 -0
  109. package/templates/app/skeleton-lite/.github/dependabot.yml +1 -14
  110. package/templates/app/skeleton-lite/.starcistacks/application-stacks.yaml +57 -3
  111. package/templates/app/skeleton-lite/README.md +6 -0
  112. package/templates/app/upgrade-full/.starcistacks/application-stacks.yaml +52 -1
  113. package/upgrade/index.mjs +1 -1
package/sync/index.mjs CHANGED
@@ -7,11 +7,11 @@
7
7
  // templates/<scope or common>/<managedBy>/<file path with each leading dot dropped>, with dotted skeleton paths tried when no
8
8
  // undotted template exists. The scope is app for a root slot and the
9
9
  // side (be, fe) for a side slot, whose files land under that side's folder. Two targets are not whole files of a managedBy slot
10
- // and are listed in this module: the marked block of the root .gitignore, and .starciwork/.gitignore, which lives inside the
10
+ // and are listed in this module: the marked blocks of the root .gitignore and README.md, and .starciwork/.gitignore, which lives inside the
11
11
  // .starciwork directory slot. Every file is rendered with the app's hfs.json (both sides and their apps) and, for the Sonar
12
12
  // exclusions, the jest preset the app installs for its be side and the one coverage scope derived from the slot manifest
13
13
  // (scripts/hfs/coverage-scope.mjs: the be/jest.config.js scope, sonar.coverage.exclusions and the codecov.yml paths and components). `--check` compares the sha256 of the rendered content with the
14
- // file on disk and fails on any drift; `--write` rewrites the drifted files. `.gitignore` is the one shared file: only the marked
14
+ // file on disk and fails on any drift; `--write` rewrites the drifted files. `.gitignore` and README.md are shared files: only the marked
15
15
  // block is managed and the app's own lines around it are left alone. The root package.json is managed by its `scripts` block
16
16
  // only (mode scripts, compared as parsed JSON): the rest of the file (dependencies, the npm workspaces fe/apps/* and fe/packages/*,
17
17
  // HFS_MONO_WORKSPACES) is the app's. The fe side runs through turbo (turbo.json, app.task-graph): each fe app and package is a workspace.
@@ -31,14 +31,16 @@ import { readDeclaredStack, stackWorkflowVariables } from './stack-images.mjs';
31
31
  import { byCodeUnit } from '../report/order.mjs';
32
32
  import { isRuntimeExampleApp } from '../runtime/scripts/lib/runtime-example.mjs';
33
33
  import { ciWorkflowCommands } from './ci-commands.mjs';
34
+ import { dependabotUpdates, hasDeclaredServices, readDeclaredQuality, readDeclaredBadges, sonarHostLine } from './quality-services.mjs';
35
+ import { qualityServices } from '../runtime/scripts/hfs/ci-declaration.mjs';
34
36
 
35
37
  export const TEMPLATES_DIR = path.join(import.meta.dirname, '..', 'templates');
36
38
  const NODE_MAJOR = 22;
37
39
  const APP_BINARY = 'starci app';
38
40
  /** The scopes a target is rendered for: the app root, then each side. */
39
41
  export const SCOPES = Object.freeze([APP_SCOPE, ...SIDES]);
40
- export const BLOCK_BEGIN = '# >>> starci app sync (managed block; do not edit) >>>';
41
- export const BLOCK_END = '# <<< starci app sync <<<';
42
+ const BLOCK_BEGIN = '# >>> starci app sync (managed block; do not edit) >>>';
43
+ const BLOCK_END = '# <<< starci app sync <<<';
42
44
  const HEADER = scope => `# Generated by ${APP_BINARY} sync (${scope === APP_SCOPE ? 'app root' : scope + ' side'}). Do not edit: run "npx ${APP_BINARY} sync --write".`;
43
45
 
44
46
  export class SyncError extends Error {
@@ -167,7 +169,7 @@ const SOURCE_SKIPPED = new Set(['node_modules', 'dist', 'coverage']);
167
169
  * lite brand tree exists, so sync can preserve its absence. `hasImage` identifies authored optional Dockerfiles without reading
168
170
  * their content. An app with no be folder has no source files.
169
171
  */
170
- export function appSource(root) {
172
+ export function appSource(root, { parseYaml } = {}) {
171
173
  const files = [];
172
174
  const walk = dir => {
173
175
  let entries;
@@ -180,7 +182,9 @@ export function appSource(root) {
180
182
  for (const dir of ['apps', 'src']) walk(dir);
181
183
  return { runtimeExample: isRuntimeExampleApp(root), files: files.sort(byCodeUnit), read: file => fs.readFileSync(path.join(root, 'be', file), 'utf8'),
182
184
  workPresent: fs.existsSync(path.join(root, '.starciwork')), hasImage: file => isFile(path.join(root, file), { followLinks: false }),
183
- stack: readDeclaredStack(root) };
185
+ stack: readDeclaredStack(root, { parseYaml }), quality: readDeclaredQuality(root, { parseYaml }),
186
+ badges: readDeclaredBadges(root, { parseYaml }),
187
+ qualityDeclared: hasDeclaredServices(root, { parseYaml }) };
184
188
  }
185
189
 
186
190
  /**
@@ -299,8 +303,10 @@ export function variables(app, scope, presets, sonarKey, manifest = loadSlotMani
299
303
  const packages = opensPackages(fe);
300
304
  const lite = app.edition === 'lite';
301
305
  const providers = (app.sides.be.connections ?? []).map(connection => connection.provider);
306
+ const quality = source.quality ?? qualityServices({});
302
307
  return {
303
308
  header: HEADER(scope),
309
+ readmeBadges: source.badges ?? '',
304
310
  project: app.project,
305
311
  appBinary: APP_BINARY,
306
312
  appScripts: appScripts(app, source),
@@ -313,15 +319,18 @@ export function variables(app, scope, presets, sonarKey, manifest = loadSlotMani
313
319
  lintPrefix: 'npm run codegen --silent && ',
314
320
  nodeMajor: String(NODE_MAJOR),
315
321
  sonarKey: sonarKey ?? app.project,
322
+ sonarHostLine: sonarHostLine(quality.sonar),
316
323
  sonarExclusions: [presets?.sonarExclusions, '**/.next/**', '**/node_modules/**', '**/src/messages/**', ...(lite ? ['supabase/types/**'] : [])].filter(Boolean).join(','),
317
324
  sonarSources: ['be/apps', 'be/src', 'fe/apps', ...(packages ? ['fe/packages'] : [])].join(','),
318
325
  lcovReport: LCOV_REPORT,
319
- ...ciWorkflowCommands(source, LCOV_REPORT),
326
+ ...ciWorkflowCommands(source, LCOV_REPORT, source.qualityDeclared === false ? undefined : source.quality),
320
327
  // 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).
321
328
  jestCoverage: scope === 'be' ? jestCoverageSource(jestCoverage(manifest, providers)) : '',
322
329
  coverageExclusions: scope === APP_SCOPE ? sonarCoverageExclusions(manifest, providers).join(',') : '',
323
330
  codecovPaths: scope === APP_SCOPE ? codecovPaths(manifest, providers).map(glob => ` - ${JSON.stringify(glob)}`).join('\n') : '',
324
331
  codecovComponents: scope === APP_SCOPE ? componentsYaml(coverageComponents(manifest, { ...source, apps: app.sides.be.apps, providers })) : '',
332
+ codecovTarget: quality.codecov.target,
333
+ dependabotUpdates: dependabotUpdates(quality.dependabot),
325
334
  tsconfigPaths: ['be/tsconfig.json', ...fe.apps.map(entry => `fe/apps/${entry.name}/tsconfig.json`), ...(packages ? ['fe/packages/*/tsconfig.json'] : [])].join(','),
326
335
  styleGlob: STYLE_GLOB,
327
336
  imageMatrix: imageMatrix(app, source),
@@ -353,10 +362,15 @@ export function renderTargets(hfs, presets, { sonarKey, readTemplate = readBundl
353
362
  const hasTemplate = name => { try { return readTemplate(name) !== undefined; } catch { return false; } };
354
363
  return SCOPES.flatMap(scope => {
355
364
  const vars = variables(app, scope, presets, sonarKey, manifest, source);
356
- const targets = targetsOf(scope, { edition: app.edition, manifest, hasTemplate })
365
+ const declaredTargets = targetsOf(scope, { edition: app.edition, manifest, hasTemplate });
366
+ if (scope === APP_SCOPE && source?.badges) declaredTargets.push({ path: 'README.md', mode: 'block', markdown: true, template: 'app/readme-badges/README.md' });
367
+ const targets = declaredTargets
357
368
  .filter(target => !(app.edition === 'lite' && source?.workPresent === false && target.path === '.starciwork/.gitignore'));
358
369
  return targets.map(target => {
359
- const body = render(readTemplate(target.template), vars, readTemplate);
370
+ const template = target.path === 'codecov.yml' && source?.quality?.codecov.enabled === false
371
+ ? '{{header}}\n# Codecov is disabled in the manifest.\ncodecov:\n require_ci_to_pass: false\n'
372
+ : readTemplate(target.template);
373
+ const body = render(template, vars, readTemplate);
360
374
  const where = onSide(scope, target.path);
361
375
  if (target.mode === 'scripts') {
362
376
  const scripts = parseScripts(body);
@@ -364,21 +378,22 @@ export function renderTargets(hfs, presets, { sonarKey, readTemplate = readBundl
364
378
  const content = scriptsText(scripts);
365
379
  return { path: where, scope, mode: 'scripts', content, hash: hashOf(content), scripts };
366
380
  }
367
- const managed = target.mode === 'block' ? `${BLOCK_BEGIN}\n${trailingNewline(body)}${BLOCK_END}\n` : body;
368
- return { path: where, scope, mode: target.mode, content: managed, hash: hashOf(managed) };
381
+ const markers = target.markdown ? { begin: '<!-- >>> starci app sync (managed block; do not edit) >>> -->', end: '<!-- <<< starci app sync <<< -->' } : { begin: BLOCK_BEGIN, end: BLOCK_END };
382
+ const managed = target.mode === 'block' ? `${markers.begin}\n${trailingNewline(body)}${markers.end}\n` : body;
383
+ return { path: where, scope, mode: target.mode, markdown: target.markdown, markers, content: managed, hash: hashOf(managed) };
369
384
  });
370
385
  });
371
386
  }
372
387
 
373
388
  // The part of a file sync manages: all of it, the marked block, or the scripts block as text.
374
- function managedPart(mode, text) {
389
+ function managedPart(mode, text, markers = { begin: BLOCK_BEGIN, end: BLOCK_END }) {
375
390
  if (mode === 'file') return text;
376
391
  if (mode === 'scripts') {
377
392
  const scripts = parseScripts(text);
378
393
  return scripts ? scriptsText(scripts) : null;
379
394
  }
380
- const start = text.indexOf(BLOCK_BEGIN);
381
- const end = text.indexOf(BLOCK_END);
395
+ const start = text.indexOf(markers.begin);
396
+ const end = text.indexOf(markers.end);
382
397
  if (start < 0 || end < start) return null;
383
398
  const stop = text.indexOf('\n', end);
384
399
  return text.slice(start, stop < 0 ? text.length : stop + 1);
@@ -397,7 +412,7 @@ export function checkTargets(root, targets) {
397
412
  return targets.map(target => {
398
413
  const file = path.join(root, target.path);
399
414
  if (!fs.existsSync(file)) return { path: target.path, status: 'missing', expectedHash: target.hash };
400
- const part = managedPart(target.mode, lf(fs.readFileSync(file, 'utf8')));
415
+ const part = managedPart(target.mode, lf(fs.readFileSync(file, 'utf8')), target.markers);
401
416
  if (part === null) return { path: target.path, status: 'missing', expectedHash: target.hash };
402
417
  const actualHash = hashOf(part);
403
418
  if (actualHash === target.hash) return { path: target.path, status: 'ok', expectedHash: target.hash, actualHash };
@@ -405,6 +420,14 @@ export function checkTargets(root, targets) {
405
420
  });
406
421
  }
407
422
 
423
+ function insertAfterTitle(existing, content) {
424
+ const title = /^# [^\n]*(?:\n|$)/m.exec(existing);
425
+ if (!title) return `${content}\n${existing}`;
426
+ const end = title.index + title[0].length;
427
+ const heading = trailingNewline(existing.slice(0, end));
428
+ return `${heading}\n${content}${existing.slice(end)}`;
429
+ }
430
+
408
431
  /** Rewrite the missing or drifted targets; returns the paths written. */
409
432
  function writtenContent(target, existing) {
410
433
  if (target.mode === 'scripts') {
@@ -415,8 +438,10 @@ function writtenContent(target, existing) {
415
438
  return `${JSON.stringify({ ...pkg, scripts: target.scripts }, null, 2)}\n`;
416
439
  }
417
440
  if (target.mode === 'block' && existing) {
418
- const current = managedPart('block', existing);
419
- return current === null ? `${target.content}\n${existing}` : existing.replace(current, () => target.content);
441
+ const current = managedPart('block', existing, target.markers);
442
+ if (current !== null) return existing.replace(current, () => target.content);
443
+ if (target.markdown) return insertAfterTitle(existing, target.content);
444
+ return `${target.content}\n${existing}`;
420
445
  }
421
446
  return target.content;
422
447
  }
@@ -454,7 +479,7 @@ export function loadHfs(root, manifest = loadSlotManifest()) {
454
479
  export async function renderRepo(root, { presets, parseYaml, manifest = loadSlotManifest() } = {}) {
455
480
  const hfs = loadHfs(root, manifest);
456
481
  const sonarKey = await readDeclaredSonarKey(root, { parseYaml, fail: message => { throw new SyncError('HFS_SYNC_SONAR_KEY', message); } });
457
- return { hfs, targets: renderTargets(hfs, presets ?? await loadPresets(root), { sonarKey, manifest, source: { ...appSource(root), stack: readDeclaredStack(root, { parseYaml }) } }) };
482
+ return { hfs, targets: renderTargets(hfs, presets ?? await loadPresets(root), { sonarKey, manifest, source: appSource(root, { parseYaml }) }) };
458
483
  }
459
484
 
460
485
  /** `starci app sync --check | --write [--cwd <dir>]` at the app root; returns 0 clean, 1 drift, 2 usage. */
@@ -0,0 +1,61 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { parseYaml as bundledParseYaml } from '../runtime/engine/yaml.mjs';
4
+ import { qualityServices } from '../runtime/scripts/hfs/ci-declaration.mjs';
5
+ import { isRuntimeExampleApp } from '../runtime/scripts/lib/runtime-example.mjs';
6
+ import { renderReadmeBadges } from './readme-badges.mjs';
7
+ import { DECLARATION } from './sonar-key.mjs';
8
+
9
+ /** Apps without any service block retain the legacy CI defaults until they declare their services. */
10
+ export function hasDeclaredServices(root, { parseYaml = bundledParseYaml } = {}) {
11
+ try {
12
+ const doc = parseYaml(fs.readFileSync(path.join(root, DECLARATION), 'utf8'));
13
+ return Object.keys(doc?.services ?? {}).length > 0;
14
+ } catch {
15
+ return false;
16
+ }
17
+ }
18
+
19
+ /** Missing or unreadable manifests preserve the shared quality defaults; example apps use the runtime example policy. */
20
+ export function readDeclaredQuality(root, { parseYaml = bundledParseYaml } = {}) {
21
+ try {
22
+ const doc = parseYaml(fs.readFileSync(path.join(root, DECLARATION), 'utf8'));
23
+ return qualityServices(doc, { example: isRuntimeExampleApp(root) });
24
+ } catch {
25
+ return qualityServices({});
26
+ }
27
+ }
28
+
29
+ /** SonarCloud names its organization and public host; SonarQube uses the manifest host. */
30
+ export function sonarHostLine(sonar) {
31
+ if (!sonar) return '';
32
+ if (sonar.provider === 'sonarcloud') return [sonar.organization ? `sonar.organization=${sonar.organization}` : '', 'sonar.host.url=https://sonarcloud.io'].filter(Boolean).join('\n');
33
+ return sonar.host ? `sonar.host.url=${sonar.host}` : '';
34
+ }
35
+
36
+ /** Dependabot groups minor and patch updates while majors remain per package; security updates have their own immediate group. */
37
+ export function dependabotUpdates(dependabot) {
38
+ if (!dependabot.ecosystems.length) return ' []';
39
+ return dependabot.ecosystems.map(ecosystem => [
40
+ ` - package-ecosystem: ${ecosystem}`,
41
+ ...(ecosystem === 'docker' ? [' directories:', ' - /be/apps/*', ' - /fe/apps/*'] : [' directory: /']),
42
+ ' schedule:',
43
+ ` interval: ${dependabot.schedule.interval}`,
44
+ ...(dependabot.schedule.day ? [` day: ${dependabot.schedule.day}`] : []),
45
+ ...(dependabot.schedule.time ? [` time: ${JSON.stringify(dependabot.schedule.time)}`] : []),
46
+ ' groups:',
47
+ ...(ecosystem === 'github-actions' ? [' actions:', ' patterns: ["*"]'] : [
48
+ ...(dependabot.groups.minorPatch === 'grouped' ? [' minor-and-patch:', ' update-types: [minor, patch]'] : []),
49
+ ...(dependabot.security === 'immediate' ? [' security:', ' applies-to: security-updates', ' patterns: ["*"]', ' update-types: [minor, patch]'] : []),
50
+ ]),
51
+ ...(ecosystem === 'npm' && dependabot.ignore.length ? [' ignore:', ...dependabot.ignore.map(name => ` - dependency-name: ${JSON.stringify(name)}`)] : []),
52
+ ].join('\n')).join('\n');
53
+ }
54
+
55
+ /** README badges use only public manifest fields and the package repository fallback. */
56
+ export function readDeclaredBadges(root, { parseYaml = bundledParseYaml } = {}) {
57
+ let doc = {}, pkg = {};
58
+ try { doc = parseYaml(fs.readFileSync(path.join(root, DECLARATION), 'utf8')); } catch { /* No declaration. */ }
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) });
61
+ }
@@ -0,0 +1,44 @@
1
+ import { qualityServices } from '../runtime/scripts/hfs/ci-declaration.mjs';
2
+
3
+ /** Package repository fallbacks must name a GitHub HTTPS or Git transport URL. */
4
+ function githubRepository(repository) {
5
+ const url = typeof repository === 'string' ? repository : repository?.url;
6
+ const match = /^(?:git\+)?https:\/\/github\.com\/([^/]+\/[^/#?]+)|^git@github\.com:([^/]+\/[^/#?]+)/.exec(url ?? '');
7
+ return (match?.[1] ?? match?.[2] ?? '').replace(/\.git$/, '');
8
+ }
9
+
10
+ const badge = (name, image, link) => `[![${name}](${image})](${link})`;
11
+
12
+ /** Render live CI, coverage, quality gate and Dependabot badges only when a GitHub repository is declared or inferable; no network is used. */
13
+ export function renderReadmeBadges(doc, { repository, example = false } = {}) {
14
+ const github = doc?.ci?.github ?? {};
15
+ const repo = github.repository ?? githubRepository(repository);
16
+ if (!repo) return '';
17
+ const branch = encodeURIComponent(github.defaultBranch ?? 'main');
18
+ const workflow = encodeURIComponent(github.workflow ?? 'ci.yml');
19
+ const base = `https://github.com/${repo}`;
20
+ const quality = qualityServices(doc, { example });
21
+ const lines = [badge('CI', `${base}/actions/workflows/${workflow}/badge.svg?branch=${branch}`, `${base}/actions/workflows/${workflow}`)];
22
+ if (quality.codecov.enabled) {
23
+ const flag = quality.codecov.flag ? `?flag=${encodeURIComponent(quality.codecov.flag)}` : '';
24
+ lines.push(badge('Codecov', `https://codecov.io/gh/${repo}/branch/${branch}/graph/badge.svg${flag}`, `https://codecov.io/gh/${repo}`));
25
+ }
26
+ const sonar = sonarBadge(doc, quality.sonar, example);
27
+ if (sonar) lines.push(sonar);
28
+ if (quality.dependabot.ecosystems.length) {
29
+ const updates = `${base}/actions/workflows/dependabot/dependabot-updates`;
30
+ lines.push(badge('Dependabot', `${updates}/badge.svg`, updates));
31
+ }
32
+ return lines.join('\n');
33
+ }
34
+
35
+ /** Runtime examples use the organization-qualified key passed by examples.yml; products declare their complete project key. */
36
+ function sonarBadge(doc, sonar, example) {
37
+ if (!sonar?.projectKey) return '';
38
+ const host = sonar.provider === 'sonarcloud' ? 'https://sonarcloud.io' : sonar.host?.replace(/\/$/, '');
39
+ if (!host) return '';
40
+ const organization = sonar.organization ?? doc?.ci?.vars?.find(entry => entry.name === sonar.organizationVar)?.value;
41
+ const prefix = example && organization ? `${organization}_` : '';
42
+ 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}`);
44
+ }
@@ -32,7 +32,7 @@ function environmentImages(environment) {
32
32
  * The images of a parsed declaration, [{ service, app, buildArgs }] sorted by service: the union over its environments (the first
33
33
  * environment that names a service decides its app; a later one only adds build arguments). A declaration with no deploy block has none.
34
34
  */
35
- export function declaredStackImages(declaration) {
35
+ function declaredStackImages(declaration) {
36
36
  const byService = new Map();
37
37
  for (const environment of Object.values(record(declaration?.environments) ? declaration.environments : {})) {
38
38
  for (const image of environmentImages(environment)) {
@@ -44,15 +44,22 @@ export function declaredStackImages(declaration) {
44
44
  }
45
45
 
46
46
  /** The host variables (names only) the declared environments interpolate into `docker stack config`: the union of `deploy.passEnv`. */
47
- export function declaredPassEnv(declaration) {
47
+ function declaredPassEnv(declaration) {
48
48
  const names = Object.values(record(declaration?.environments) ? declaration.environments : {})
49
49
  .flatMap(environment => (Array.isArray(environment?.deploy?.passEnv) ? environment.deploy.passEnv : []))
50
50
  .filter(name => typeof name === 'string' && KEY.test(name));
51
51
  return [...new Set(names)].sort(byCodeUnit);
52
52
  }
53
53
 
54
+ /** Release builds select the sole image environment, or production when several environments deploy images. */
55
+ function imageEnvironments(declaration) {
56
+ return Object.fromEntries(Object.entries(declaration?.environments ?? {})
57
+ .filter(([name, environment]) => NAME.test(name) && environmentImages(environment).length > 0)
58
+ .map(([name, environment]) => [name, environmentImages(environment).map(image => image.app)]));
59
+ }
60
+
54
61
  /**
55
- * What the managed workflow renders from the declaration of the app at `root`: { images, passEnv } (both empty when the app has no
62
+ * What the managed workflows render from the declaration at `root`: { images, passEnv, imageEnvironments } (empty when the app has no
56
63
  * declaration, no deploy block or a declaration that does not parse; `starci app check` judges those). `parseYaml(text)` is injected.
57
64
  */
58
65
  export function readDeclaredStack(root, { parseYaml = bundledParseYaml } = {}) {
@@ -64,7 +71,7 @@ export function readDeclaredStack(root, { parseYaml = bundledParseYaml } = {}) {
64
71
  } catch {
65
72
  return { images: [], passEnv: [] };
66
73
  }
67
- return { images: declaredStackImages(declaration), passEnv: declaredPassEnv(declaration) };
74
+ return { images: declaredStackImages(declaration), passEnv: declaredPassEnv(declaration), imageEnvironments: imageEnvironments(declaration) };
68
75
  }
69
76
 
70
77
  const envName = service => `IMAGE_${service.toUpperCase().replaceAll('-', '_')}`;
@@ -109,7 +116,7 @@ function buildPushStep(image, dockerfile) {
109
116
  * stackPassEnv the env entries of the deploy step for the declared host variables (vars.<NAME>)
110
117
  * `dockerfileOf(app)` names the Dockerfile of an app (the sync module knows the layout).
111
118
  */
112
- export function stackWorkflowVariables({ images = [], passEnv = [] }, project, dockerfileOf) {
119
+ export function stackWorkflowVariables({ images = [], passEnv = [], imageEnvironments: environments = {} }, project, dockerfileOf) {
113
120
  const guard = images.length ? '' : block([
114
121
  '- name: require a declared image',
115
122
  ' shell: bash',
@@ -117,6 +124,7 @@ export function stackWorkflowVariables({ images = [], passEnv = [] }, project, d
117
124
  ]);
118
125
  const names = images.map(image => `echo "${image.service}=ghcr.io/\${owner}/${project}-${image.app}" >> "$GITHUB_OUTPUT"`);
119
126
  return {
127
+ ...releaseImageVariables(environments),
120
128
  stackGuard: guard,
121
129
  stackNames: block(names, ' '.repeat(10)),
122
130
  stackImageSteps: images.map(image => block([...(image.buildArgs ? buildArgsStep(image) : []), ...buildPushStep(image, dockerfileOf(image.app))])).join(''),
@@ -125,3 +133,21 @@ export function stackWorkflowVariables({ images = [], passEnv = [] }, project, d
125
133
  stackPassEnv: block(passEnv.map(name => `${name}: \${{ vars.${name} }}`), ' '.repeat(10)),
126
134
  };
127
135
  }
136
+
137
+ /** The matrix uses the deploy verb only for apps the selected environment deploys; undeployed images retain Dockerfile defaults. */
138
+ function releaseImageVariables(environments) {
139
+ const names = Object.keys(environments);
140
+ const selectedApp = names.map(name => `(env.STACK_ENVIRONMENT == '${name}' && contains(fromJSON('${JSON.stringify(environments[name])}'), matrix.name))`).join(' || ') || 'false';
141
+ const multiple = names.length > 1;
142
+ const environment = multiple ? 'production' : names[0] ?? 'production';
143
+ return {
144
+ imageEnvironment: environment,
145
+ imageJobEnvironment: names.length ? `\n environment: ${environment}` : '',
146
+ imageEnvironmentGuard: multiple ? block([
147
+ '- name: require a declared image environment',
148
+ ` if: \${{ !contains(fromJSON('${JSON.stringify(names)}'), env.STACK_ENVIRONMENT) }}`,
149
+ ' run: \'echo "::error::STACKS_DEPLOY_ENV_UNKNOWN: selected environment declares no deploy images"; exit 1\'',
150
+ ]) : '',
151
+ imageBuildArgsIf: `\${{ ${selectedApp} }}`,
152
+ };
153
+ }
@@ -15,43 +15,45 @@ concurrency:
15
15
  cancel-in-progress: false
16
16
 
17
17
  jobs:
18
- ci:
18
+ fast:
19
19
  runs-on: ubuntu-latest
20
- # id-token: write lets the Codecov upload authenticate with GitHub's OIDC token: no repository secret, no silent skip.
21
- permissions:
22
- contents: read
23
- id-token: write
24
- env:
25
- SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
20
+ {{sonarEnv}}
26
21
  steps:
27
- - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
28
- - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
29
- with:
30
- node-version: {{nodeMajor}}
31
- cache: npm
32
- - run: npm ci --ignore-scripts
22
+ {{> app/ci-workflows/install-steps.yml}}
33
23
  - name: production dependency audit
34
- run: npm audit --omit=dev --audit-level=high
35
- - name: lint
36
- run: {{ciLintSonar}}
24
+ run: {{auditCommand}}
25
+ {{ciLintSteps}}
37
26
  - name: format
38
27
  run: npm run format:check
39
28
  - name: typecheck
40
29
  run: npm run typecheck
30
+
31
+ test:
32
+ runs-on: ubuntu-latest
33
+ # Codecov OIDC authentication requires a job-scoped identity token.
34
+ permissions:
35
+ contents: read
36
+ id-token: write
37
+ steps:
38
+ {{> app/ci-workflows/install-steps.yml}}
41
39
  - name: unit
42
40
  run: npm test -- --ci
43
41
  - name: coverage proof
44
42
  run: {{ciCoverageProof}}
45
- - name: coverage upload
46
- uses: codecov/codecov-action@0fb7174895f61a3b6b78fc075e0cd60383518dac # v5.5.5
47
- if: ${{ !cancelled() && startsWith(github.ref, 'refs/tags/v') }}
43
+ - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
44
+ if: always() && hashFiles('{{lcovReport}}') != ''
48
45
  with:
49
- use_oidc: true
50
- files: {{lcovReport}}
51
- disable_search: true
52
- fail_ci_if_error: true
46
+ name: {{lcovArtifact}}
47
+ path: {{lcovReport}}
48
+ {{codecovSteps}}
49
+
50
+ build:
51
+ runs-on: ubuntu-latest
52
+ steps:
53
+ {{> app/ci-workflows/install-steps.yml}}
53
54
  - name: build be
54
55
  run: npm run build:be
55
56
  - name: build fe
56
57
  run: npm run build:fe
57
- {{> app/ci-workflows/sonar-steps.yml}}
58
+
59
+ {{sonarJob}}
@@ -17,21 +17,43 @@ concurrency:
17
17
 
18
18
  jobs:
19
19
  image:
20
- runs-on: ubuntu-latest
20
+ runs-on: ubuntu-latest{{imageJobEnvironment}}
21
+ env:
22
+ STACK_ENVIRONMENT: {{imageEnvironment}}
21
23
  strategy:
22
24
  fail-fast: false
23
25
  matrix:
24
26
  include:
25
27
  {{imageMatrix}}
26
- steps:
28
+ steps:{{imageEnvironmentGuard}}
27
29
  - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
28
30
  - name: release tag matches package version
29
31
  run: node -e "const version=require('./package.json').version;if(process.env.GITHUB_REF_NAME!=='v'+version)process.exit(1)"
32
+ - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
33
+ if: {{imageBuildArgsIf}}
34
+ with:
35
+ node-version: {{nodeMajor}}
36
+ cache: npm
37
+ - run: npm ci --ignore-scripts
38
+ if: {{imageBuildArgsIf}}
39
+ - name: install SOPS
40
+ if: {{imageBuildArgsIf}}
41
+ uses: mdgreenwald/mozilla-sops-action@bb2d96a13ef438ad122e5827a57ebd75d6ff118d # v2.1.0
42
+ with:
43
+ version: v3.13.2
44
+ - name: public build values
45
+ id: args
46
+ if: {{imageBuildArgsIf}}
47
+ env:
48
+ SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }}
49
+ IMAGE_APP: ${{ matrix.name }}
50
+ run: npx --no-install starci app stack build-args --env "$STACK_ENVIRONMENT" --app "$IMAGE_APP"
30
51
  - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
31
52
  - uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
32
53
  with:
33
54
  context: .
34
55
  file: ${{ matrix.file }}
56
+ build-args: ${{ steps.args.outputs.args }}
35
57
  push: false
36
58
  tags: {{imageProject}}/${{ matrix.name }}:${{ github.ref_name }}
37
59
  # OCI keeps the SBOM and provenance attestations without publishing to a registry.
@@ -50,7 +50,6 @@ jobs:
50
50
  packages: write
51
51
  env:
52
52
  STACK_ENVIRONMENT: ${{ inputs.environment }}
53
- XDG_DATA_HOME: ${{ runner.temp }}/starci-data
54
53
  steps:{{stackGuard}}
55
54
  - name: require release tag
56
55
  shell: bash
@@ -80,6 +79,7 @@ jobs:
80
79
  password: ${{ github.token }}{{stackImageSteps}}
81
80
  - name: connect to the swarm manager
82
81
  env:
82
+ XDG_DATA_HOME: ${{ runner.temp }}/starci-data
83
83
  VPS_SSH_KEY: ${{ secrets.VPS_SSH_KEY }}
84
84
  VPS_KNOWN_HOSTS: ${{ secrets.VPS_KNOWN_HOSTS }}
85
85
  VPS_HOST: ${{ vars.VPS_HOST }}
@@ -87,11 +87,14 @@ jobs:
87
87
  run: npx --no-install starci app stack connect --env "$STACK_ENVIRONMENT" --execute
88
88
  - name: deploy the release
89
89
  env:
90
+ XDG_DATA_HOME: ${{ runner.temp }}/starci-data
90
91
  SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }}{{stackImageEnv}}{{stackPassEnv}}
91
92
  run: |
92
93
  npx --no-install starci app stack deploy --env "$STACK_ENVIRONMENT" \{{stackImageArgs}}
93
94
  --source-sha "$GITHUB_SHA" --execute
94
95
  - name: disconnect from the swarm manager
96
+ env:
97
+ XDG_DATA_HOME: ${{ runner.temp }}/starci-data
95
98
  if: ${{ always() }}
96
99
  run: npx --no-install starci app stack disconnect --env "$STACK_ENVIRONMENT" --execute
97
100
 
@@ -105,7 +108,6 @@ jobs:
105
108
  packages: read
106
109
  env:
107
110
  STACK_ENVIRONMENT: ${{ inputs.environment }}
108
- XDG_DATA_HOME: ${{ runner.temp }}/starci-data
109
111
  steps:
110
112
  - name: require release tag
111
113
  shell: bash
@@ -129,6 +131,7 @@ jobs:
129
131
  password: ${{ github.token }}
130
132
  - name: connect to the swarm manager
131
133
  env:
134
+ XDG_DATA_HOME: ${{ runner.temp }}/starci-data
132
135
  VPS_SSH_KEY: ${{ secrets.VPS_SSH_KEY }}
133
136
  VPS_KNOWN_HOSTS: ${{ secrets.VPS_KNOWN_HOSTS }}
134
137
  VPS_HOST: ${{ vars.VPS_HOST }}
@@ -136,6 +139,7 @@ jobs:
136
139
  run: npx --no-install starci app stack connect --env "$STACK_ENVIRONMENT" --execute
137
140
  - name: redeploy the stored release
138
141
  env:
142
+ XDG_DATA_HOME: ${{ runner.temp }}/starci-data
139
143
  SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }}
140
144
  RELEASE: ${{ inputs.release }}
141
145
  shell: bash
@@ -144,5 +148,7 @@ jobs:
144
148
  [ -z "$RELEASE" ] || release=(--release "$RELEASE")
145
149
  npx --no-install starci app stack rollback --env "$STACK_ENVIRONMENT" "${release[@]}" --execute
146
150
  - name: disconnect from the swarm manager
151
+ env:
152
+ XDG_DATA_HOME: ${{ runner.temp }}/starci-data
147
153
  if: ${{ always() }}
148
154
  run: npx --no-install starci app stack disconnect --env "$STACK_ENVIRONMENT" --execute
@@ -0,0 +1,21 @@
1
+ - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
2
+ - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
3
+ with:
4
+ node-version: {{nodeMajor}}
5
+ cache: npm
6
+ - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
7
+ id: deps
8
+ with:
9
+ path: node_modules
10
+ key: ${{ runner.os }}-node{{nodeMajor}}-modules-${{ hashFiles('package-lock.json') }}
11
+ - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
12
+ with:
13
+ path: |
14
+ .turbo
15
+ fe/apps/*/.next/cache
16
+ key: ${{ runner.os }}-node{{nodeMajor}}-build-${{ hashFiles('package-lock.json') }}-${{ github.sha }}
17
+ restore-keys: |
18
+ ${{ runner.os }}-node{{nodeMajor}}-build-${{ hashFiles('package-lock.json') }}-
19
+ ${{ runner.os }}-node{{nodeMajor}}-build-
20
+ - run: npm ci --ignore-scripts
21
+ if: steps.deps.outputs.cache-hit != 'true'
@@ -1,9 +1,3 @@
1
- - uses: SonarSource/sonarqube-scan-action@c7ee0f9df90b7aa20e8dcf9695dcfe2e7da5b4f2 # v7.2.1
2
- if: ${{ !cancelled() && startsWith(github.ref, 'refs/tags/v') && env.SONAR_TOKEN != '' }}
3
- env:
4
- SONAR_HOST_URL: ${{ vars.SONAR_HOST_URL }}
5
- - uses: SonarSource/sonarqube-quality-gate-action@7a5fffe8e523c40e0c740b6bc2712ab503e52efa # v1.2.1
6
- if: ${{ !cancelled() && startsWith(github.ref, 'refs/tags/v') && env.SONAR_TOKEN != '' }}
7
- timeout-minutes: 10
8
- env:
9
- SONAR_HOST_URL: ${{ vars.SONAR_HOST_URL }}
1
+ {{sonarJobHeader}}
2
+ {{> app/ci-workflows/install-steps.yml}}
3
+ {{sonarJobSteps}}