@starci/hfs 4.5.0 → 4.6.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 (39) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/ci/cli.mjs +6 -4
  3. package/ci/vars.mjs +22 -5
  4. package/ci/verify.mjs +16 -2
  5. package/lint/run.mjs +3 -4
  6. package/package.json +1 -1
  7. package/release/ci-red.mjs +44 -12
  8. package/release/gate.mjs +50 -19
  9. package/release/push.mjs +1 -1
  10. package/runtime/knowledge/hfs/canon-pins.yaml +5 -5
  11. package/runtime/knowledge/sonar-gate.yaml +2 -2
  12. package/runtime/modules/schemas/application-stacks.schema.yaml +35 -0
  13. package/runtime/packages/cli/src/catalog.generated.mjs +9 -7
  14. package/runtime/scripts/hfs/ci-declaration.mjs +12 -3
  15. package/runtime/scripts/hfs/rules/secrets.mjs +8 -10
  16. package/runtime/scripts/lib/secret-patterns.mjs +77 -0
  17. package/scaffold/app.mjs +2 -2
  18. package/sync/ci-commands.mjs +154 -14
  19. package/sync/hygiene.mjs +4 -1
  20. package/sync/index.mjs +16 -12
  21. package/sync/quality-services.mjs +7 -5
  22. package/sync/readme-badges.mjs +18 -2
  23. package/sync/stack-images.mjs +5 -0
  24. package/templates/app/ci-workflows/github/workflows/ci.yml +9 -3
  25. package/templates/app/ci-workflows/github/workflows/e2e.yml +1 -0
  26. package/templates/app/ci-workflows/github/workflows/images.yml +1 -0
  27. package/templates/app/ci-workflows/install-steps.yml +2 -0
  28. package/templates/app/ci-workflows/jest-cache.yml +8 -0
  29. package/templates/app/ci-workflows/lint-base.yml +16 -0
  30. package/templates/app/ci-workflows-lite/github/workflows/ci.yml +10 -4
  31. package/templates/app/ci-workflows-lite/github/workflows/db-deploy.yml +1 -0
  32. package/templates/app/ci-workflows-lite/github/workflows/images.yml +1 -0
  33. package/templates/app/gitignore +2 -0
  34. package/templates/app/gitignore-lite +2 -0
  35. package/templates/app/skeleton/.github/dependabot.yml +1 -2
  36. package/templates/app/skeleton/.starcistacks/application-stacks.yaml +1 -0
  37. package/templates/app/skeleton-lite/.github/dependabot.yml +1 -2
  38. package/templates/app/skeleton-lite/.starcistacks/application-stacks.yaml +1 -0
  39. package/templates/app/upgrade-full/.starcistacks/application-stacks.yaml +1 -0
@@ -7,7 +7,7 @@
7
7
  // A finding names the file, the line and the pattern, never the value.
8
8
  import fs from 'node:fs';
9
9
  import path from 'node:path';
10
- import { FORBIDDEN_FILES, secretHits } from '../../lib/secret-patterns.mjs';
10
+ import { FORBIDDEN_FILES, secretHitsInFile } from '../../lib/secret-patterns.mjs';
11
11
  import { isSopsEnvelope } from '../../lib/sops-envelope.mjs';
12
12
  import { found, readText } from './read.mjs';
13
13
 
@@ -34,20 +34,17 @@ export function untrackedSecretFindings({ repoRoot, files, repo }) {
34
34
  * no sops envelope, or a line that matches a secret pattern. `text` is the file's text, or null when it cannot be read (binary, too
35
35
  * large, absent). The whole-tree check reads the work tree and the commit hook (`starci app hygiene`) reads the staged blob: one judgement.
36
36
  */
