@starci/hfs 4.4.1 → 4.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/ci/cli.mjs +64 -0
  3. package/ci/github.mjs +41 -0
  4. package/ci/secret.mjs +28 -0
  5. package/ci/vars.mjs +33 -0
  6. package/ci/verify.mjs +36 -0
  7. package/db/backup-receipt.mjs +129 -0
  8. package/db/dump.mjs +66 -0
  9. package/db/migrate.mjs +4 -1
  10. package/db/reset.mjs +125 -0
  11. package/emit/db-types.mjs +98 -5
  12. package/lint/run.mjs +28 -6
  13. package/package.json +2 -1
  14. package/release/ci-red.mjs +70 -0
  15. package/release/gate.mjs +98 -27
  16. package/release/index.mjs +6 -4
  17. package/release/push.mjs +1 -1
  18. package/runtime/knowledge/hfs/canon-pins.yaml +5 -5
  19. package/runtime/knowledge/hfs/rules.yaml +95 -4
  20. package/runtime/modules/kernel/failure-codes.yaml +60 -0
  21. package/runtime/modules/schemas/application-stacks.schema.yaml +134 -1
  22. package/runtime/packages/cli/src/catalog.generated.mjs +243 -20
  23. package/runtime/scripts/hfs/ci-declaration.mjs +102 -0
  24. package/runtime/scripts/hfs/rules/a15-findings.mjs +9 -0
  25. package/runtime/scripts/hfs/rules/app-workflow-findings.mjs +10 -0
  26. package/runtime/scripts/hfs/rules/ci-custody.mjs +104 -0
  27. package/runtime/scripts/hfs/rules/deploy-workflow.mjs +6 -19
  28. package/runtime/scripts/hfs/rules/fe-build-args.mjs +142 -0
  29. package/runtime/scripts/hfs/rules/supply-chain.mjs +40 -3
  30. package/runtime/scripts/hfs/rules/workflow-contexts.mjs +103 -0
  31. package/runtime/scripts/lib/secret-patterns.mjs +2 -2
  32. package/src/main.mjs +17 -6
  33. package/stack/context.mjs +13 -1
  34. package/stack/docker-transport.mjs +93 -15
  35. package/stack/release-store.mjs +12 -7
  36. package/stack/remote.mjs +14 -6
  37. package/stack/ssh-session.mjs +178 -0
  38. package/sync/ci-commands.mjs +205 -1
  39. package/sync/index.mjs +42 -16
  40. package/sync/quality-services.mjs +61 -0
  41. package/sync/readme-badges.mjs +44 -0
  42. package/sync/stack-images.mjs +29 -3
  43. package/templates/app/ci-workflows/github/workflows/ci.yml +33 -25
  44. package/templates/app/ci-workflows/github/workflows/e2e.yml +1 -0
  45. package/templates/app/ci-workflows/github/workflows/images.yml +25 -2
  46. package/templates/app/ci-workflows/github/workflows/stack-deploy.yml +8 -2
  47. package/templates/app/ci-workflows/install-steps.yml +23 -0
  48. package/templates/app/ci-workflows/jest-cache.yml +8 -0
  49. package/templates/app/ci-workflows/lint-base.yml +16 -0
  50. package/templates/app/ci-workflows/sonar-steps.yml +3 -9
  51. package/templates/app/ci-workflows-lite/github/workflows/ci.yml +36 -36
  52. package/templates/app/ci-workflows-lite/github/workflows/db-deploy.yml +3 -1
  53. package/templates/app/ci-workflows-lite/github/workflows/images.yml +25 -2
  54. package/templates/app/ci-workflows-lite/github/workflows/stack-deploy.yml +8 -2
  55. package/templates/app/gitignore +2 -0
  56. package/templates/app/gitignore-lite +2 -0
  57. package/templates/app/quality-config/codecov.yml +6 -6
  58. package/templates/app/quality-config/sonar-project.properties +1 -0
  59. package/templates/app/readme-badges/README.md +1 -0
  60. package/templates/app/skeleton/.github/dependabot.yml +1 -14
  61. package/templates/app/skeleton/.starcistacks/application-stacks.yaml +62 -3
  62. package/templates/app/skeleton/README.md +6 -0
  63. package/templates/app/skeleton-lite/.github/dependabot.yml +1 -14
  64. package/templates/app/skeleton-lite/.starcistacks/application-stacks.yaml +58 -3
  65. package/templates/app/skeleton-lite/README.md +6 -0
  66. package/templates/app/upgrade-full/.starcistacks/application-stacks.yaml +53 -1
