@starci/hfs 4.5.1 → 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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,10 @@
1
1
  # Changelog
2
2
 
3
+ ## 4.6.0 - 2026-10-18
4
+ - Added: `services.codecov|sonar.badgeToken` README badge tokens and `ci.github.visibility` with a private-repository notice (sync, ci verify); `ci.vars` `ownerValue: true` (var sync skips, var set reads stdin, verify checks the name).
5
+ - Fixed: the app release gate counts tag-push CI runs reachable from origin/main, main or HEAD; the secret scans exempt only declared badge tokens.
6
+ - Fixed: empty placeholder lines vanish so every managed file is prettier-stable; CI coverage proof heap, per-side Sonar lint reports merged for the import, per-fe-app build env; `app lint --side` combines with `--sonar`.
7
+
3
8
  ## 4.5.1 - 2026-10-18
4
9
  - Fixed: managed CI base refs, public build values, caching and timeouts; optional quality service handling, README formatting and Supabase migration-history parsing.
5
10
 
package/ci/cli.mjs CHANGED
@@ -5,7 +5,7 @@ import { readCiDeclaration, sealedKeyNames } from '../runtime/scripts/hfs/ci-dec
5
5
  import { github, refuse } from './github.mjs';
6
6
  import { setSecret } from './secret.mjs';
7
7
  import { setVariable, syncVariables } from './vars.mjs';
8
- import { verifyCi } from './verify.mjs';
8
+ import { badgeVisibilityNotices, verifyCi } from './verify.mjs';
9
9
 
