@starci/hfs 4.5.0 → 4.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,8 @@
1
1
  # Changelog
2
2
 
3
+ ## 4.5.1 - 2026-10-18
4
+ - Fixed: managed CI base refs, public build values, caching and timeouts; optional quality service handling, README formatting and Supabase migration-history parsing.
5
+
3
6
  ## 4.5.0 - 2026-10-17
4
7
  - Added: consumer-class CI declarations, `app ci` custody verbs and R296-R298; manifest-driven quality files and split cached CI with immutable action pins.
5
8
  - Added: release audit/local Sonar and main-CI protection; encrypted outside-repository DB dump and armed linked reset with backup/auth/storage validation.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@starci/hfs",
3
- "version": "4.5.0",
3
+ "version": "4.5.1",
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",
package/release/gate.mjs CHANGED
@@ -1,4 +1,4 @@
1
- // gate.mjs - `starci app release gate`: the honest release gate. It runs the app's own gates on a clean HEAD and ONLY when every one is green it
1
+ // gate.mjs - `starci app release gate`: the honest release gate. It runs the app's own gates on a clean HEAD and when all required gates pass it
2
2
  // writes the L4 record <git-common-dir>/starci-release/<HEAD sha>.l4.json (head, tag, per-gate log path and sha256) and creates the annotated tag.
3
3
  // The canon pre-push hook lets main and v* tags through only for a HEAD with that tag and that record. Nothing is pushed here (see push.mjs).
4
4
  // The gate list comes from the optional `release:` block of .starcistacks/application-stacks.yaml, else from the app's managed package scripts.
@@ -13,6 +13,7 @@ import { failure, refusal } from '../stack/errors.mjs';
13
13
  import { latestDefaultBranchCi, requireGreenCi } from './ci-red.mjs';
14
14
 
15
15
  export const RELEASE_CODE = 'RELEASE_CONFIG_INVALID';
16
+ const SONAR_UNREACHABLE = 'SONAR_UNREACHABLE';
16
17
 
17
18
  const FIELDS = new Set(['gates', 'tagPrefix', 'defaultBranch', 'tagMatchesVersion']);
18
19
  const GATE_FIELDS = new Set(['name', 'command', 'summary', 'env']);