@@ -1,13 +1,217 @@
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
+
1
6
  // One command vocabulary for managed CI rendering and the runtime coverage gate.
2
7
  const exampleCommand = (source, command) => source?.runtimeExample
3
8
  ? 'node node_modules/@starci/hfs/runtime/scripts/hfs/example-secrets.mjs --run . -- ' + command
4
9
  : command;
5
10
 
6
- export function ciWorkflowCommands(source, lcovReport = 'be/coverage/lcov.info') {
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) {
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: [fast, test]',
102
+ ' timeout-minutes: 20',
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
+ ' name: sonar-lint',
120
+ ' path: reports',
121
+ ' - uses: SonarSource/sonarqube-scan-action@c7ee0f9df90b7aa20e8dcf9695dcfe2e7da5b4f2 # v7.2.1',
122
+ ` if: ${condition}`,
123
+ ' - uses: SonarSource/sonarqube-quality-gate-action@7a5fffe8e523c40e0c740b6bc2712ab503e52efa # v1.2.1',
124
+ ` if: ${condition}`,
125
+ ' timeout-minutes: 10',
126
+ ].join('\n');
127
+ // Variable strings are inserted after render() expands partials, so resolve Sonar's shared install partial before insertion.
128
+ const read = name => fs.readFileSync(new URL('../templates/app/ci-workflows/' + name, import.meta.url), 'utf8').replaceAll('\r\n', '\n');
129
+ return read('sonar-steps.yml')
130
+ .replace('{{> app/ci-workflows/install-steps.yml}}', () => read('install-steps.yml').trimEnd())
131
+ .replaceAll('{{nodeMajor}}', nodeMajor)
132
+ .replace('{{sonarJobHeader}}', () => header)
133
+ .replace('{{sonarJobSteps}}', () => steps)
134
+ .trimEnd();
135
+ }
136
+
137
+ /** The app source reader is be-relative; the authored manifest lives one directory above that side. */
138
+ function buildDeclaration(source) {
139
+ if (!source?.read) return {};
140
+ try {
141
+ return parseYaml(source.read('../.starcistacks/application-stacks.yaml')) ?? {};
142
+ } catch (error) {
143
+ if (error.code === 'ENOENT') return {};
144
+ throw error;
145
+ }
146
+ }
147
+
148
+ /** Static CI values are strings with valid environment names; expressions cannot turn public config into runtime secret reads. */
149
+ function mergeBuildValues(values, entries) {
150
+ for (const [key, value] of Object.entries(entries ?? {})) {
151
+ if (!/^[A-Z][A-Z0-9_]*$/u.test(key) || typeof value !== 'string' || value.includes('${{')) {
152
+ throw refusal('STACK_BUILD_ARG_INVALID', 'CI build values must map environment names to literal strings');
153
+ }
154
+ if (values.has(key) && values.get(key) !== value) {
155
+ throw refusal('STACK_BUILD_ARG_INVALID', 'CI build values disagree for ' + key);
156
+ }
157
+ values.set(key, value);
158
+ }
159
+ }
160
+
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
+ const declaration = buildDeclaration(source);
164
+ const environments = Object.keys(source?.stack?.imageEnvironments ?? {});
165
+ 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);
169
+ 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');
173
+ }
174
+
175
+ /** 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' } = {}) {
177
+ const configuredSonar = quality === undefined ? { token: 'SONAR_TOKEN', hostVar: 'SONAR_HOST_URL' } : quality.sonar;
178
+ const sonar = configuredSonar?.wiring === 'not-used' || configuredSonar?.mode === 'disabled' ? null : configuredSonar;
179
+ const codecov = quality?.codecov ?? { enabled: true, auth: 'oidc' };
180
+ const audit = quality?.audit ?? { level: 'high', omitDev: true };
181
+ const lcovArtifact = 'coverage-lcov';
7
182
  return {
183
+ ciBuildEnv: buildEnv(source),
8
184
  ciLint: exampleCommand(source, 'npm run lint'),
9
185
  ciLintSonar: exampleCommand(source, 'npm run lint -- --sonar reports/lint.sonar.json'),
10
186
  ciCoverageProof: exampleCommand(source, 'npx starci app check --coverage-from ' + lcovReport),
11
187
  ciDatabaseLint: exampleCommand(source, 'npm run db:lint'),
188
+ auditCommand: `npm audit${audit.omitDev ? ' --omit=dev' : ''} --audit-level=${audit.level}`,
189
+ sonarTokenSecret: sonar?.token ?? 'SONAR_TOKEN',
190
+ sonarHostVar: sonar?.hostVar ?? 'SONAR_HOST_URL',
191
+ sonarEnv: sonar ? ` env:\n SONAR_TOKEN: \${{ secrets.${sonar.token} }}` : '',
192
+ 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
+ ]),
200
+ ...(sonar ? [
201
+ ' - name: lint with Sonar import',
202
+ " if: env.SONAR_TOKEN != ''",
203
+ ` run: ${exampleCommand(source, 'npm run lint -- --sonar reports/lint.sonar.json')}`,
204
+ ' env:',
205
+ ' NODE_OPTIONS: --max-old-space-size=6144',
206
+ ` - uses: ${UPLOAD_ARTIFACT}`,
207
+ " if: always() && env.SONAR_TOKEN != '' && hashFiles('reports/lint.sonar.json') != ''",
208
+ ' with:',
209
+ ' name: sonar-lint',
210
+ ' path: reports/lint.sonar.json',
211
+ ] : []),
212
+ ].join('\n'),
213
+ codecovSteps: codecovSteps(lcovReport, codecov),
214
+ sonarJob: sonarJob(sonar, lcovReport, lcovArtifact, nodeMajor),
215
+ lcovArtifact,
12
216
  };
13
217
  }
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,6 +31,8 @@ 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;
@@ -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,23 @@ 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 spacing = target.markdown ? '\n' : '';
383
+ const managed = target.mode === 'block' ? `${markers.begin}\n${spacing}${trailingNewline(body)}${spacing}${markers.end}\n` : body;
384
+ return { path: where, scope, mode: target.mode, markdown: target.markdown, markers, content: managed, hash: hashOf(managed) };
369
385
  });
370
386
  });
371
387
  }
372
388
 
373
389
  // The part of a file sync manages: all of it, the marked block, or the scripts block as text.
374
- function managedPart(mode, text) {
390
+ function managedPart(mode, text, markers = { begin: BLOCK_BEGIN, end: BLOCK_END }) {
375
391
  if (mode === 'file') return text;
376
392
  if (mode === 'scripts') {
377
393
  const scripts = parseScripts(text);
378
394
  return scripts ? scriptsText(scripts) : null;
379
395
  }
380
- const start = text.indexOf(BLOCK_BEGIN);
381
- const end = text.indexOf(BLOCK_END);
396
+ const start = text.indexOf(markers.begin);
397
+ const end = text.indexOf(markers.end);
382
398
  if (start < 0 || end < start) return null;
383
399
  const stop = text.indexOf('\n', end);
384
400
  return text.slice(start, stop < 0 ? text.length : stop + 1);
@@ -397,7 +413,7 @@ export function checkTargets(root, targets) {
397
413
  return targets.map(target => {
398
414
  const file = path.join(root, target.path);
399
415
  if (!fs.existsSync(file)) return { path: target.path, status: 'missing', expectedHash: target.hash };
400
- const part = managedPart(target.mode, lf(fs.readFileSync(file, 'utf8')));
416
+ const part = managedPart(target.mode, lf(fs.readFileSync(file, 'utf8')), target.markers);
401
417
  if (part === null) return { path: target.path, status: 'missing', expectedHash: target.hash };
402
418
  const actualHash = hashOf(part);
403
419
  if (actualHash === target.hash) return { path: target.path, status: 'ok', expectedHash: target.hash, actualHash };
@@ -405,6 +421,14 @@ export function checkTargets(root, targets) {
405
421
  });
406
422
  }
407
423
 
424
+ function insertAfterTitle(existing, content) {
425
+ const title = /^# [^\n]*(?:\n|$)/m.exec(existing);
426
+ if (!title) return `${content}\n${existing}`;
427
+ const end = title.index + title[0].length;
428
+ const heading = trailingNewline(existing.slice(0, end));
429
+ return `${heading}\n${content}${existing.slice(end)}`;
430
+ }
431
+
408
432
  /** Rewrite the missing or drifted targets; returns the paths written. */