37
- export function secretFileFindings({ file, text }) {
37
+ export function secretFileFindings({ file, text, manifestText }) {
38
38
  const being = FORBIDDEN_FILES.find((rule) => rule.test(file));
39
39
  if (being) return [found(PLAINTEXT_SECRET, file, `${file} is a plaintext secret by being (${being.name}); a secret is committed only as a sops envelope at .starcistacks/<env>/secrets/<slug>.enc`, { pattern: being.name })];
40
40
  if (text === null || text === undefined || NOT_SCANNED.test(file)) return [];
41
41
  if (file.endsWith('.enc')) return isSopsEnvelope(text) ? [] : [found(PLAINTEXT_SECRET, file, `${file} is named like a sealed secret but is not a sops envelope; encrypt the value with sops and commit only the envelope`, { pattern: 'sops-not-envelope' })];
42
42
  const findings = [];
43
- const lines = text.split(/\r?\n/);
44
43
  const seen = new Set();
45
- for (const [index, line] of lines.entries()) {
46
- for (const pattern of secretHits(file, line)) {
47
- if (seen.has(pattern)) continue;
48
- seen.add(pattern);
49
- findings.push(found(PLAINTEXT_SECRET, file, `${file}:${index + 1} holds a plaintext secret (${pattern}); seal it with sops at .starcistacks/<env>/secrets/<slug>.enc and read it by *_FILE`, { line: index + 1, pattern }));
50
- }
44
+ for (const { line, pattern } of secretHitsInFile(file, text, { manifestText })) {
45
+ if (seen.has(pattern)) continue;
46
+ seen.add(pattern);
47
+ findings.push(found(PLAINTEXT_SECRET, file, `${file}:${line} holds a plaintext secret (${pattern}); seal it with sops at .starcistacks/<env>/secrets/<slug>.enc and read it by *_FILE`, { line, pattern }));
51
48
  }
52
49
  return findings;
53
50
  }
@@ -62,7 +59,8 @@ export function secretFindings({ repoRoot, files, resolver }) {
62
59
  findings.push(found(PLAINTEXT_SECRET, file, `${file} is a plaintext secret file tracked in ${slot.slot}${destination}`, { slot: slot.slot, goesTo: slot.goesTo }));
63
60
  continue;
64
61
  }
65
- findings.push(...secretFileFindings({ file, text: readText(repoRoot, file) }));
62
+ const manifestText = /(?:^|\/)README\.md$/.test(file) ? readText(repoRoot, path.posix.join(path.posix.dirname(file), '.starcistacks/application-stacks.yaml')) : undefined;
63
+ findings.push(...secretFileFindings({ file, text: readText(repoRoot, file), manifestText }));
66
64
  }
67
65
  return findings;
68
66
  }
@@ -1,3 +1,5 @@
1
+ import { parseYaml } from '../../engine/yaml.mjs';
2
+
1
3
  // secret-patterns.mjs — the one list of secret shapes the runtime refuses to carry: the push secret scan
2
4
  // (scripts/supervisor/push-mains.mjs scanDiff) refuses an outgoing range that adds one, and the typed-log
3
5
  // redaction (scripts/kernel/typed-logs.mjs redactText) blanks the value before a log row is stored.
@@ -102,3 +104,78 @@ export function secretHits(file, text) {
102
104
  }
103
105
  return names;
104
106
  }