@@ -63,14 +64,15 @@ function auditGate(audit) {
63
64
 
64
65
  /** A requested local Sonar scan uses only the declared host and token variable; the token never enters argv. */
65
66
  function sonarGate(services, env) {
66
- const sonar = services?.sonar;
67
+ const configured = services?.sonar;
68
+ const sonar = configured?.provider === 'sonarcloud' ? { ...configured, host: 'https://sonarcloud.io' } : configured;
67
69
  const tokenName = sonar?.token;
68
70
  if (!sonar?.host || !ENV_KEY.test(tokenName) || !env[tokenName]) throw refusal('RELEASE_SONAR_UNAVAILABLE', 'declare an enabled services.sonar host and set its ci.token environment variable');
69
71
  const command = ['npx', '--yes', '@sonar/scan@4.3.0', `-Dsonar.host.url=${sonar.host}`];
70
72
  const organization = sonar.organization ?? (sonar.organizationVar ? env[sonar.organizationVar] : null);
71
73
  if (organization) command.push(`-Dsonar.organization=${organization}`);
72
74
  if (!command.every((word) => TOKEN.test(word))) throw refusal('RELEASE_SONAR_UNAVAILABLE', 'Sonar host and organization must be plain command arguments');
73
- return { name: 'sonar', command, summary: null, env: { SONAR_TOKEN: env[tokenName] } };
75
+ return { name: 'sonar', command, summary: null, env: { SONAR_TOKEN: env[tokenName] }, provider: sonar };
74
76
  }
75
77
 
76
78
  function gateProblems(entry, at, seen) {
@@ -209,6 +211,32 @@ export function launch(argv, platform) {
209
211
  return [argv[0], argv.slice(1)];
210
212
  }
211
213
 
214
+ /** Sonar availability is public and bounded; HTTP uses the existing injected fetch seam. */
215
+ async function sonarAvailability(ctx, gate) {
216
+ const sonar = gate.provider;
217
+ if (!sonar) return null;
218
+ let up = false;
219
+ try {
220
+ const response = await ctx.fetch(sonar.host.replace(/\/$/u, '') + '/api/system/status', { signal: AbortSignal.timeout(10000) });
221
+ up = response.ok && (await response.json()).status === 'UP';
222
+ } catch { up = false; }
223
+ if (up) return null;
224
+ const detail = sonar.optional
225
+ ? `notice: sonar skipped: ${sonar.host} not reachable (services.sonar.optional)`
226
+ : `${SONAR_UNREACHABLE}: ${sonar.host} did not report UP within ten seconds`;
227
+ return { skipped: sonar.optional === true, pass: false, detail };
228
+ }
229
+
230
+ /** Unreachable optional providers remain explicit skipped rows, with a real log and no scan invocation. */
231
+ async function runAvailableGate(ctx, gate, options) {
232
+ const availability = await sonarAvailability(ctx, gate);
233
+ if (!availability) return runGate(ctx, gate, options);
234
+ const log = path.join(options.dir, `${gate.name}.log`);
235
+ fs.writeFileSync(log, availability.detail + '\n');
236
+ return { name: gate.name, command: gate.command.join(' '), ...availability, seconds: 0,
237
+ summary: null, log: path.resolve(log), logSha256: sha256(log), env: Object.keys(gate.env) };
238
+ }
239
+
212
240
  function runGate(ctx, gate, { dir, platform }) {
213
241
  const log = path.join(dir, `${gate.name}.log`);
214
242
  const started = ctx.now();
@@ -270,7 +298,7 @@ function inspect(ctx, config, options) {
270
298
  }
271
299
 
272
300
  function planLines(config, facts, dir, recordPath) {
273
- const out = [`L4 record ${recordPath}`, `logs ${dir}`, '', 'gates (all must pass, in this order):'];
301
+ const out = [`L4 record ${recordPath}`, `logs ${dir}`, '', 'gates (required gates must pass, optional provider outages are skipped, in this order):'];
274
302
  for (const gate of config.gates) {
275
303
  const summary = gate.summary ? ` (summary: ${gate.summary})` : '';
276
304
  out.push(` ${gate.name.padEnd(17)} ${gate.command.join(' ')}${summary}`);
@@ -289,28 +317,31 @@ function assertUnmoved(ctx, head) {
289
317
  function writeRecord(ctx, { head, tag, results, recordPath }) {
290
318
  const record = {
291
319
  schema: 'starci-app-l4-record/1', head, tag, createdAt: new Date(ctx.now()).toISOString(), producedBy: 'starci app release gate',
292
- note: 'The full gates ran on a clean HEAD; every entry links the real log and its sha256.',
320
+ note: 'Required gates passed on a clean HEAD; optional provider skips are explicit and every entry links its log and sha256.',
293
321
  gates: results,
294
322
  };
295
323
  fs.mkdirSync(path.dirname(recordPath), { recursive: true });
296
324
  fs.writeFileSync(recordPath, `${JSON.stringify(record, null, 2)}\n`);
297
325
  }
298
326
 
299
- function runAll(ctx, config, { dir, platform }) {
300
- fs.mkdirSync(dir, { recursive: true });
301
- const results = [];
302
- for (const gate of config.gates) {
303
- const result = runGate(ctx, gate, { dir, platform });
304
- results.push(result);
305
- if (!result.pass) break;
306
- }
307
- return results;
327
+ /** Gates run sequentially because each failure prevents subsequent commands and record creation. */
328
+ async function runAll(ctx, config, options) {
329
+ fs.mkdirSync(options.dir, { recursive: true });
330
+ return config.gates.reduce(async (pending, gate) => {
331
+ const results = await pending;
332
+ if (results.some((result) => !result.pass && !result.skipped)) return results;
333
+ return [...results, await runAvailableGate(ctx, gate, options)];
334
+ }, Promise.resolve([]));
308
335
  }
309
336
 
310
- const summaryOf = (result) => `${result.pass ? 'PASS' : 'FAIL'} ${result.name} (${result.detail}, ${result.seconds}s) log ${result.log}`;
337
+ function summaryOf(result) {
338
+ let status = result.pass ? 'PASS' : 'FAIL';
339
+ if (result.skipped) status = 'SKIPPED';
340
+ return `${status} ${result.name} (${result.detail}, ${result.seconds}s) log ${result.log}`;
341
+ }
311
342
 
312
343
  /**
313
- * `starci app release gate`: dry run prints the preconditions, the gate list, the head and the tag; --execute runs every gate and, only if all are green,
344
+ * `starci app release gate`: dry run prints the preconditions, the gate list, the head and the tag; --execute runs every gate and, only if required gates pass,
314
345
  * writes the L4 record and creates the annotated tag. options: { execute, sonar, source, tagPrefix, platform }.
315
346
  */
316
347
  export async function releaseGate(ctx, config, options = {}) {
@@ -325,14 +356,14 @@ export async function releaseGate(ctx, config, options = {}) {
325
356
  const base = { head: facts.head, tag: facts.chosen.tag, record: recordPath, gates: config.gates.map((gate) => gate.name), ci: { state: ci.state, notices: ciLines } };
326
357
  if (facts.red.length > 0) return { ok: false, lines: [...plan, ...facts.red.map((item) => `RED: ${item}`), 'nothing was run.'], data: { ...base, red: facts.red, executed: false } };
327
358
  if (options.execute !== true) return { ok: true, lines: [...plan, '', 'dry run: no gate was run, nothing written. preconditions OK.'], data: { ...base, red: [], executed: false } };
328
- const results = runAll(ctx, config, { dir, platform: options.platform ?? process.platform });
359
+ const results = await runAll(ctx, config, { dir, platform: options.platform ?? process.platform });
329
360
  const report = results.map(summaryOf);
330
- const failed = results.filter((result) => !result.pass);
361
+ const failed = results.filter((result) => !result.pass && !result.skipped);
331
362
  if (failed.length > 0) return { ok: false, lines: [...plan, ...report, `gate(s) red: ${failed.map((item) => item.name).join(', ')}. No L4 record, no tag.`], data: { ...base, executed: true, results } };
332
363
  assertUnmoved(ctx, facts.head);
333
364
  writeRecord(ctx, { head: facts.head, tag: facts.chosen.tag, results, recordPath });
334
365
  const created = facts.chosen.state !== 'on-head';
335
- if (created) git(ctx, ['tag', '-a', facts.chosen.tag, '-m', `release ${facts.chosen.tag}: gates green on ${facts.head.slice(0, 12)} (L4 record ${path.basename(recordPath)})`, facts.head]);
366
+ if (created) git(ctx, ['tag', '-a', facts.chosen.tag, '-m', `release ${facts.chosen.tag}: required gates passed on ${facts.head.slice(0, 12)} (L4 record ${path.basename(recordPath)})`, facts.head]);
336
367
  const tagged = created ? `annotated tag ${facts.chosen.tag} created on ${facts.head.slice(0, 10)}` : `annotated tag ${facts.chosen.tag} already on ${facts.head.slice(0, 10)}`;
337
368
  return { ok: true, lines: [...plan, ...report, `L4 record written: ${recordPath}`, tagged], data: { ...base, executed: true, results, tagCreated: created } };
338
369
  }
package/release/push.mjs CHANGED
@@ -14,7 +14,7 @@ function readRecord(file) {
14
14
  }
15
15
 
16
16
  const greenRecord = (record, head, tag) => record?.head === head && record?.tag === tag
17
- && Array.isArray(record.gates) && record.gates.length > 0 && record.gates.every((gate) => gate?.pass === true);
17
+ && Array.isArray(record.gates) && record.gates.length > 0 && record.gates.every((gate) => gate?.pass === true || (gate?.name === 'sonar' && gate?.skipped === true));
18
18
 
19
19
  /** The release gate as the hook reads it: the annotated tag on HEAD whose L4 record names head and tag. Returns { tag } or { reason }. */
20
20
  function gateState(ctx, head, prefix) {
@@ -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.2
32
+ version: 3.4.3
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.2
39
+ version: 8.4.3
40
40
  group: starci
41
41
  install: registry
42
42
  side: fe
@@ -62,7 +62,7 @@ pins:
62
62
  side: both
63
63
  source: packages/prettier-config/package.json
64
64
  '@starci/jest-preset':
65
- version: 2.3.0
65
+ version: 2.3.1
66
66
  group: starci
67
67
  install: registry
68
68
  side: be
@@ -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.0
79
+ version: 1.5.1
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.0
86
+ version: 4.5.1
87
87
  group: starci
88
88
  install: registry
89
89
  side: both
@@ -149,6 +149,9 @@ $defs:
149
149
  target: {type: number, minimum: 0, maximum: 100}
150
150
  components: {enum: [auto]}
151
151
  required: {type: boolean}
152
+ optional:
153
+ description: Skip an unreachable quality provider with a notice; SonarQube defaults true, SonarCloud and Codecov false.
154
+ type: boolean
152
155
  ecosystems: {type: array, items: {enum: [npm, docker, github-actions]}}
153
156
  schedule:
154
157
  type: object
@@ -33,13 +33,13 @@ export function qualityServices(doc, { example = false } = {}) {
33
33
  const dependabot = services.dependabot ?? {}, audit = services.audit ?? {};
34
34
  return {
35
35
  sonar: !sonar || sonar.mode === 'disabled' ? null : {
36
- provider: sonar.provider, mode: sonar.mode, host: sonar.host?.public || sonar.host?.local || null,
36
+ provider: sonar.provider, mode: sonar.mode, optional: sonar.optional ?? sonar.provider === 'sonarqube', host: sonar.host?.public || sonar.host?.local || null,
37
37
  organization: sonar.organization ?? null,
38
38
  organizationVar: sonar.ci?.organizationVar ?? (sonar.provider === 'sonarcloud' && (example || list(sonar.runtimeSecrets).length > 0) && !list(sonar.credentials).length ? 'SONAR_ORGANIZATION' : null), projectKey: sonar.projects?.[0]?.key ?? null,
39
39
  qualityGate: sonar.qualityGate ?? (example ? 'starci-quality-lite' : 'starci-quality'), wiring: sonar.ci?.wiring ?? 'required',
40
40
  token: sonar.ci?.token || nameOf(sonar.ci?.secrets?.[0]) || 'SONAR_TOKEN', hostVar: sonar.ci?.hostVar || 'SONAR_HOST_URL',
41
41
  },
42
- codecov: { enabled: codecov.mode !== 'disabled', auth: codecov.auth ?? 'oidc', token: codecov.ci?.token ?? null,
42
+ codecov: { optional: codecov.optional ?? false, enabled: codecov.mode !== 'disabled', auth: codecov.auth ?? 'oidc', token: codecov.ci?.token ?? null,
43
43
  flag: codecov.flag ?? null, target: codecov.target ?? 100, components: codecov.components ?? 'auto', required: codecov.required ?? true, wiring: codecov.ci?.wiring ?? 'required' },
44
44
  dependabot: { ecosystems: [...(dependabot.ecosystems ?? ['npm', 'github-actions', 'docker'])],
45
45
  schedule: { interval: 'weekly', ...dependabot.schedule }, groups: { minorPatch: 'grouped', major: 'per-package', ...dependabot.groups },
@@ -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.
@@ -71,6 +134,44 @@ function sonarJob(sonar, lcovReport, lcovArtifact, nodeMajor) {
71
134
  .trimEnd();
72
135
  }
73
136
 
137
+ /** The app source reader is be-relative; the authored manifest lives one directory above that side. */
138
+ function buildDeclaration(source) {
139
+ if (!source?.read) return {};
140
+ try {
141
+ return parseYaml(source.read('../.starcistacks/application-stacks.yaml')) ?? {};
142
+ } catch (error) {
143
+ if (error.code === 'ENOENT') return {};
144
+ throw error;
145
+ }
146
+ }
147
+
148
+ /** Static CI values are strings with valid environment names; expressions cannot turn public config into runtime secret reads. */
149
+ function mergeBuildValues(values, entries) {
150
+ for (const [key, value] of Object.entries(entries ?? {})) {
151
+ if (!/^[A-Z][A-Z0-9_]*$/u.test(key) || typeof value !== 'string' || value.includes('${{')) {
152
+ throw refusal('STACK_BUILD_ARG_INVALID', 'CI build values must map environment names to literal strings');
153
+ }
154
+ if (values.has(key) && values.get(key) !== value) {
155
+ throw refusal('STACK_BUILD_ARG_INVALID', 'CI build values disagree for ' + key);
156
+ }
157
+ values.set(key, value);
158
+ }
159
+ }
160
+
161
+ /** CI builds render only plain values from the images.yml environment plus the explicit build-fe gate; sealed members stay unopened. */
162
+ function buildEnv(source) {
163
+ const declaration = buildDeclaration(source);
164
+ const environments = Object.keys(source?.stack?.imageEnvironments ?? {});
165
+ const environment = environments.length > 1 ? 'production' : environments[0];
166
+ const images = declaration.environments?.[environment]?.deploy?.images ?? {};
167
+ const values = new Map();
168
+ for (const image of Object.values(images)) mergeBuildValues(values, image?.buildArgs?.values);
169
+ const gates = declaration.release?.gates ?? [];
170
+ for (const gate of gates.filter(entry => entry.name === 'build-fe')) mergeBuildValues(values, gate.env);
171
+ if (!values.size) return '';
172
+ return [' env:', ...[...values.keys()].sort(byCodeUnit).map(key => ' ' + key + ': ' + JSON.stringify(values.get(key)))].join('\n');
173
+ }
174
+
74
175
  /** An omitted quality declaration preserves hosted Sonar, required OIDC Codecov, and the high production dependency audit. */
75
176
  export function ciWorkflowCommands(source, lcovReport = 'be/coverage/lcov.info', quality = undefined, { nodeMajor = '22' } = {}) {
76
177
  const configuredSonar = quality === undefined ? { token: 'SONAR_TOKEN', hostVar: 'SONAR_HOST_URL' } : quality.sonar;
@@ -79,6 +180,7 @@ export function ciWorkflowCommands(source, lcovReport = 'be/coverage/lcov.info',
79
180
  const audit = quality?.audit ?? { level: 'high', omitDev: true };
80
181
  const lcovArtifact = 'coverage-lcov';
81
182
  return {
183
+ ciBuildEnv: buildEnv(source),
82
184
  ciLint: exampleCommand(source, 'npm run lint'),
83
185
  ciLintSonar: exampleCommand(source, 'npm run lint -- --sonar reports/lint.sonar.json'),
84
186
  ciCoverageProof: exampleCommand(source, 'npx starci app check --coverage-from ' + lcovReport),
package/sync/index.mjs CHANGED
@@ -379,7 +379,8 @@ export function renderTargets(hfs, presets, { sonarKey, readTemplate = readBundl
379
379
  return { path: where, scope, mode: 'scripts', content, hash: hashOf(content), scripts };
380
380
  }
381
381
  const markers = target.markdown ? { begin: '<!-- >>> starci app sync (managed block; do not edit) >>> -->', end: '<!-- <<< starci app sync <<< -->' } : { begin: BLOCK_BEGIN, end: BLOCK_END };
382
- const managed = target.mode === 'block' ? `${markers.begin}\n${trailingNewline(body)}${markers.end}\n` : body;
382
+ const spacing = target.markdown ? '\n' : '';
383
+ const managed = target.mode === 'block' ? `${markers.begin}\n${spacing}${trailingNewline(body)}${spacing}${markers.end}\n` : body;
383
384
  return { path: where, scope, mode: target.mode, markdown: target.markdown, markers, content: managed, hash: hashOf(managed) };
384
385
  });
385
386
  });
@@ -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,8 +39,9 @@ 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}}
43
47
  - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
@@ -48,6 +52,7 @@ jobs:
48
52
  {{codecovSteps}}
49
53
 
50
54
  build:
55
+ timeout-minutes: 30
51
56
  runs-on: ubuntu-latest
52
57
  steps:
53
58
  {{> app/ci-workflows/install-steps.yml}}
@@ -55,5 +60,6 @@ jobs:
55
60
  run: npm run build:be
56
61
  - name: build fe
57
62
  run: npm run build:fe
63
+ {{ciBuildEnv}}
58
64
 
59
65
  {{sonarJob}}
@@ -10,6 +10,7 @@ permissions:
10
10
 
11
11
  jobs:
12
12
  e2e:
13
+ timeout-minutes: 45
13
14
  runs-on: ubuntu-latest
14
15
  steps:
15
16
  - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
@@ -17,6 +17,7 @@ concurrency:
17
17
 
18
18
  jobs:
19
19
  image:
20
+ timeout-minutes: 30
20
21
  runs-on: ubuntu-latest{{imageJobEnvironment}}
21
22
  env:
22
23
  STACK_ENVIRONMENT: {{imageEnvironment}}
@@ -1,4 +1,6 @@
1
1
  - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
2
+ with:
3
+ fetch-depth: 0
2
4
  - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
3
5
  with:
4
6
  node-version: {{nodeMajor}}
@@ -0,0 +1,8 @@
1
+ - name: Jest cache
2
+ uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
3
+ with:
4
+ path: .jest-cache
5
+ key: ${{ runner.os }}-node{{nodeMajor}}-jest-${{ hashFiles('package-lock.json') }}-${{ github.sha }}
6
+ restore-keys: |
7
+ ${{ runner.os }}-node{{nodeMajor}}-jest-${{ hashFiles('package-lock.json') }}-
8
+ ${{ runner.os }}-node{{nodeMajor}}-jest-
@@ -0,0 +1,16 @@
1
+ - name: lint baseline
2
+ shell: bash
3
+ env:
4
+ BASE_BRANCH: ${{ github.base_ref }}
5
+ DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
6
+ BEFORE: ${{ github.event.before }}
7
+ run: |
8
+ base="origin/$DEFAULT_BRANCH"
9
+ if [[ "$GITHUB_EVENT_NAME" == pull_request ]]; then
10
+ base="origin/$BASE_BRANCH"
11
+ elif [[ "$GITHUB_REF" == refs/tags/* ]]; then
12
+ base=$(git describe --tags --abbrev=0 "$GITHUB_REF^" 2>/dev/null) || base="origin/$DEFAULT_BRANCH"
13
+ elif [[ "$GITHUB_EVENT_NAME" == push && -n "$BEFORE" && ! "$BEFORE" =~ ^0+$ ]]; then
14
+ base="$BEFORE"
15
+ fi
16
+ echo "STARCI_BASE_REF=$base" >> "$GITHUB_ENV"
@@ -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,8 +39,9 @@ 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}}
43
47
  - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
@@ -48,6 +52,7 @@ jobs:
48
52
  {{codecovSteps}}
49
53
 
50
54
  build:
55
+ timeout-minutes: 30
51
56
  runs-on: ubuntu-latest
52
57
  steps:
53
58
  {{> app/ci-workflows/install-steps.yml}}
@@ -55,12 +60,14 @@ jobs:
55
60
  run: npm run build:be
56
61
  - name: build fe
57
62
  run: npm run build:fe
63
+ {{ciBuildEnv}}
58
64
  - name: database migration checks
59
65
  run: {{ciDatabaseLint}}
60
66
 
61
67
  {{sonarJob}}
62
68
 
63
69
  db-types:
70
+ timeout-minutes: 30
64
71
  runs-on: ubuntu-latest
65
72
  steps:
66
73
  {{> app/ci-workflows/install-steps.yml}}
@@ -22,6 +22,7 @@ concurrency:
22
22
 
23
23
  jobs:
24
24
  deploy:
25
+ timeout-minutes: 30
25
26
  runs-on: ubuntu-latest
26
27
  environment: ${{ inputs.environment }}
27
28
  env:
@@ -17,6 +17,7 @@ concurrency:
17
17
 
18
18
  jobs:
19
19
  image:
20
+ timeout-minutes: 30
20
21
  runs-on: ubuntu-latest{{imageJobEnvironment}}
21
22
  env:
22
23
  STACK_ENVIRONMENT: {{imageEnvironment}}
@@ -1,4 +1,6 @@
1
1
  {{> common/gitignore.base}}
2
+ # Jest transforms reused by the CI test job
3
+ .jest-cache/
2
4
  # be: the schema Nest writes when it boots GraphQL
3
5
  schema.gql
4
6
  # fe: Next build output and its generated type file
@@ -1,4 +1,6 @@
1
1
  {{> common/gitignore.base}}
2
+ # Jest transforms reused by the CI test job
3
+ .jest-cache/
2
4
  # Supabase CLI state is local Docker metadata, never application source.
3
5
  supabase/.temp/
4
6
  supabase/.branches/
@@ -148,6 +148,7 @@ ci:
148
148
  services:
149
149
  sonar:
150
150
  provider: sonarqube
151
+ optional: true
151
152
  mode: local
152
153
  qualityGate: {{sonarGate}}
153
154
  host: {local: http://127.0.0.1:9000, public: "https://sonar.{{project}}.example.invalid"}
@@ -193,6 +193,7 @@ ci:
193
193
  services:
194
194
  sonar:
195
195
  provider: sonarqube
196
+ optional: true
196
197
  mode: local
197
198
  qualityGate: {{sonarGate}}
198
199
  host: {local: http://127.0.0.1:9000, public: "https://sonar.{{project}}.example.invalid"}
@@ -43,6 +43,7 @@ ci:
43
43
  services:
44
44
  sonar:
45
45
  provider: sonarqube
46
+ optional: true
46
47
  mode: disabled
47
48
  reason: Enable the repository's Sonar service when a host and project token are provisioned.
48
49
  qualityGate: starci-quality