409
433
  function writtenContent(target, existing) {
410
434
  if (target.mode === 'scripts') {
@@ -415,8 +439,10 @@ function writtenContent(target, existing) {
415
439
  return `${JSON.stringify({ ...pkg, scripts: target.scripts }, null, 2)}\n`;
416
440
  }
417
441
  if (target.mode === 'block' && existing) {
418
- const current = managedPart('block', existing);
419
- return current === null ? `${target.content}\n${existing}` : existing.replace(current, () => target.content);
442
+ const current = managedPart('block', existing, target.markers);
443
+ if (current !== null) return existing.replace(current, () => target.content);
444
+ if (target.markdown) return insertAfterTitle(existing, target.content);
445
+ return `${target.content}\n${existing}`;
420
446
  }
421
447
  return target.content;
422
448
  }
@@ -454,7 +480,7 @@ export function loadHfs(root, manifest = loadSlotManifest()) {
454
480
  export async function renderRepo(root, { presets, parseYaml, manifest = loadSlotManifest() } = {}) {
455
481
  const hfs = loadHfs(root, manifest);
456
482
  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 }) } }) };
483
+ return { hfs, targets: renderTargets(hfs, presets ?? await loadPresets(root), { sonarKey, manifest, source: appSource(root, { parseYaml }) }) };
458
484
  }