10
10
  const COMMANDS = {
11
11
  'secret set': ['env', 'dependabot', 'repo'],
@@ -46,16 +46,18 @@ function parse(argv) {
46
46
  export async function ciMain(argv, { cwd = process.cwd(), stdout = (s) => process.stdout.write(s), stderr = (s) => process.stderr.write(s), stdin, env = process.env, tools = {} } = {}) {
47
47
  try {
48
48
  const parsed = parse(argv);
49
- let declaration, sealed;
49
+ let doc, declaration, sealed;
50
50
  try {
51
- declaration = readCiDeclaration(parseYaml(fs.readFileSync(path.join(cwd, '.starcistacks/application-stacks.yaml'), 'utf8')));
51
+ doc = parseYaml(fs.readFileSync(path.join(cwd, '.starcistacks/application-stacks.yaml'), 'utf8'));
52
+ declaration = readCiDeclaration(doc);
52
53
  sealed = sealedKeyNames(cwd, { fs });
53
54
  } catch { throw refuse('CI_DECLARATION_INVALID', 'the manifest or sealed key names could not be read'); }
54
55
  if (declaration.problems.length) throw refuse('CI_DECLARATION_INVALID', 'correct the CI declaration before using GitHub');
55
56
  const context = { ...parsed, declaration, sealed, gh: github({ cwd, env, spawn: tools.spawn }), stdin, stdout };
56
57
  if (parsed.command === 'secret set') return await setSecret(context);
57
- if (parsed.command === 'var set') return setVariable(context);
58
+ if (parsed.command === 'var set') return await setVariable(context);
58
59
  if (parsed.command === 'var sync') return syncVariables(context);
60
+ context.notices = badgeVisibilityNotices({ doc, gh: context.gh, repo: context.gh.repository(parsed.options.repo) });
59
61
  return verifyCi(context);
60
62
  } catch (error) {
61
63
  stderr(`${error.code ? error.message : 'CI_USAGE: CI command could not complete'}\n`);
package/ci/vars.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import fs from 'node:fs';
1
2
  import { refuse, scopeArgs, scopeName } from './github.mjs';
2
3
  import { declaredEntry } from './secret.mjs';
3
4
 
@@ -15,18 +16,34 @@ function writeVariable(gh, repo, entry, value, options, stdout) {
15
16
  stdout(`set variable ${entry.name} (${scopeName(options)})\n`);
16
17
  }
17
18
 
18
- export function setVariable({ declaration, gh, options, name, stdout }) {
19
- const entry = declaredEntry(declaration.vars, name, options), value = valueOf(entry, options.value);
19
+ /** Owner-supplied input is read once from stdin, with one trailing newline removed and no value echoed. */
20
+ async function ownerValueOf(options, stdin) {
21
+ if (Object.hasOwn(options, 'value')) throw refuse('CI_VALUE_ARGV', 'an owner-supplied variable takes its value on stdin');
22
+ let value;
23
+ if (stdin === undefined) {
24
+ if (process.stdin.isTTY) throw refuse('CI_VALUE_EMPTY', 'an owner-supplied variable value is required on stdin');
25
+ value = fs.readFileSync(0, 'utf8');
26
+ } else value = typeof stdin === 'function' ? await stdin() : stdin;
27
+ value = String(value ?? '').replace(/\r?\n$/, '');
28
+ if (!value.length) throw refuse('CI_VALUE_EMPTY', 'an owner-supplied variable value is required on stdin');
29
+ return value;
30
+ }
31
+
32
+ /** Owner-supplied variables accept stdin only; other public variables retain manifest and argv agreement. */
33
+ export async function setVariable({ declaration, gh, options, name, stdin, stdout }) {
34
+ const entry = declaredEntry(declaration.vars, name, options);
35
+ const value = entry.ownerValue ? await ownerValueOf(options, stdin) : valueOf(entry, options.value);
20
36
  writeVariable(gh, gh.repository(options.repo), entry, value, options, stdout);
21
37
  return 0;
22
38
  }
23
39
 
24
40
  /** Sync validates the whole plan first and performs no GitHub access until execution is requested. */
25
41
  export function syncVariables({ declaration, gh, options, stdout }) {
26
- const plan = declaration.vars.map((entry) => ({ entry, value: valueOf(entry), options: { env: entry.scope === 'environment' ? entry.environment : undefined } }));
27
- const repo = options.execute && plan.length ? gh.repository(options.repo) : undefined;
42
+ const plan = declaration.vars.map((entry) => ({ entry, value: entry.ownerValue ? undefined : valueOf(entry), options: { env: entry.scope === 'environment' ? entry.environment : undefined } }));
43
+ const repo = options.execute && plan.some((item) => !item.entry.ownerValue) ? gh.repository(options.repo) : undefined;
28
44
  for (const item of plan) {
29
- if (options.execute) writeVariable(gh, repo, item.entry, item.value, item.options, stdout);
45
+ if (item.entry.ownerValue) stdout(`skip variable ${item.entry.name} (${scopeName(item.options)}): owner-supplied value; the owner runs starci app ci var set ${item.entry.name} [--env ENV] with the value on stdin\n`);
46
+ else if (options.execute) writeVariable(gh, repo, item.entry, item.value, item.options, stdout);
30
47
  else stdout(`would set variable ${item.entry.name} (${scopeName(item.options)})\n`);
31
48
  }
32
49
  return 0;
package/ci/verify.mjs CHANGED
@@ -1,7 +1,8 @@
1
+ import { badgeTokenNotices } from '../sync/readme-badges.mjs';
1
2
  import { refuse, scopeArgs, scopeName } from './github.mjs';
2
3
 
3
4
  /** Verification reports names and scopes only; values are compared in memory and excluded from findings. */
4
- export function verifyCi({ declaration, sealed, gh, options, stdout }) {
5
+ export function verifyCi({ declaration, sealed, gh, options, stdout, notices = [] }) {
5
6
  const repo = gh.repository(options.repo), stores = [];
6
7
  const environments = [...new Set([...declaration.secrets, ...declaration.vars].filter((item) => item.scope === 'environment').map((item) => item.environment))].sort();
7
8
  for (const scope of [{}, ...environments.map((env) => ({ env })), { dependabot: true }]) {
@@ -29,8 +30,21 @@ export function verifyCi({ declaration, sealed, gh, options, stdout }) {
29
30
  add(sameKind.some((entry) => entry.name === row.name) ? 'CI_SCOPE_MISMATCH' : store.kind === 'secret' ? 'CI_SECRET_UNDECLARED' : 'CI_VAR_UNDECLARED', row.name, store.scope);
30
31
  }
31
32
  }
32
- if (options.json) stdout(`${JSON.stringify({ ok: findings.length === 0, findings }, null, 2)}\n`);
33
+ if (options.json) stdout(`${JSON.stringify({ ok: findings.length === 0, findings, notices }, null, 2)}\n`);
33
34
  else if (!findings.length) stdout('CI verification passed\n');
34
35
  else for (const finding of findings) stdout(`${finding.code}: ${finding.name} (${finding.scope})\n`);
36
+ if (!options.json) for (const notice of notices) stdout(`${notice}\n`);
35
37
  return findings.length ? 1 : 0;
36
38
  }
39
+
40
+ /** Manifest visibility wins; optional GitHub visibility discovery never changes verification findings or errors. */
41
+ export function badgeVisibilityNotices({ doc, gh, repo }) {
42
+ let visibility = doc?.ci?.github?.visibility;
43
+ if (visibility === undefined) {
44
+ try {
45
+ const remote = gh.json(['repo', 'view', '--json', 'visibility', '--repo', repo]);
46
+ visibility = ['PRIVATE', 'INTERNAL'].includes(remote?.visibility) ? 'private' : 'public';
47
+ } catch { return []; }
48
+ }
49
+ return badgeTokenNotices({ ...doc, ci: { ...doc?.ci, github: { ...doc?.ci?.github, visibility } } }, { repository: `https://github.com/${repo}` });
50
+ }
package/lint/run.mjs CHANGED
@@ -26,7 +26,7 @@ import { STYLE_GLOB } from '../sync/index.mjs';
26
26
  import { byCodeUnit } from '../report/order.mjs';
27
27
  import { braceVariants, globExpression } from '../runtime/scripts/lib/glob.mjs';
28
28
 
29
- // --side judges only that side; root HFS findings belong to be, and Sonar always requires the full lint.
29
+ // --side judges only that side; root HFS findings belong to be, its Sonar report stays on that side.
30
30
  export const LINT_SCHEMA = 'starci/lint@1';
31
31
  /** The side whose stylesheets stylelint judges. */
32
32
  const STYLE_SIDE = 'fe';
@@ -42,11 +42,10 @@ export function workspaceOf(workspace) {
42
42
  return rel;
43
43
  }
44
44
 
45
- /** Side scopes partition the full lint; Sonar requires the complete report and a frontend workspace cannot belong to be. */
45
+ /** Side scopes partition the full lint; Sonar reports share that scope and a frontend workspace cannot belong to be. */
46
46
  function validateSide(opts) {
47
47
  if (opts.side === undefined) return;
48
48
  if (!SIDES.includes(opts.side)) throw new Error('LINT_SIDE_INVALID: --side is be or fe');
49
- if (opts.sonar !== undefined) throw new Error('LINT_SIDE_SONAR_CONFLICT: --side and --sonar do not combine');
50
49
  if (opts.side === 'be' && opts.workspace !== undefined) throw new Error('LINT_SIDE_WORKSPACE_CONFLICT: --side be cannot judge a frontend workspace');
51
50
  }
52
51
 
@@ -312,7 +311,7 @@ export async function lintRepository({ repoRoot, opts, hfsCheck, trackedFiles =
312
311
 
313
312
  let properties = '';
314
313
  try { properties = fs.readFileSync(path.join(repoRoot, 'sonar-project.properties'), 'utf8'); } catch { /* no properties: every finding keeps its own path */ }
315
- const sourceRoots = sourceRootsOf(properties);
314
+ const sourceRoots = sourceRootsOf(properties).filter((root) => findingOnSide({ path: root }, opts.side));
316
315
  const tracked = sourceRoots.length ? trackedFiles(repoRoot) : [];
317
316
  const sonar = mergeReports([
318
317
  sonarReport(kept, { sourceRoots, tracked }),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@starci/hfs",
3
- "version": "4.5.1",
3
+ "version": "4.6.0",
4
4
  "description": "The StarCi app command implementation for scaffolding, linting, checking and synchronizing one product repository.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -20,29 +20,61 @@ function runsOf(result) {
20
20
  } catch { return null; }
21
21
  }
22
22
 
23
+ /** Resolve each available default-branch reference once, including HEAD in detached release worktrees. */
24
+ function referenceTips(spawn, cwd, defaultBranch) {
25
+ const refs = [`origin/${defaultBranch}`, defaultBranch, 'HEAD'];
26
+ return refs.flatMap((name) => {
27
+ const result = run(spawn, cwd, 'git', ['rev-parse', '--verify', '--quiet', `${name}^{commit}`]);
28
+ const tip = String(result.stdout ?? '').trim();
29
+ return !result.error && result.status === 0 && SHA.test(tip) ? [{ name, tip }] : [];
30
+ });
31
+ }
32
+
33
+ /** Equality proves reachability directly; ancestry is unknown only when every available probe errors. */
34
+ function pushAncestry(spawn, cwd, row, refs) {
35
+ if (refs.some((ref) => ref.tip === row.headSha)) return { candidate: true, unknown: false };
36
+ let known = false;
37
+ for (const ref of refs) {
38
+ const result = run(spawn, cwd, 'git', ['merge-base', '--is-ancestor', row.headSha, ref.tip]);
39
+ if (!result.error && result.status === 0) return { candidate: true, unknown: false };
40
+ if (!result.error && result.status === 1) known = true;
41
+ }
42
+ return { candidate: false, unknown: !known };
43
+ }
44
+
45
+ /** Branch rows remain candidates; push rows must reach a resolved reference, and each databaseId counts once. */
46
+ function candidateRuns(spawn, cwd, branch, pushes, refs) {
47
+ const candidates = branch.filter((row) => row && SHA.test(row.headSha));
48
+ let ancestryUnknown = refs.length === 0;
49
+ for (const row of pushes) {
50
+ if (!row || !SHA.test(row.headSha) || row.event !== 'push') continue;
51
+ const ancestry = pushAncestry(spawn, cwd, row, refs);
52
+ if (ancestry.candidate) candidates.push(row);
53
+ if (ancestry.unknown) ancestryUnknown = true;
54
+ }
55
+ const unique = new Map();
56
+ for (const row of candidates) if (!unique.has(row.databaseId)) unique.set(row.databaseId, row);
57
+ return { candidates: [...unique.values()], ancestryUnknown };
58
+ }
59
+
23
60
  /** Reads branch runs plus push/tag runs whose commits are reachable from the default branch; the newest completed run decides, even if a newer run is pending. */
24
61
  export function latestDefaultBranchCi({ spawn, cwd, defaultBranch = 'main' }) {
62
+ const refs = referenceTips(spawn, cwd, defaultBranch);
63
+ const unknown = (why) => ({ state: 'unknown', why: `${why} (refs: ${refs.map((ref) => ref.name).join(', ') || 'none'})` });
25
64
  const args = ['run', 'list', '--workflow', 'ci.yml'];
26
65
  const tail = ['--limit', '20', '--json', FIELDS];
27
66
  const branch = runsOf(run(spawn, cwd, 'gh', [...args, '--branch', defaultBranch, ...tail]));
28
- if (!branch) return { state: 'unknown', why: 'gh branch runs unavailable (missing gh, authentication, or unreadable response)' };
67
+ if (!branch) return unknown('gh branch runs unavailable (missing gh, authentication, or unreadable response)');
29
68
  const pushes = runsOf(run(spawn, cwd, 'gh', [...args, '--event', 'push', ...tail]));
30
- if (!pushes) return { state: 'unknown', why: 'gh push runs unavailable (missing gh, authentication, or unreadable response)' };
31
- const candidates = branch.filter((row) => row && SHA.test(row.headSha));
32
- let ancestryUnknown = false;
33
- for (const row of pushes) {
34
- if (!row || !SHA.test(row.headSha) || row.event !== 'push') continue;
35
- const ancestor = run(spawn, cwd, 'git', ['merge-base', '--is-ancestor', row.headSha, defaultBranch]);
36
- if (!ancestor.error && ancestor.status === 0) candidates.push(row);
37
- else if (ancestor.error || ancestor.status !== 1) ancestryUnknown = true;
38
- }
69
+ if (!pushes) return unknown('gh push runs unavailable (missing gh, authentication, or unreadable response)');
70
+ const { candidates, ancestryUnknown } = candidateRuns(spawn, cwd, branch, pushes, refs);
39
71
  const completed = candidates.filter((row) => row.status === 'completed' && Number.isFinite(Date.parse(row.createdAt)));
40
72
  completed.sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt) || Number(b.databaseId) - Number(a.databaseId));
41
73
  const latest = completed[0];
42
- if (!latest) return { state: 'unknown', why: ancestryUnknown ? 'cannot establish push run ancestry on the default branch' : 'no completed CI run on the default branch' };
74
+ if (!latest) return unknown(ancestryUnknown ? 'cannot establish push run ancestry on the default branch' : 'no completed CI run on the default branch');
43
75
  if (latest.conclusion === 'success') return { state: 'green', run: latest };
44
76
  if (RED.has(latest.conclusion)) return { state: 'red', run: latest };
45
- return { state: 'unknown', why: 'latest completed CI run has no success or red conclusion', run: latest };
77
+ return { ...unknown('latest completed CI run has no success or red conclusion'), run: latest };
46
78
  }
47
79
 
48
80
  /** Only a complete starci/ci-override@1 record for this exact commit can excuse a red run. */
@@ -29,14 +29,14 @@ pins:
29
29
  source: packages/grammar/package.json
30
30
  why: 'the grammar front ends read: the brand layer sets `--font-sans` and `--font-mono` and the grammar reads them; it carries the Input tel kind, the copyable and read-only Input, the IconButton disclosure props and count badge, ChatBubble, FillViewport, Textarea mentions and Enter-to-send, the leading PrimaryRailLayout rail, the plate-less Image, ListRow, the soft Alert, Progress steps, the floating primary IconButton, FlowGraph, the STATE-8 loading views and the bordered selectable TagGroup chips. The runtime source version wins for a @starci package.'
31
31
  '@starci/eslint-canon-be':
32
- version: 3.4.3
32
+ version: 3.4.4
33
33
  group: starci
34
34
  install: registry
35
35
  side: be
36
36
  source: packages/eslint/be/package.json
37
37
  why: 'the back-end ESLint canon: `loadHfs(import.meta.url)` of be/eslint.config.mjs finds the app-root hfs.json (kind app) and gives every linted file the view of the be side, and the project graph is built per side; its bundled runtime copies (slots, canon-pins, failure codes, the architecture machine) put .starcistacks and .sops.yaml at the app root; `starciBeConfig({ hfs: loadHfs(import.meta.url) })` is the typed factory and the BE-CONVENTION laws are its rules.'
38
38
  '@starci/eslint-canon-fe':
39
- version: 8.4.3
39
+ version: 8.4.4
40
40
  group: starci
41
41
  install: registry
42
42
  side: fe
@@ -76,14 +76,14 @@ pins:
76
76
  source: packages/test-world/package.json
77
77
  why: 'the shared e2e library of every back end (R47, R48), a devDependency of every back end: the warm stack behind toxiproxy, the network-edge fakes, the Nest boot, the typed useTestWorld handle, useSandbox for contract specs and the outage lock. Kafka is real own infra (the one digest-pinned apache/kafka KRaft image, a broker listener and proxy per slot, slot-prefixed topics, consumer groups and client ids) and Postgres is schema-per-context (a schema and a login role per context in a shared database). Each worker has its own data slot (<ns>_w<k>) with its own databases, Keycloak realm, leased Redis DB, bucket/collection/topic prefixes, fakes host, toxiproxy proxies and outage lock; a run provisions min(jest workers, workers cap, default 2) slots, and state file protocol 2 pairs exactly with @starci/jest-preset (TEST_WORLD_PAIR_MISMATCH otherwise). A modules world boots real peer apps beside its modules, world.apps.<name>.during(fn) is the outage of a peer app, w.keycloak.clientSecret(client) answers the run-generated secret of a confidential realm client, world.keycloak.events/sessions read the realm user events and live sessions, world.infra.postgresql.connection(name) takes one database connection down while the others serve, and world.resolve and scope.resolve take any Nest token. Every path a declaration names (`stack`, seeds, the realm, Dockerfiles) resolves from the app root (the directory of hfs.json); the globalSetup registers the path aliases as TypeScript resolves them. Its CLI main is exported at ./cli for starci app stack.'
78
78
  '@starci/cli':
79
- version: 1.5.1
79
+ version: 1.6.0
80
80
  group: starci
81
81
  install: registry
82
82
  side: both
83
83
  source: packages/cli/package.json
84
84
  why: 'the only StarCi command package; product repositories install it for starci app, while @starci/hfs remains its pinned transitive implementation.'
85
85
  '@starci/hfs':
86
- version: 4.5.1
86
+ version: 4.6.0
87
87
  group: starci
88
88
  install: registry
89
89
  side: both
@@ -41,8 +41,8 @@ newCode:
41
41
  minReviewedPercent: 100
42
42
  unreviewedMax: 0
43
43
  # The whole code, not only the new code. Every finding of the HFS canon reaches Sonar through ONE entry, `starci app lint`
44
- # (packages/hfs, contract changes hfs-sonar-import and hfs-lint-entry), which writes ONE file, reports/lint.sonar.json
45
- # (`starci app lint --sonar reports/lint.sonar.json`), named by sonar.externalIssuesReportPaths; the gate holds the open-issue count of
44
+ # (packages/hfs, contract changes hfs-sonar-import and hfs-lint-entry). CI runs it per side with --side and --sonar,
45
+ # then merges rules by id and joins issues into ONE file, reports/lint.sonar.json, named by sonar.externalIssuesReportPaths; the gate holds the open-issue count of
46
46
  # the whole project at zero. The file carries three engines:
47
47
  # starci-hfs the repository findings of `starci app check` (managed files, tree, pins, CI, contracts; the rule id is the finding code)
48
48
  # eslint ESLint's json over the repository (the BE and FE canon plugins alike, per-file and project-graph rules)
@@ -152,6 +152,13 @@ $defs:
152
152
  optional:
153
153
  description: Skip an unreachable quality provider with a notice; SonarQube defaults true, SonarCloud and Codecov false.
154
154
  type: boolean
155
+ badgeToken:
156
+ description: >-
157
+ codecov and sonar only: a read-only, public-safe badge token (Codecov graphing token, Sonar project badge token)
158
+ that the README badge of a private repository needs. It is public configuration rendered into README.md, never a
159
+ secret; a CI token or any write credential never goes here.
160
+ type: string
161
+ pattern: '^[A-Za-z0-9_-]{6,128}$'
155
162
  ecosystems: {type: array, items: {enum: [npm, docker, github-actions]}}
156
163
  schedule:
157
164
  type: object
@@ -275,6 +282,9 @@ $defs:
275
282
  repository: {type: string, pattern: '^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$'}
276
283
  defaultBranch: {type: string, minLength: 1, default: main}
277
284
  workflow: {type: string, pattern: '^[A-Za-z0-9_.-]+\.ya?ml$', default: ci.yml}
285
+ visibility:
286
+ description: The GitHub repository visibility; private READMEs need services.<id>.badgeToken for live badges. Omitted means unknown.
287
+ enum: [public, private]
278
288
  secrets: {type: array, items: {$ref: '#/$defs/ciSecret'}}
279
289
  vars: {type: array, items: {$ref: '#/$defs/ciVariable'}}
280
290
  dependabot:
@@ -310,6 +320,9 @@ $defs:
310
320
  purpose: {type: string}
311
321
  optional: {type: boolean}
312
322
  ciVariable:
323
+ description: >-
324
+ A public CI variable either carries its literal value, or declares ownerValue true (no value) when only the owner
325
+ may supply it; var sync skips an owner-supplied value and ci verify checks that it exists by name.
313
326
  anyOf:
314
327
  - type: object
315
328
  additionalProperties: false
@@ -330,6 +343,25 @@ $defs:
330
343
  value: {type: string}
331
344
  purpose: {type: string}
332
345
  optional: {type: boolean}
346
+ - type: object
347
+ additionalProperties: false
348
+ required: [name, scope, ownerValue]
349
+ properties:
350
+ name: {$ref: '#/$defs/ciName'}
351
+ scope: {const: repository}
352
+ ownerValue: {const: true}
353
+ purpose: {type: string}
354
+ optional: {type: boolean}
355
+ - type: object
356
+ additionalProperties: false
357
+ required: [name, scope, ownerValue, environment]
358
+ properties:
359
+ name: {$ref: '#/$defs/ciName'}
360
+ scope: {const: environment}
361
+ environment: {type: string, pattern: '^[a-z][a-z0-9-]*$'}
362
+ ownerValue: {const: true}
363
+ purpose: {type: string}
364
+ optional: {type: boolean}
333
365
  serviceStack:
334
366
  description: >-
335
367
  Which stack defines a local service - either a project-owned .starcistacks stack
@@ -1,7 +1,7 @@
1
1
  // GENERATED by scripts/cli/gen-catalog.mjs — do not edit
2
2
  export const CATALOG = {
3
3
  "schema": "starci/cli-catalog@1",
4
- "hash": "d230745d3aa5cfe65f75cb5fb2872924b935f1b4610c0b72db2f729572386404",
4
+ "hash": "93fd9007c93fe7430043d8b6682fd46dd0e1d5f9f9b7094fc94ffad2ccdcceeb",
5
5
  "global": [
6
6
  {
7
7
  "name": "json",
@@ -224,12 +224,12 @@ export const CATALOG = {
224
224
  {
225
225
  "name": "value",
226
226
  "type": "string",
227
- "summary": "public variable value that must agree with the manifest"
227
+ "summary": "public variable value that must agree with the manifest; var set ownerValue requires stdin instead"
228
228
  },
229
229
  {
230
230
  "name": "execute",
231
231
  "type": "boolean",
232
- "summary": "apply the variable sync plan"
232
+ "summary": "apply the variable sync plan; var sync skips ownerValue variables"
233
233
  },
234
234
  {
235
235
  "name": "json",
@@ -7317,12 +7317,12 @@ export const CATALOG = {
7317
7317
  {
7318
7318
  "name": "dev",
7319
7319
  "type": "boolean",
7320
- "summary": "save the packages as development dependencies"
7320
+ "summary": "save the CLI and explicit packages as development dependencies"
7321
7321
  },
7322
7322
  {
7323
7323
  "name": "canon",
7324
7324
  "type": "boolean",
7325
- "summary": "install the target CLI and all of its exact StarCi canon pins"
7325
+ "summary": "install the target CLI and move its declared exact StarCi canon pins"
7326
7326
  }
7327
7327
  ],
7328
7328
  "exit": {
@@ -7353,10 +7353,12 @@ export const CATALOG = {
7353
7353
  "bare package names resolve only from knowledge/hfs/canon-pins.yaml and never through a registry lookup",
7354
7354
  "explicit versions use exact x.y.z form and must agree with the canon pin when one exists, except the target CLI version on a canon upgrade",
7355
7355
  "naming @starci/cli or passing --canon installs the exact target CLI first,",
7356
- "then reads its installed HFS runtime canon-pins.yaml and installs every pinned @starci package in the same host lock",
7356
+ "then reads its installed HFS runtime canon-pins.yaml and, in the same host lock,",
7357
+ "moves each other pinned @starci package only where a root or workspace package.json declares it, in its own section",
7357
7358
  "--canon without a CLI positional uses the running runtime's exact CLI pin as the target",
7358
7359
  "target pins never fall back to the running runtime when the target pin file is missing or inconsistent",
7359
- "each target StarCi pin is reported as moved or refused with a reason; a failed canon phase exits nonzero even when the first CLI install succeeded",
7360
+ "each target StarCi pin is reported as moved (with its workspace), skipped when no package.json declares it, or refused with a reason",
7361
+ "a failed canon phase exits nonzero even when the first CLI install succeeded",
7360
7362
  "installs always use --save-exact and report the package.json and package-lock.json diff stat"
7361
7363
  ],
7362
7364
  "runtimeSide": "host"
@@ -4,6 +4,14 @@ import { parseYaml } from '../../engine/yaml.mjs';
4
4
 
5
5
  const nameOf = (item) => typeof item === 'string' ? item : item?.name;
6
6
 
7
+ /** A public CI variable declares either a manifest value or an owner-supplied value, never both. */
8
+ function variableProblems(item, at, invalid) {
9
+ const hasValue = item != null && Object.hasOwn(item, 'value');
10
+ const hasOwnerValue = item != null && Object.hasOwn(item, 'ownerValue');
11
+ if (hasValue && hasOwnerValue) invalid(at, 'a variable has either a value or ownerValue');
12
+ else if (!hasValue && item?.ownerValue !== true) invalid(at, 'a variable needs a value or ownerValue: true');
13
+ }
14
+
7
15
  /** Each CI name belongs to one class and scope; declarations contain names and public configuration only. */
8
16
  export function readCiDeclaration(doc) {
9
17
  const problems = [], seen = new Set();
@@ -17,8 +25,9 @@ export function readCiDeclaration(doc) {
17
25
  if (!['repository', 'environment'].includes(scope)) invalid(`${where}.scope`, 'scope must be repository or environment');
18
26
  if (scope === 'environment' && (typeof item?.environment !== 'string' || !/^[a-z][a-z0-9-]*$/.test(item.environment))) invalid(`${where}.environment`, 'environment scope requires an environment name');
19
27
  if ((dependabot || scope !== 'environment') && item && Object.hasOwn(item, 'environment')) invalid(`${where}.environment`, 'only environment scope may name an environment');
28
+ if (variable) variableProblems(item, where, invalid);
20
29
  return { name, scope, environment: item?.environment ?? null, purpose: item?.purpose ?? null,
21
- optional: item?.optional === true, ...(variable ? { value: item?.value } : {}) };
30
+ optional: item?.optional === true, ...(variable ? { value: item?.value, ownerValue: item?.ownerValue === true } : {}) };
22
31
  });
23
32
  const ci = doc?.ci;
24
33
  const secrets = normalize(ci?.secrets, 'ci.secrets');
@@ -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);
@@ -131,7 +131,7 @@ function sonarJob(sonar, lcovReport, lcovArtifact, nodeMajor) {
131
131
  .replaceAll('{{nodeMajor}}', nodeMajor)
132
132
  .replace('{{sonarJobHeader}}', () => header)
133
133
  .replace('{{sonarJobSteps}}', () => steps)
134
- .trimEnd();
134
+ .trimEnd() + '\n';
135
135
  }
136
136
 
137
137
  /** The app source reader is be-relative; the authored manifest lives one directory above that side. */
@@ -158,29 +158,64 @@ function mergeBuildValues(values, entries) {
158
158
  }
159
159
  }
160
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) {
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) {
163
174
  const declaration = buildDeclaration(source);
164
175
  const environments = Object.keys(source?.stack?.imageEnvironments ?? {});
165
176
  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);
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));
169
179
  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');
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
+ ];
173
206
  }
174
207
 
175
208
  /** 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' } = {}) {
209
+ export function ciWorkflowCommands(source, lcovReport = 'be/coverage/lcov.info', quality = undefined, { nodeMajor = '22', app } = {}) {
177
210
  const configuredSonar = quality === undefined ? { token: 'SONAR_TOKEN', hostVar: 'SONAR_HOST_URL' } : quality.sonar;
178
211
  const sonar = configuredSonar?.wiring === 'not-used' || configuredSonar?.mode === 'disabled' ? null : configuredSonar;
179
212
  const codecov = quality?.codecov ?? { enabled: true, auth: 'oidc' };
180
213
  const audit = quality?.audit ?? { level: 'high', omitDev: true };
181
214
  const lcovArtifact = 'coverage-lcov';
215
+ const build = frontendBuild(source, app);
182
216
  return {
183
- ciBuildEnv: buildEnv(source),
217
+ ciBuildEnv: build.env,
218
+ ciBuildSteps: build.steps,
184
219
  ciLint: exampleCommand(source, 'npm run lint'),
185
220
  ciLintSonar: exampleCommand(source, 'npm run lint -- --sonar reports/lint.sonar.json'),
186
221
  ciCoverageProof: exampleCommand(source, 'npx starci app check --coverage-from ' + lcovReport),
@@ -198,11 +233,14 @@ export function ciWorkflowCommands(source, lcovReport = 'be/coverage/lcov.info',
198
233
  ' NODE_OPTIONS: --max-old-space-size=6144',
199
234
  ]),
200
235
  ...(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',
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(),
206
244
  ` - uses: ${UPLOAD_ARTIFACT}`,
207
245
  " if: always() && env.SONAR_TOKEN != '' && hashFiles('reports/lint.sonar.json') != ''",
208
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);
@@ -429,8 +430,8 @@ function insertAfterTitle(existing, content) {
429
430
  return `${heading}\n${content}${existing.slice(end)}`;
430
431
  }
431
432
 
432
- /** Rewrite the missing or drifted targets; returns the paths written. */
433
- function writtenContent(target, existing) {
433
+ /** Apply exactly one target to its whole file while preserving content outside the managed block or scripts. */
434
+ export function writtenContent(target, existing) {
434
435
  if (target.mode === 'scripts') {
435
436
  let pkg = {};
436
437
  if (existing) {
@@ -477,10 +478,10 @@ export function loadHfs(root, manifest = loadSlotManifest()) {
477
478
  }
478
479
 
479
480
  /** The rendered targets of the app at `root`: its hfs.json, the Sonar key the be side's stack declaration names, the presets it installs. */
480
- export async function renderRepo(root, { presets, parseYaml, manifest = loadSlotManifest() } = {}) {
481
+ export async function renderRepo(root, { presets, parseYaml, notices, manifest = loadSlotManifest() } = {}) {
481
482
  const hfs = loadHfs(root, manifest);
482
483
  const sonarKey = await readDeclaredSonarKey(root, { parseYaml, fail: message => { throw new SyncError('HFS_SYNC_SONAR_KEY', message); } });
483
- return { hfs, targets: renderTargets(hfs, presets ?? await loadPresets(root), { sonarKey, manifest, source: appSource(root, { parseYaml }) }) };
484
+ return { hfs, targets: renderTargets(hfs, presets ?? await loadPresets(root), { sonarKey, manifest, source: appSource(root, { parseYaml, notices }) }) };
484
485
  }
485
486
 
486
487
  /** `starci app sync --check | --write [--cwd <dir>]` at the app root; returns 0 clean, 1 drift, 2 usage. */
@@ -516,7 +517,9 @@ export async function runSync(argv, { cwd = process.cwd(), out = line => process
516
517
  const { write, cwdAt } = options;
517
518
  const root = cwdAt >= 0 ? path.resolve(cwd, argv[cwdAt + 1]) : cwd;
518
519
  try {
519
- const { targets } = await renderRepo(root, { presets, parseYaml });
520
+ const notices = [];
521
+ const { targets } = await renderRepo(root, { presets, parseYaml, notices });
522
+ if (targets.some(target => target.path === 'README.md')) for (const notice of new Set(notices)) out(notice);
520
523
  if (write) {
521
524
  const written = writeTargets(root, targets);
522
525
  for (const file of written) out(`wrote ${file}`);
@@ -3,7 +3,7 @@ import path from 'node:path';
3
3
  import { parseYaml as bundledParseYaml } from '../runtime/engine/yaml.mjs';
4
4
  import { qualityServices } from '../runtime/scripts/hfs/ci-declaration.mjs';
5
5
  import { isRuntimeExampleApp } from '../runtime/scripts/lib/runtime-example.mjs';
6
- import { renderReadmeBadges } from './readme-badges.mjs';
6
+ import { badgeTokenNotices, renderReadmeBadges } from './readme-badges.mjs';
7
7
  import { DECLARATION } from './sonar-key.mjs';
8
8
 
9
9
  /** Apps without any service block retain the legacy CI defaults until they declare their services. */
@@ -35,8 +35,8 @@ export function sonarHostLine(sonar) {
35
35
 
36
36
  /** Dependabot groups minor and patch updates while majors remain per package; security updates have their own immediate group. */
37
37
  export function dependabotUpdates(dependabot) {
38
- if (!dependabot.ecosystems.length) return ' []';
39
- return dependabot.ecosystems.map(ecosystem => [
38
+ if (!dependabot.ecosystems.length) return ' []';
39
+ return '\n' + dependabot.ecosystems.map(ecosystem => [
40
40
  ` - package-ecosystem: ${ecosystem}`,
41
41
  ...(ecosystem === 'docker' ? [' directories:', ' - /be/apps/*', ' - /fe/apps/*'] : [' directory: /']),
42
42
  ' schedule:',
@@ -53,9 +53,11 @@ export function dependabotUpdates(dependabot) {
53
53
  }
54
54
 
55
55
  /** README badges use only public manifest fields and the package repository fallback. */
56
- export function readDeclaredBadges(root, { parseYaml = bundledParseYaml } = {}) {
56
+ export function readDeclaredBadges(root, { parseYaml = bundledParseYaml, notices } = {}) {
57
57
  let doc = {}, pkg = {};
58
58
  try { doc = parseYaml(fs.readFileSync(path.join(root, DECLARATION), 'utf8')); } catch { /* No declaration. */ }
59
59
  try { pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')); } catch { /* No repository fallback. */ }
60
- return renderReadmeBadges(doc, { repository: pkg.repository, example: isRuntimeExampleApp(root) });
60
+ const options = { repository: pkg.repository, example: isRuntimeExampleApp(root) };
61
+ notices?.push(...badgeTokenNotices(doc, options));
62
+ return renderReadmeBadges(doc, options);
61
63
  }
@@ -7,6 +7,9 @@ function githubRepository(repository) {
7
7
  return (match?.[1] ?? match?.[2] ?? '').replace(/\.git$/, '');
8
8
  }
9
9
 
10
+ /** Notice code (never an error) of a private repository badge rendered without its read-only badge token. */
11
+ const BADGE_TOKEN_NOTICE = 'README_BADGE_TOKEN_MISSING';
12
+
10
13
  const badge = (name, image, link) => `[![${name}](${image})](${link})`;
11
14
 
12
15
  /** Render live CI, coverage, quality gate and Dependabot badges only when a GitHub repository is declared or inferable; no network is used. */
@@ -20,7 +23,10 @@ export function renderReadmeBadges(doc, { repository, example = false } = {}) {
20
23
  const quality = qualityServices(doc, { example });
21
24
  const lines = [badge('CI', `${base}/actions/workflows/${workflow}/badge.svg?branch=${branch}`, `${base}/actions/workflows/${workflow}`)];
22
25
  if (quality.codecov.enabled) {
23
- const flag = quality.codecov.flag ? `?flag=${encodeURIComponent(quality.codecov.flag)}` : '';
26
+ const params = [];
27
+ if (quality.codecov.flag) params.push(`flag=${encodeURIComponent(quality.codecov.flag)}`);
28
+ if (doc?.services?.codecov?.badgeToken) params.push(`token=${encodeURIComponent(doc.services.codecov.badgeToken)}`);
29
+ const flag = params.length ? `?${params.join('&')}` : '';
24
30
  lines.push(badge('Codecov', `https://codecov.io/gh/${repo}/branch/${branch}/graph/badge.svg${flag}`, `https://codecov.io/gh/${repo}`));
25
31
  }
26
32
  const sonar = sonarBadge(doc, quality.sonar, example);
@@ -40,5 +46,15 @@ function sonarBadge(doc, sonar, example) {
40
46
  const organization = sonar.organization ?? doc?.ci?.vars?.find(entry => entry.name === sonar.organizationVar)?.value;
41
47
  const prefix = example && organization ? `${organization}_` : '';
42
48
  const key = encodeURIComponent(prefix + sonar.projectKey);
43
- return badge('Quality Gate', `${host}/api/project_badges/measure?project=${key}&metric=alert_status`, `${host}/dashboard?id=${key}`);
49
+ const token = doc?.services?.sonar?.badgeToken ? `&token=${encodeURIComponent(doc.services.sonar.badgeToken)}` : '';
50
+ return badge('Quality Gate', `${host}/api/project_badges/measure?project=${key}&metric=alert_status${token}`, `${host}/dashboard?id=${key}`);
51
+ }
52
+
53
+ /** Private repositories need public-safe badge tokens only for badges the renderer actually emits; examples never receive notices. */
54
+ export function badgeTokenNotices(doc, { example = false, repository } = {}) {
55
+ if (example || doc?.ci?.github?.visibility !== 'private') return [];
56
+ const rendered = renderReadmeBadges(doc, { example, repository });
57
+ return [['codecov', 'Codecov', 'Codecov'], ['sonar', 'Sonar', 'Quality Gate']]
58
+ .filter(([id, , label]) => !doc?.services?.[id]?.badgeToken && rendered.includes(`[![${label}](`))
59
+ .map(([id, name]) => `${BADGE_TOKEN_NOTICE} notice: the ${name} badge of a private repository renders "unknown" without services.${id}.badgeToken (a read-only badge token, not a CI secret)`);
44
60
  }
@@ -71,6 +71,11 @@ export function readDeclaredStack(root, { parseYaml = bundledParseYaml } = {}) {
71
71
  } catch {
72
72
  return { images: [], passEnv: [] };
73
73
  }
74
+ return declaredStack(declaration);
75
+ }
76
+
77
+ /** Parsed declarations share the exact image, environment and pass-env projection used by sync. */
78
+ export function declaredStack(declaration) {
74
79
  return { images: declaredStackImages(declaration), passEnv: declaredPassEnv(declaration), imageEnvironments: imageEnvironments(declaration) };
75
80
  }
76
81
 
@@ -44,6 +44,8 @@ jobs:
44
44
  run: npm test -- --ci --forceExit --cacheDirectory .jest-cache
45
45
  - name: coverage proof
46
46
  run: {{ciCoverageProof}}
47
+ env:
48
+ NODE_OPTIONS: --max-old-space-size=6144
47
49
  - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
48
50
  if: always() && hashFiles('{{lcovReport}}') != ''
49
51
  with:
@@ -58,8 +60,6 @@ jobs:
58
60
  {{> app/ci-workflows/install-steps.yml}}
59
61
  - name: build be
60
62
  run: npm run build:be
61
- - name: build fe
62
- run: npm run build:fe
63
- {{ciBuildEnv}}
63
+ {{ciBuildSteps}}
64
64
 
65
65
  {{sonarJob}}
@@ -44,6 +44,8 @@ jobs:
44
44
  run: npm test -- --ci --forceExit --cacheDirectory .jest-cache
45
45
  - name: coverage proof
46
46
  run: {{ciCoverageProof}}
47
+ env:
48
+ NODE_OPTIONS: --max-old-space-size=6144
47
49
  - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
48
50
  if: always() && hashFiles('{{lcovReport}}') != ''
49
51
  with:
@@ -58,14 +60,11 @@ jobs:
58
60
  {{> app/ci-workflows/install-steps.yml}}
59
61
  - name: build be
60
62
  run: npm run build:be
61
- - name: build fe
62
- run: npm run build:fe
63
- {{ciBuildEnv}}
63
+ {{ciBuildSteps}}
64
64
  - name: database migration checks
65
65
  run: {{ciDatabaseLint}}
66
66
 
67
67
  {{sonarJob}}
68
-
69
68
  db-types:
70
69
  timeout-minutes: 30
71
70
  runs-on: ubuntu-latest
@@ -1,4 +1,3 @@
1
1
  {{header}}
2
2
  version: 2
3
- updates:
4
- {{dependabotUpdates}}
3
+ updates:{{dependabotUpdates}}
@@ -1,4 +1,3 @@
1
1
  {{header}}
2
2
  version: 2
3
- updates:
4
- {{dependabotUpdates}}
3
+ updates:{{dependabotUpdates}}