107
+
108
+ const BADGE_BEGIN = '<!-- >>> starci app sync (managed block; do not edit) >>> -->';
109
+ const BADGE_END = '<!-- <<< starci app sync <<< -->';
110
+
111
+ /** Provider-shaped credentials remain refused even where the keyword heuristic admits public badge tokens. */
112
+ function blankBadgeValue(value) {
113
+ return secretHits('badge.mjs', value).length ? value : ' '.repeat(value.length);
114
+ }
115
+
116
+ /** Only scalar values at the two block-mapping service paths qualify; every other YAML substring remains scanned. */
117
+ function manifestBadgeText(text) {
118
+ try { parseYaml(text); } catch { return text; }
119
+ const parents = [];
120
+ return text.split('\n').map(line => {
121
+ if (/^\s*(?:#.*)?$/.test(line)) return line;
122
+ const indent = /^ */.exec(line)[0].length;
123
+ while (parents.length && parents.at(-1).indent >= indent) parents.pop();
124
+ const match = /^( *)(?:([A-Za-z0-9_-]+)|"([A-Za-z0-9_-]+)"|'([A-Za-z0-9_-]+)'):(.*)$/.exec(line);
125
+ if (!match) { parents.push({ indent, key: null }); return line; }
126
+ match[5] = match[5].trimStart();
127
+ const key = match[2] ?? match[3] ?? match[4];
128
+ const keys = [...parents.map(parent => parent.key), key].join('.');
129
+ const container = /^(?:#.*)?$/.test(match[5].trim());
130
+ const nestedScalar = parents.some(parent => parent.container === false);
131
+ parents.push({ indent, key, container });
132
+ if (nestedScalar) return line;
133
+ if (!['services.codecov.badgeToken', 'services.sonar.badgeToken'].includes(keys)) return line;
134
+ const scalar = /^(?:"([A-Za-z0-9_-]{6,128})"|'([A-Za-z0-9_-]{6,128})'|([A-Za-z0-9_-]{6,128}))(?=\s*(?:#|$))/.exec(match[5]);
135
+ if (!scalar) return line;
136
+ const start = line.length - match[5].length;
137
+ return line.slice(0, start) + blankBadgeValue(scalar[0]) + match[5].slice(scalar[0].length);
138
+ }).join('\n');
139
+ }
140
+
141
+ /** Unreadable manifests admit only the default badge hosts; readable Sonar declarations select their own hosts. */
142
+ function badgeHosts(manifestText) {
143
+ const hosts = new Set(['codecov.io']);
144
+ let doc;
145
+ try { doc = parseYaml(manifestText); } catch { hosts.add('sonarcloud.io'); return hosts; }
146
+ const sonar = doc?.services?.sonar;
147
+ if (sonar?.provider === 'sonarcloud') hosts.add('sonarcloud.io');
148
+ else for (const host of [sonar?.host?.public, sonar?.host?.local]) {
149
+ try { hosts.add(new URL(host).host); } catch { /* Invalid hosts admit nothing. */ }
150
+ }
151
+ return hosts;
152
+ }
153
+
154
+ /** Only token query values in managed Markdown badge images on declared hosts are blanked; links and other parameters remain scanned. */
155
+ function readmeBadgeText(text, manifestText) {
156
+ const begin = text.indexOf(BADGE_BEGIN), end = text.indexOf(BADGE_END, begin + BADGE_BEGIN.length);
157
+ if (begin < 0 || end < 0) return text;
158
+ const hosts = badgeHosts(manifestText);
159
+ const start = begin + BADGE_BEGIN.length;
160
+ const block = text.slice(start, end).replace(/!\[[^\]\n]*\]\((https?:\/\/[^\s)]+)\)/g, (image, address) => {
161
+ let url;
162
+ try { url = new URL(address); } catch { return image; }
163
+ if (!hosts.has(url.host) || url.username || url.password) return image;
164
+ const badgePath = url.host === 'codecov.io' ? /\/graph\/badge\.svg$/.test(url.pathname) : url.pathname === '/api/project_badges/measure';
165
+ if (!badgePath) return image;
166
+ const fragment = address.indexOf('#');
167
+ const queryEnd = fragment < 0 ? address.length : fragment;
168
+ const safe = address.slice(0, queryEnd).replace(/([?&]token=)([^&#)\s]*)/g, (_, prefix, value) => prefix + blankBadgeValue(value));
169
+ return image.replace(address, () => safe + address.slice(queryEnd));
170
+ });
171
+ return text.slice(0, start) + block + text.slice(end);
172
+ }
173
+
174
+ /** Whole-file secret judgement exempts only declared public badge values and preserves original line numbers and every provider shape. */
175
+ export function secretHitsInFile(file, fullText, { manifestText } = {}) {
176
+ const normalized = file.replace(/\\/g, '/');
177
+ let text = fullText;
178
+ if (/(?:^|\/)\.starcistacks\/application-stacks\.yaml$/.test(normalized)) text = manifestBadgeText(text);
179
+ if (/(?:^|\/)README\.md$/.test(normalized)) text = readmeBadgeText(text, manifestText);
180
+ return text.split(/\r?\n/).flatMap((line, index) => secretHits(file, line).map(pattern => ({ line: index + 1, pattern })));
181
+ }
package/scaffold/app.mjs CHANGED
@@ -222,7 +222,7 @@ const FE_PACKAGES = Object.freeze({
222
222
  const PACKAGE_WORKSPACE_SCRIPTS = Object.freeze({ build: 'tsc -p tsconfig.build.json', lint: WORKSPACE_LINT, typecheck: 'tsc --noEmit -p tsconfig.json' });
223
223
 
224
224
  /** The app hfs.json of a new app called `name`; full remains the byte-for-byte default declaration. */
225
- const starterDeclaration = (name, manifest = loadSlotManifest(), edition = 'full') => ({
225
+ export const starterDeclaration = (name, manifest = loadSlotManifest(), edition = 'full') => ({
226
226
  hfs: manifest.major,
227
227
  kind: 'app',
228
228
  ...(edition === 'full' ? {} : { edition }),
@@ -493,7 +493,7 @@ export function scaffoldWorkSeeds(project) {
493
493
  * be side (once per product app of the fe side, coreFeApps), the be `apps/cli/` folder once when the cli app is declared, an fe `apps/<name>/` folder
494
494
  * once for the declared fe app <name> (none other), `__project__` in a path replaced by the project name.
495
495
  */
496
- function skeletonOf(scope, app, vars) {
496
+ export function skeletonOf(scope, app, vars) {
497
497
  const files = app.edition === 'lite'
498
498
  ? [...skeletonDirectory(scope, 'skeleton', app, vars, rel => liteBaseFile(scope, rel)), ...skeletonDirectory(scope, 'skeleton-lite', app, vars, rel => liteOverlayFile(scope, rel))]
499
499
  : skeletonDirectory(scope, 'skeleton', app, vars);
@@ -1,4 +1,7 @@
1
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';
2
5
 
3
6
  // One command vocabulary for managed CI rendering and the runtime coverage gate.
4
7
  const exampleCommand = (source, command) => source?.runtimeExample
@@ -9,56 +12,116 @@ const UPLOAD_ARTIFACT = 'actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f
9
12
  const DOWNLOAD_ARTIFACT = 'actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0';
10
13
  const SONAR_CONDITION = "${{ !cancelled() && startsWith(github.ref, 'refs/tags/v') && env.SONAR_TOKEN != '' }}";
11
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
+
12
61
  /** Codecov reports missing coverage without failing twice; uploads retain the release-tag condition and required authentication. */
13
62
  function codecovSteps(lcovReport, codecov) {
14
63
  if (codecov.enabled === false || codecov.wiring === 'not-used') return [
15
64
  ' - name: coverage upload',
16
65
  ' run: echo "codecov disabled in .starcistacks/application-stacks.yaml"',
17
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;
18
69
  return [
19
70
  ' - name: coverage upload skipped',
20
71
  ` if: \${{ !cancelled() && hashFiles('${lcovReport}') == '' }}`,
21
72
  ' run: |',
22
73
  ` echo "::notice::codecov upload skipped: no coverage file at ${lcovReport} (the test step failed or produced none)"`,
23
74
  ' exit 0',
75
+ ...codecovProbe(codecov, condition),
24
76
  ' - name: coverage upload',
77
+ ...(codecov.optional ? [' id: codecov-upload', ' continue-on-error: true'] : []),
25
78
  ' uses: codecov/codecov-action@0fb7174895f61a3b6b78fc075e0cd60383518dac # v5.5.5',
26
- ` if: \${{ !cancelled() && startsWith(github.ref, 'refs/tags/v') && hashFiles('${lcovReport}') != '' }}`,
79
+ ` if: ${uploadCondition}`,
27
80
  ' with:',
28
81
  codecov.auth === 'token' ? ` token: \${{ secrets.${codecov.token} }}` : ' use_oidc: true',
29
82
  ` files: ${lcovReport}`,
30
83
  ' disable_search: true',
31
- ' fail_ci_if_error: 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
+ ] : []),
32
91
  ].join('\n');
33
92
  }
34
93
 
35
94
  /** Sonar consumes the test coverage and fast lint artifacts only on release tags with a configured token. */
36
95
  function sonarJob(sonar, lcovReport, lcovArtifact, nodeMajor) {
37
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} }}`;
38
99
  const header = [
39
100
  ' sonar:',
40
101
  ' needs: [fast, test]',
102
+ ' timeout-minutes: 20',
41
103
  ' runs-on: ubuntu-latest',
42
104
  ' env:',
43
105
  ` SONAR_TOKEN: \${{ secrets.${sonar.token} }}`,
44
- ` SONAR_HOST_URL: \${{ vars.${sonar.hostVar} }}`,
106
+ ` SONAR_HOST_URL: ${host}`,
45
107
  ' steps:',
46
108
  ].join('\n');
47
109
  const steps = [
110
+ ...sonarProbe(sonar),
48
111
  ` - uses: ${DOWNLOAD_ARTIFACT}`,
49
- ` if: ${SONAR_CONDITION}`,
112
+ ` if: ${condition}`,
50
113
  ' with:',
51
114
  ` name: ${lcovArtifact}`,
52
115
  ` path: ${lcovReport.split('/').slice(0, -1).join('/') || '.'}`,
53
116
  ` - uses: ${DOWNLOAD_ARTIFACT}`,
54
- ` if: ${SONAR_CONDITION}`,
117
+ ` if: ${condition}`,
55
118
  ' with:',
56
119
  ' name: sonar-lint',
57
120
  ' path: reports',
58
121
  ' - uses: SonarSource/sonarqube-scan-action@c7ee0f9df90b7aa20e8dcf9695dcfe2e7da5b4f2 # v7.2.1',
59
- ` if: ${SONAR_CONDITION}`,
122
+ ` if: ${condition}`,
60
123
  ' - uses: SonarSource/sonarqube-quality-gate-action@7a5fffe8e523c40e0c740b6bc2712ab503e52efa # v1.2.1',
61
- ` if: ${SONAR_CONDITION}`,
124
+ ` if: ${condition}`,
62
125
  ' timeout-minutes: 10',
63
126
  ].join('\n');
64
127
  // Variable strings are inserted after render() expands partials, so resolve Sonar's shared install partial before insertion.
@@ -68,17 +131,91 @@ function sonarJob(sonar, lcovReport, lcovArtifact, nodeMajor) {
68
131
  .replaceAll('{{nodeMajor}}', nodeMajor)
69
132
  .replace('{{sonarJobHeader}}', () => header)
70
133
  .replace('{{sonarJobSteps}}', () => steps)
71
- .trimEnd();
134
+ .trimEnd() + '\n';
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
+ /** Image values own their keys; the build-fe gate supplies only missing keys, and sealed members stay unopened. */
162
+ function imageBuildEnv(image, gates) {
163
+ const values = new Map();
164
+ mergeBuildValues(values, image?.buildArgs?.values);
165
+ const fallback = new Map();
166
+ for (const gate of gates.filter(entry => entry.name === 'build-fe')) mergeBuildValues(fallback, gate.env);
167
+ for (const [key, value] of fallback) if (!values.has(key)) values.set(key, value);
168
+ if (!values.size) return '';
169
+ return [' env:', ...[...values.keys()].sort(byCodeUnit).map(key => ' ' + key + ': ' + JSON.stringify(values.get(key)))].join('\n');
170
+ }
171
+
172
+ /** 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. */
173
+ function frontendBuild(source, app) {
174
+ const declaration = buildDeclaration(source);
175
+ const environments = Object.keys(source?.stack?.imageEnvironments ?? {});
176
+ const environment = environments.length > 1 ? 'production' : environments[0];
177
+ const images = Object.values(declaration.environments?.[environment]?.deploy?.images ?? {});
178
+ const frontend = images.filter(image => app?.sides.fe.apps.some(entry => entry.name === image.app));
179
+ const gates = declaration.release?.gates ?? [];
180
+ const singleEnv = imageBuildEnv(frontend[0], gates);
181
+ if (frontend.length <= 1) return { env: singleEnv, steps: [' - name: build fe', ' run: npm run build:fe', singleEnv].filter(Boolean).join('\n') };
182
+ const steps = frontend.map(image => [
183
+ ' - name: build fe ' + image.app,
184
+ ' run: npm run codegen --silent && npx --no-install turbo run build --filter=./fe/apps/' + image.app,
185
+ imageBuildEnv(image, gates),
186
+ ].filter(Boolean).join('\n')).join('\n');
187
+ return { env: singleEnv, steps };
188
+ }
189
+
190
+ /** Side lint reports join into the single Sonar artifact even when one side could not write its report. */
191
+ function sonarMergeSteps() {
192
+ return [
193
+ ' - name: merge Sonar lint reports',
194
+ " if: always() && env.SONAR_TOKEN != ''",
195
+ ' run: |',
196
+ " node --input-type=module <<'NODE'",
197
+ " import fs from 'node:fs';",
198
+ " const files = ['reports/lint.be.sonar.json', 'reports/lint.fe.sonar.json'];",
199
+ " const reports = files.filter(file => fs.existsSync(file)).map(file => JSON.parse(fs.readFileSync(file, 'utf8')));",
200
+ ' const rules = [...new Map(reports.flatMap(report => report.rules).map(rule => [rule.id, rule])).values()];',
201
+ ' const issues = reports.flatMap(report => report.issues);',
202
+ " fs.mkdirSync('reports', { recursive: true });",
203
+ " fs.writeFileSync('reports/lint.sonar.json', JSON.stringify({ rules, issues }, null, 2) + '\\n');",
204
+ ' NODE',
205
+ ];
72
206
  }
73
207
 
74
208
  /** An omitted quality declaration preserves hosted Sonar, required OIDC Codecov, and the high production dependency audit. */
75
- export function ciWorkflowCommands(source, lcovReport = 'be/coverage/lcov.info', quality = undefined, { nodeMajor = '22' } = {}) {
209
+ export function ciWorkflowCommands(source, lcovReport = 'be/coverage/lcov.info', quality = undefined, { nodeMajor = '22', app } = {}) {
76
210
  const configuredSonar = quality === undefined ? { token: 'SONAR_TOKEN', hostVar: 'SONAR_HOST_URL' } : quality.sonar;
77
211
  const sonar = configuredSonar?.wiring === 'not-used' || configuredSonar?.mode === 'disabled' ? null : configuredSonar;
78
212
  const codecov = quality?.codecov ?? { enabled: true, auth: 'oidc' };
79
213
  const audit = quality?.audit ?? { level: 'high', omitDev: true };
80
214
  const lcovArtifact = 'coverage-lcov';
215
+ const build = frontendBuild(source, app);
81
216
  return {
217
+ ciBuildEnv: build.env,
218
+ ciBuildSteps: build.steps,
82
219
  ciLint: exampleCommand(source, 'npm run lint'),
83
220
  ciLintSonar: exampleCommand(source, 'npm run lint -- --sonar reports/lint.sonar.json'),
84
221
  ciCoverageProof: exampleCommand(source, 'npx starci app check --coverage-from ' + lcovReport),
@@ -96,11 +233,14 @@ export function ciWorkflowCommands(source, lcovReport = 'be/coverage/lcov.info',
96
233
  ' NODE_OPTIONS: --max-old-space-size=6144',
97
234
  ]),
98
235
  ...(sonar ? [
99
- ' - name: lint with Sonar import',
100
- " if: env.SONAR_TOKEN != ''",
101
- ` run: ${exampleCommand(source, 'npm run lint -- --sonar reports/lint.sonar.json')}`,
102
- ' env:',
103
- ' NODE_OPTIONS: --max-old-space-size=6144',
236
+ ...['be', 'fe'].flatMap((side) => [
237
+ ' - name: lint ' + side + ' with Sonar import',
238
+ " if: env.SONAR_TOKEN != ''",
239
+ ` run: ${exampleCommand(source, 'npm run lint -- --side ' + side + ' --sonar reports/lint.' + side + '.sonar.json')}`,
240
+ ' env:',
241
+ ' NODE_OPTIONS: --max-old-space-size=6144',
242
+ ]),
243
+ ...sonarMergeSteps(),
104
244
  ` - uses: ${UPLOAD_ARTIFACT}`,
105
245
  " if: always() && env.SONAR_TOKEN != '' && hashFiles('reports/lint.sonar.json') != ''",
106
246
  ' with:',
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);
@@ -379,7 +380,8 @@ export function renderTargets(hfs, presets, { sonarKey, readTemplate = readBundl
379
380
  return { path: where, scope, mode: 'scripts', content, hash: hashOf(content), scripts };
380
381
  }
381
382
  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
+ const spacing = target.markdown ? '\n' : '';
384
+ const managed = target.mode === 'block' ? `${markers.begin}\n${spacing}${trailingNewline(body)}${spacing}${markers.end}\n` : body;
383
385
  return { path: where, scope, mode: target.mode, markdown: target.markdown, markers, content: managed, hash: hashOf(managed) };
384
386
  });
385
387
  });
@@ -428,8 +430,8 @@ function insertAfterTitle(existing, content) {
428
430
  return `${heading}\n${content}${existing.slice(end)}`;
429
431
  }
430
432
 
431
- /** Rewrite the missing or drifted targets; returns the paths written. */
432
- 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) {
433
435
  if (target.mode === 'scripts') {
434
436
  let pkg = {};
435
437
  if (existing) {
@@ -476,10 +478,10 @@ export function loadHfs(root, manifest = loadSlotManifest()) {
476
478
  }
477
479
 
478
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. */
479
- export async function renderRepo(root, { presets, parseYaml, manifest = loadSlotManifest() } = {}) {
481
+ export async function renderRepo(root, { presets, parseYaml, notices, manifest = loadSlotManifest() } = {}) {
480
482
  const hfs = loadHfs(root, manifest);
481
483
  const sonarKey = await readDeclaredSonarKey(root, { parseYaml, fail: message => { throw new SyncError('HFS_SYNC_SONAR_KEY', message); } });
482
- 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 }) }) };
483
485
  }
484
486
 
485
487
  /** `starci app sync --check | --write [--cwd <dir>]` at the app root; returns 0 clean, 1 drift, 2 usage. */
@@ -515,7 +517,9 @@ export async function runSync(argv, { cwd = process.cwd(), out = line => process
515
517
  const { write, cwdAt } = options;
516
518
  const root = cwdAt >= 0 ? path.resolve(cwd, argv[cwdAt + 1]) : cwd;
517
519
  try {
518
- 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);
519
523
  if (write) {
520
524
  const written = writeTargets(root, targets);
521
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
 
@@ -16,10 +16,12 @@ concurrency:
16
16
 
17
17
  jobs:
18
18
  fast:
19
+ timeout-minutes: 20
19
20
  runs-on: ubuntu-latest
20
21
  {{sonarEnv}}
21
22
  steps:
22
23
  {{> app/ci-workflows/install-steps.yml}}
24
+ {{> app/ci-workflows/lint-base.yml}}
23
25
  - name: production dependency audit
24
26
  run: {{auditCommand}}
25
27
  {{ciLintSteps}}
@@ -29,6 +31,7 @@ jobs:
29
31
  run: npm run typecheck
30
32
 
31
33
  test:
34
+ timeout-minutes: 45
32
35
  runs-on: ubuntu-latest
33
36
  # Codecov OIDC authentication requires a job-scoped identity token.
34
37
  permissions:
@@ -36,10 +39,13 @@ jobs:
36
39
  id-token: write
37
40
  steps:
38
41
  {{> app/ci-workflows/install-steps.yml}}
42
+ {{> app/ci-workflows/jest-cache.yml}}
39
43
  - name: unit
40
- run: npm test -- --ci
44
+ run: npm test -- --ci --forceExit --cacheDirectory .jest-cache
41
45
  - name: coverage proof
42
46
  run: {{ciCoverageProof}}
47
+ env:
48
+ NODE_OPTIONS: --max-old-space-size=6144
43
49
  - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
44
50
  if: always() && hashFiles('{{lcovReport}}') != ''
45
51
  with:
@@ -48,12 +54,12 @@ jobs:
48
54
  {{codecovSteps}}
49
55
 
50
56
  build:
57
+ timeout-minutes: 30
51
58
  runs-on: ubuntu-latest
52
59
  steps:
53
60
  {{> app/ci-workflows/install-steps.yml}}
54
61
  - name: build be
55
62
  run: npm run build:be
56
- - name: build fe
57
- run: npm run build:fe
63
+ {{ciBuildSteps}}
58
64
 
59
65
  {{sonarJob}}