459
485
 
460
486
  /** `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
+ }
@@ -51,8 +51,15 @@ function declaredPassEnv(declaration) {
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,51 @@ concurrency:
15
15
  cancel-in-progress: false
16
16
 
17
17
  jobs:
18
- ci:
18
+ fast:
19
+ timeout-minutes: 20
19
20
  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 }}
21
+ {{sonarEnv}}
26
22
  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
23
+ {{> app/ci-workflows/install-steps.yml}}
24
+ {{> app/ci-workflows/lint-base.yml}}
33
25
  - name: production dependency audit
34
- run: npm audit --omit=dev --audit-level=high
35
- - name: lint
36
- run: {{ciLintSonar}}
26
+ run: {{auditCommand}}
27
+ {{ciLintSteps}}
37
28
  - name: format
38
29
  run: npm run format:check
39
30
  - name: typecheck
40
31
  run: npm run typecheck
32
+
33
+ test:
34
+ timeout-minutes: 45
35
+ runs-on: ubuntu-latest
36
+ # Codecov OIDC authentication requires a job-scoped identity token.
37
+ permissions:
38
+ contents: read
39
+ id-token: write
40
+ steps:
41
+ {{> app/ci-workflows/install-steps.yml}}
42
+ {{> app/ci-workflows/jest-cache.yml}}
41
43
  - name: unit
42
- run: npm test -- --ci
44
+ run: npm test -- --ci --forceExit --cacheDirectory .jest-cache
43
45
  - name: coverage proof
44
46
  run: {{ciCoverageProof}}
45
- - name: coverage upload
46
- uses: codecov/codecov-action@0fb7174895f61a3b6b78fc075e0cd60383518dac # v5.5.5
47
- if: ${{ !cancelled() && startsWith(github.ref, 'refs/tags/v') }}
47
+ - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
48
+ if: always() && hashFiles('{{lcovReport}}') != ''
48
49
  with:
49
- use_oidc: true
50
- files: {{lcovReport}}
51
- disable_search: true
52
- fail_ci_if_error: true
50
+ name: {{lcovArtifact}}
51
+ path: {{lcovReport}}
52
+ {{codecovSteps}}
53
+
54
+ build:
55
+ timeout-minutes: 30
56
+ runs-on: ubuntu-latest
57
+ steps:
58
+ {{> app/ci-workflows/install-steps.yml}}
53
59
  - name: build be
54
60
  run: npm run build:be
55
61
  - name: build fe
56
62
  run: npm run build:fe
57
- {{> app/ci-workflows/sonar-steps.yml}}
63
+ {{ciBuildEnv}}
64
+
65
+ {{sonarJob}}
@@ -10,6 +10,7 @@ permissions:
10
10
 
11
11
  jobs:
12
12
  e2e:
13
+ timeout-minutes: 45
13
14
  runs-on: ubuntu-latest
14
15
  steps:
15
16
  - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0