@starci/hfs 4.4.1 → 4.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/CHANGELOG.md +5 -0
  2. package/ci/cli.mjs +64 -0
  3. package/ci/github.mjs +41 -0
  4. package/ci/secret.mjs +28 -0
  5. package/ci/vars.mjs +33 -0
  6. package/ci/verify.mjs +36 -0
  7. package/db/backup-receipt.mjs +129 -0
  8. package/db/dump.mjs +66 -0
  9. package/db/migrate.mjs +4 -1
  10. package/db/reset.mjs +125 -0
  11. package/emit/db-types.mjs +98 -5
  12. package/lint/run.mjs +28 -6
  13. package/package.json +2 -1
  14. package/release/ci-red.mjs +70 -0
  15. package/release/gate.mjs +50 -10
  16. package/release/index.mjs +6 -4
  17. package/runtime/knowledge/hfs/canon-pins.yaml +4 -4
  18. package/runtime/knowledge/hfs/rules.yaml +95 -4
  19. package/runtime/modules/kernel/failure-codes.yaml +60 -0
  20. package/runtime/modules/schemas/application-stacks.schema.yaml +131 -1
  21. package/runtime/packages/cli/src/catalog.generated.mjs +243 -20
  22. package/runtime/scripts/hfs/ci-declaration.mjs +102 -0
  23. package/runtime/scripts/hfs/rules/a15-findings.mjs +9 -0
  24. package/runtime/scripts/hfs/rules/app-workflow-findings.mjs +10 -0
  25. package/runtime/scripts/hfs/rules/ci-custody.mjs +104 -0
  26. package/runtime/scripts/hfs/rules/deploy-workflow.mjs +6 -19
  27. package/runtime/scripts/hfs/rules/fe-build-args.mjs +142 -0
  28. package/runtime/scripts/hfs/rules/supply-chain.mjs +40 -3
  29. package/runtime/scripts/hfs/rules/workflow-contexts.mjs +103 -0
  30. package/runtime/scripts/lib/secret-patterns.mjs +2 -2
  31. package/src/main.mjs +17 -6
  32. package/stack/context.mjs +13 -1
  33. package/stack/docker-transport.mjs +93 -15
  34. package/stack/release-store.mjs +12 -7
  35. package/stack/remote.mjs +14 -6
  36. package/stack/ssh-session.mjs +178 -0
  37. package/sync/ci-commands.mjs +103 -1
  38. package/sync/index.mjs +41 -16
  39. package/sync/quality-services.mjs +61 -0
  40. package/sync/readme-badges.mjs +44 -0
  41. package/sync/stack-images.mjs +29 -3
  42. package/templates/app/ci-workflows/github/workflows/ci.yml +26 -24
  43. package/templates/app/ci-workflows/github/workflows/images.yml +24 -2
  44. package/templates/app/ci-workflows/github/workflows/stack-deploy.yml +8 -2
  45. package/templates/app/ci-workflows/install-steps.yml +21 -0
  46. package/templates/app/ci-workflows/sonar-steps.yml +3 -9
  47. package/templates/app/ci-workflows-lite/github/workflows/ci.yml +28 -35
  48. package/templates/app/ci-workflows-lite/github/workflows/db-deploy.yml +2 -1
  49. package/templates/app/ci-workflows-lite/github/workflows/images.yml +24 -2
  50. package/templates/app/ci-workflows-lite/github/workflows/stack-deploy.yml +8 -2
  51. package/templates/app/quality-config/codecov.yml +6 -6
  52. package/templates/app/quality-config/sonar-project.properties +1 -0
  53. package/templates/app/readme-badges/README.md +1 -0
  54. package/templates/app/skeleton/.github/dependabot.yml +1 -14
  55. package/templates/app/skeleton/.starcistacks/application-stacks.yaml +61 -3
  56. package/templates/app/skeleton/README.md +6 -0
  57. package/templates/app/skeleton-lite/.github/dependabot.yml +1 -14
  58. package/templates/app/skeleton-lite/.starcistacks/application-stacks.yaml +57 -3
  59. package/templates/app/skeleton-lite/README.md +6 -0
  60. package/templates/app/upgrade-full/.starcistacks/application-stacks.yaml +52 -1
package/emit/db-types.mjs CHANGED
@@ -8,7 +8,8 @@
8
8
  * schemas `supabase/config.toml` declares under [api]; the CLI reaches Docker, so nothing here runs inside a check that has
9
9
  * no stack. `run` is the command runner - `(file, args, {cwd})` answering the spawnSync result `{status, stdout, stderr}` -
10
10
  * the default spawns the `supabase` binary with an argument array, never a shell string and never printing env; a spec's fake
11
- * keeps Docker out of the suite. A run that cannot produce the text throws DbTypesError (HFS_EMIT_DB_TYPES_FAILED).
11
+ * keeps Docker out of the suite. Before generation, `migration list --local` must prove that the database's applied versions
12
+ * equal the migration filename stamps (HFS_EMIT_DB_TYPES_STALE otherwise). Unreadable history or failed generation throws HFS_EMIT_DB_TYPES_FAILED.
12
13
  */
13
14
  import { createHash } from 'node:crypto';
14
15
  import { spawnSync } from 'node:child_process';
@@ -44,12 +45,12 @@ const DB_TYPES_SLOT = 'app.supabase.types';
44
45
  const CONFIG_FILE = 'supabase/config.toml';
45
46
  const SUPABASE_CLI = 'supabase';
46
47
 
47
- /** A types emit that could not run: the code modules/kernel/failure-codes.yaml catalogues as HFS_EMIT_DB_TYPES_FAILED. */
48
+ /** A types emit refuses unreadable history or failed generation with HFS_EMIT_DB_TYPES_FAILED, and unequal applied versions with HFS_EMIT_DB_TYPES_STALE. */
48
49
  class DbTypesError extends Error {
49
- constructor(message, details = {}) {
50
- super(`HFS_EMIT_DB_TYPES_FAILED: ${message}`);
50
+ constructor(message, details = {}, code = 'HFS_EMIT_DB_TYPES_FAILED') {
51
+ super(`${code}: ${message}`);
51
52
  this.name = 'DbTypesError';
52
- this.code = 'HFS_EMIT_DB_TYPES_FAILED';
53
+ this.code = code;
53
54
  this.details = details;
54
55
  }
55
56
  }
@@ -97,6 +98,97 @@ function apiSchemas(root) {
97
98
  return (toml?.api?.schemas ?? []).filter((schema) => typeof schema === 'string');
98
99
  }
99
100
 
101
+ /** CLI JSON and table rows use the remote field for the database history even with --local; local only describes files. */
102
+ function historyVersion(value) {
103
+ if (value == null || value === '') return null;
104
+ if (typeof value !== 'string' || !/^\d+$/.test(value)) {
105
+ throw new DbTypesError('the local migration history contains an unreadable row', { file: dbTypesPath });
106
+ }
107
+ return value;
108
+ }
109
+
110
+ /** JSON must explicitly carry a migrations array; an empty array proves an empty database history. */
111
+ function jsonAppliedVersions(text) {
112
+ let history;
113
+ try { history = JSON.parse(text); } catch {
114
+ throw new DbTypesError('the local migration history contains unreadable JSON', { file: dbTypesPath });
115
+ }
116
+ if (!Array.isArray(history?.migrations)) {
117
+ throw new DbTypesError('the local migration history has no migrations array', { file: dbTypesPath });
118
+ }
119
+ const versions = new Set();
120
+ for (const row of history.migrations) {
121
+ if (!row || !Object.hasOwn(row, 'local') || !Object.hasOwn(row, 'remote')) {
122
+ throw new DbTypesError('the local migration history contains an unreadable row', { file: dbTypesPath });
123
+ }
124
+ historyVersion(row.local);
125
+ const applied = historyVersion(row.remote);
126
+ if (applied) versions.add(applied);
127
+ }
128
+ return versions;
129
+ }
130
+
131
+ /** Optional outer borders do not add columns; pretty output may wrap each table cell in Markdown backticks. */
132
+ const historyCells = (line, bordered) => {
133
+ const row = bordered ? line.trim().replace(/^\|/, '').replace(/\|$/, '') : line;
134
+ return row.split('|').map((cell) => cell.trim().replace(/^`(.*)`$/, '$1'));
135
+ };
136
+
137
+ /** A recognized header, including a header without rows, proves a table history; malformed rows still refuse. */
138
+ function tableAppliedVersions(lines, header) {
139
+ const versions = new Set();
140
+ const bordered = /^\s*\|/.test(lines[header]);
141
+ const width = historyCells(lines[header], bordered).length;
142
+ for (const line of lines.slice(header + 1)) {
143
+ if (/^[\s|+-]+$/.test(line)) continue;
144
+ if (!line.includes('|')) continue;
145
+ const cells = historyCells(line, bordered);
146
+ if (cells.length !== width) {
147
+ throw new DbTypesError('the local migration history contains an unreadable row', { file: dbTypesPath });
148
+ }
149
+ historyVersion(cells[0]);
150
+ const applied = historyVersion(cells[1]);
151
+ if (applied) versions.add(applied);
152
+ }
153
+ return versions;
154
+ }
155
+
156
+ /** Read either CLI stream, stripping terminal colors, and require an explicit JSON history or Local | Remote [| Time] header. */
157
+ function appliedVersions(result) {
158
+ for (const output of [result.stdout, result.stderr]) {
159
+ const text = String(output ?? '').replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '').replaceAll('\u2502', '|').trim();
160
+ const lines = text.split(/\r?\n/);
161
+ const json = lines.findIndex((line) => line.trimStart().startsWith('{'));
162
+ if (json >= 0) return jsonAppliedVersions(lines.slice(json).join('\n'));
163
+ const header = lines.findIndex((line) => /^\s*\|?\s*Local\s*\|\s*Remote\s*(?:\|\s*Time(?:\s*\(UTC\))?\s*)?\|?\s*$/i.test(line));
164
+ if (header >= 0) return tableAppliedVersions(lines, header);
165
+ }
166
+ throw new DbTypesError('the local migration history is unreadable; expected migrations JSON or the Local | Remote migration list', { file: dbTypesPath });
167
+ }
168
+
169
+ /** Every applied local version must equal a migration filename stamp, including gaps and extra versions even when the heads match. */
170
+ function requireCurrentDatabase(root, run) {
171
+ const dir = path.join(root, MIGRATIONS_DIR);
172
+ const expected = new Set();
173
+ for (const name of (fs.existsSync(dir) ? fs.readdirSync(dir) : []).filter((file) => file.endsWith('.sql'))) {
174
+ const stamp = /^(\d+)(?:_|\.sql$)/.exec(name)?.[1];
175
+ if (!stamp) throw new DbTypesError(`${MIGRATIONS_DIR} contains a SQL file without a migration version`, { file: dbTypesPath });
176
+ expected.add(stamp);
177
+ }
178
+ let result;
179
+ try { result = run(SUPABASE_CLI, ['migration', 'list', '--local'], { cwd: root }); } catch {
180
+ throw new DbTypesError('supabase migration list --local could not run; the local migration history must be readable before emitting types', { file: dbTypesPath });
181
+ }
182
+ if (result?.error || result?.signal || result?.status !== 0) {
183
+ throw new DbTypesError('supabase migration list --local failed; the local migration history must be readable before emitting types', { file: dbTypesPath });
184
+ }
185
+ const applied = appliedVersions(result);
186
+ if (applied.size !== expected.size || [...expected].some((version) => !applied.has(version))) {
187
+ const head = (versions) => [...versions].sort().at(-1) ?? 'none';
188
+ throw new DbTypesError(`the local database is at ${head(applied)}, the migrations folder at ${head(expected)}; run "npm run db:reset" then emit again`, { file: dbTypesPath }, 'HFS_EMIT_DB_TYPES_STALE');
189
+ }
190
+ }
191
+
100
192
  /**
101
193
  * The freshly generated text of `dbTypesPath` for the app at `root`: `supabase gen types typescript --local` (the running
102
194
  * local stack), narrowed to the [api] schemas of supabase/config.toml. A failing CLI, a spawn error and an empty answer all
@@ -104,6 +196,7 @@ function apiSchemas(root) {
104
196
  */
105
197
  function emitDbTypesWithRunner({ root, run }) {
106
198
  const schemas = apiSchemas(root);
199
+ requireCurrentDatabase(root, run);
107
200
  const args = ['gen', 'types', 'typescript', '--local', ...(schemas.length ? ['--schema', schemas.join(',')] : [])];
108
201
  const command = `${SUPABASE_CLI} ${args.join(' ')}`;
109
202
  let result;
package/lint/run.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  // starci app lint - the ONE lint entry and the ONE report of a StarCi app.
2
2
  //
3
- // starci app lint [--cwd <dir>] [--changed <file>...] [--workspace <dir>] [--fix] [--format text|json] [--sonar <file>]
3
+ // starci app lint [--cwd <dir>] [--changed <file>...] [--workspace <dir>] [--side be|fe] [--fix] [--format text|json] [--sonar <file>]
4
4
  //
5
5
  // It runs at the app root (the folder of hfs.json), over the app (or over the app-relative files named by --changed):
6
6
  // 1. ESLint once per side, from the side folder, through the app's own install and that side's eslint.config.mjs (be/ the be canon,
@@ -26,6 +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
30
  export const LINT_SCHEMA = 'starci/lint@1';
30
31
  /** The side whose stylesheets stylelint judges. */
31
32
  const STYLE_SIDE = 'fe';
@@ -41,6 +42,22 @@ export function workspaceOf(workspace) {
41
42
  return rel;
42
43
  }
43
44
 
45
+ /** Side scopes partition the full lint; Sonar requires the complete report and a frontend workspace cannot belong to be. */
46
+ function validateSide(opts) {
47
+ if (opts.side === undefined) return;
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
+ if (opts.side === 'be' && opts.workspace !== undefined) throw new Error('LINT_SIDE_WORKSPACE_CONFLICT: --side be cannot judge a frontend workspace');
51
+ }
52
+
53
+ /** Side findings stay with their side; findings outside both sides belong to the backend lint. */
54
+ function findingOnSide(finding, side) {
55
+ if (side === undefined) return true;
56
+ const file = posix(finding.path ?? '');
57
+ const owner = SIDES.find((candidate) => file === candidate || file.startsWith(`${candidate}/`));
58
+ return (owner ?? 'be') === side;
59
+ }
60
+
44
61
  /** The flags of `starci app lint`; `--changed` takes every argument up to the next flag, and a repeated `--changed` adds to the list (the
45
62
  * catalog's list flag, `--changed a --changed b`, as the CLI dispatcher passes it through). */
46
63
  export function parseLintArgs(argv) {
@@ -52,13 +69,15 @@ export function parseLintArgs(argv) {
52
69
  while (i + 1 < argv.length && !argv[i + 1].startsWith('--')) opts.changed.push(posix(argv[++i]));
53
70
  } else if (arg === '--fix') opts.fix = true;
54
71
  else if (arg === '--json') opts.format = 'json';
55
- else if (['--format', '--sonar', '--workspace'].includes(arg)) {
72
+ else if (['--format', '--sonar', '--workspace', '--side'].includes(arg)) {
73
+ if (arg === '--side' && (argv[i + 1] === undefined || argv[i + 1].startsWith('--'))) throw new Error('LINT_SIDE_INVALID: --side needs be or fe');
56
74
  if (argv[i + 1] === undefined) throw new Error(`${arg} needs a value`);
57
75
  opts[arg.slice(2)] = argv[++i];
58
76
  } else throw new Error(`unknown argument ${arg}`);
59
77
  }
60
78
  if (!['text', 'json'].includes(opts.format)) throw new Error('--format is text or json');
61
79
  if (opts.workspace !== undefined && opts.changed !== null) throw new Error('--workspace and --changed do not combine: a workspace lint judges the whole workspace');
80
+ validateSide(opts);
62
81
  return opts;
63
82
  }
64
83
 
@@ -182,7 +201,7 @@ function checkedEslintResults({ results, expected, cwd, scope, changed }) {
182
201
  /** Starts the ESLint child of every side that has work and returns one record per side: skipped, a scope error, or the running child. */
183
202
  async function launchEslint({ app, repoRoot, opts, changed, existing, workspace, onFe }) {
184
203
  const launches = [];
185
- for (const side of app ? SIDES : []) {
204
+ for (const side of app ? SIDES.filter((candidate) => opts.side === undefined || opts.side === candidate) : []) {
186
205
  if (workspace !== null && side !== STYLE_SIDE) { launches.push({ side, skipped: `outside --workspace ${workspace}` }); continue; }
187
206
  const sources = onSide(existing, side).filter((file) => /\.(?:[cm]?[jt]sx?)$/.test(file));
188
207
  if (changed !== null && !sources.length) { launches.push({ side, skipped: 'no changed source file' }); continue; }
@@ -197,6 +216,7 @@ async function launchEslint({ app, repoRoot, opts, changed, existing, workspace,
197
216
 
198
217
  /** Starts stylelint over the fe stylesheets (the running child), or returns null when no stylesheet is in scope. */
199
218
  function launchStylelint({ repoRoot, opts, changed, existing, onFe }) {
219
+ if (opts.side === 'be') return null;
200
220
  const styles = onSide(existing, STYLE_SIDE).filter((file) => file.endsWith('.css'));
201
221
  if (changed !== null && !styles.length) return null;
202
222
  return runLinter({ cwd: path.join(repoRoot, STYLE_SIDE), pkg: 'stylelint', bin: 'stylelint', args: [...(changed ? styles : [onFe ? `${onFe}/src/**/*.css` : STYLE_GLOB]), '--formatter', 'json', ...(opts.fix ? ['--fix'] : [])] });
@@ -210,6 +230,8 @@ export async function lintRepository({ repoRoot, opts, hfsCheck, trackedFiles =
210
230
  const changed = opts.changed === null ? null : new Set(opts.changed);
211
231
  const errors = [];
212
232
  let workspace = null;
233
+ let validSide = true;
234
+ try { validateSide(opts); } catch (error) { errors.push(String(error?.message ?? error)); validSide = false; }
213
235
  if (opts.workspace !== undefined) {
214
236
  try { workspace = workspaceOf(opts.workspace); } catch (error) { errors.push(String(error?.message ?? error)); }
215
237
  }
@@ -223,7 +245,7 @@ export async function lintRepository({ repoRoot, opts, hfsCheck, trackedFiles =
223
245
  let app = null;
224
246
  try { app = readRepoDeclaration(loadSlotManifest(), repoRoot); } catch (error) { errors.push(`starci app lint runs at the app root: ${String(error?.message ?? error)}`); }
225
247
  if (app && !app.sides) { errors.push(`${repoRoot} is the ${app.side} side of an app; starci app lint runs at the app root, the folder of hfs.json`); app = null; }
226
- if (opts.workspace !== undefined && workspace === null) app = null;
248
+ if (!validSide || (opts.workspace !== undefined && workspace === null)) app = null;
227
249
  if (workspace !== null && !fs.existsSync(path.join(repoRoot, workspace, 'package.json'))) { errors.push(`--workspace ${workspace} holds no package.json; it is not a workspace of this app`); app = null; }
228
250
  /** The workspace folder relative to the fe side folder (apps/<app> or packages/<pkg>). */
229
251
  const onFe = workspace === null ? null : workspace.slice(STYLE_SIDE.length + 1);
@@ -277,7 +299,7 @@ export async function lintRepository({ repoRoot, opts, hfsCheck, trackedFiles =
277
299
  if (settled.error) errors.push(`starci app check could not run: ${String(settled.error?.message ?? settled.error)}`);
278
300
  else checked = settled.value;
279
301
  }
280
- const repoErrors = checked.findings.filter((finding) => finding.level === 'error');
302
+ const repoErrors = checked.findings.filter((finding) => finding.level === 'error' && findingOnSide(finding, opts.side));
281
303
  // A workspace lint keeps only the app findings inside the workspace; the rest belong to the root lint of the whole app.
282
304
  const kept = workspace !== null
283
305
  ? repoErrors.filter((finding) => finding.path && inWorkspace(finding.path))
@@ -297,7 +319,7 @@ export async function lintRepository({ repoRoot, opts, hfsCheck, trackedFiles =
297
319
  linterReport('eslint', raw.eslint, { root: repoRoot, sourceRoots, tracked }),
298
320
  linterReport('stylelint', raw.stylelint, { root: repoRoot, sourceRoots, tracked }),
299
321
  ]);
300
- const report = { schema: LINT_SCHEMA, ok: findings.length === 0 && errors.length === 0, repoRoot, changed: changed ? [...changed].sort(byCodeUnit) : null, workspace, counts: { error: findings.length }, engines, errors, findings };
322
+ const report = { schema: LINT_SCHEMA, ok: findings.length === 0 && errors.length === 0, repoRoot, changed: changed ? [...changed].sort(byCodeUnit) : null, workspace, ...(opts.side === undefined ? {} : { side: opts.side }), counts: { error: findings.length }, engines, errors, findings };
301
323
  return { report, sonar, exit: errors.length ? 2 : findings.length ? 1 : 0 };
302
324
  }
303
325
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@starci/hfs",
3
- "version": "4.4.1",
3
+ "version": "4.5.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",
@@ -21,6 +21,7 @@
21
21
  "stack/**",
22
22
  "release/**",
23
23
  "db/**",
24
+ "ci/**",
24
25
  "lint/**",
25
26
  "scaffold/**",
26
27
  "report/**",
@@ -0,0 +1,70 @@
1
+ // ci-red.mjs - the latest completed default-branch CI is a release precondition; unreadable GitHub warns, a red run needs the owner's SHA-bound override.
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { refusal } from '../stack/errors.mjs';
5
+
6
+ const FIELDS = 'databaseId,headSha,status,conclusion,url,event,createdAt';
7
+ const SHA = /^[a-f0-9]{40}$/u;
8
+ const RED = new Set(['failure', 'cancelled', 'timed_out', 'startup_failure', 'action_required']);
9
+
10
+ /** Every process uses the supplied seam; an unavailable executable is an unreadable result. */
11
+ function run(spawn, cwd, command, args) {
12
+ try { return spawn(command, args, { cwd }); } catch { return { status: null }; }
13
+ }
14
+
15
+ function runsOf(result) {
16
+ if (result.error || result.status !== 0) return null;
17
+ try {
18
+ const rows = JSON.parse(result.stdout);
19
+ return Array.isArray(rows) ? rows : null;
20
+ } catch { return null; }
21
+ }
22
+
23
+ /** 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
+ export function latestDefaultBranchCi({ spawn, cwd, defaultBranch = 'main' }) {
25
+ const args = ['run', 'list', '--workflow', 'ci.yml'];
26
+ const tail = ['--limit', '20', '--json', FIELDS];
27
+ 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)' };
29
+ 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
+ }
39
+ const completed = candidates.filter((row) => row.status === 'completed' && Number.isFinite(Date.parse(row.createdAt)));
40
+ completed.sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt) || Number(b.databaseId) - Number(a.databaseId));
41
+ 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' };
43
+ if (latest.conclusion === 'success') return { state: 'green', run: latest };
44
+ 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 };
46
+ }
47
+
48
+ /** Only a complete starci/ci-override@1 record for this exact commit can excuse a red run. */
49
+ function overrideOf(ctx, sha) {
50
+ const common = run(ctx.spawn, ctx.cwd, 'git', ['rev-parse', '--git-common-dir']);
51
+ if (common.error || common.status !== 0 || !String(common.stdout ?? '').trim()) return null;
52
+ const file = path.join(path.resolve(ctx.cwd, String(common.stdout).trim()), 'starci-release', `${sha}.ci-override.json`);
53
+ try {
54
+ const record = JSON.parse(fs.readFileSync(file, 'utf8'));
55
+ if (record.schema !== 'starci/ci-override@1' || record.sha !== sha || typeof record.reason !== 'string' || !record.reason.trim() || typeof record.by !== 'string' || !record.by.trim() || typeof record.at !== 'string' || !Number.isFinite(Date.parse(record.at))) return null;
56
+ return record;
57
+ } catch { return null; }
58
+ }
59
+
60
+ /** Warns on unknown CI and refuses red CI unless the owner left a valid override beside the release records. */
61
+ export function requireGreenCi(ctx, ci) {
62
+ if (ci.state === 'unknown') {
63
+ ctx.stderr(`RELEASE_CI_UNKNOWN: ${ci.why}\n`);
64
+ return [];
65
+ }
66
+ if (ci.state !== 'red') return [];
67
+ const record = overrideOf(ctx, ci.run.headSha);
68
+ if (record) return [`RELEASE_CI_RED overridden by the owner: ${record.reason}`];
69
+ throw refusal('RELEASE_CI_RED', `CI run ${ci.run.url} for ${ci.run.headSha} concluded ${ci.run.conclusion}; the owner must use starci release ci-override --sha ${ci.run.headSha} --reason <text>`);
70
+ }
package/release/gate.mjs CHANGED
@@ -7,8 +7,10 @@ import { createHash } from 'node:crypto';
7
7
  import fs from 'node:fs';
8
8
  import path from 'node:path';
9
9
  import { isPlainObject as plain } from '../runtime/engine/plain-object.mjs';
10
+ import { qualityServices } from '../runtime/scripts/hfs/ci-declaration.mjs';
10
11
  import { firstLine, lines, tool } from '../stack/context.mjs';
11
12
  import { failure, refusal } from '../stack/errors.mjs';
13
+ import { latestDefaultBranchCi, requireGreenCi } from './ci-red.mjs';
12
14
 
13
15
  export const RELEASE_CODE = 'RELEASE_CONFIG_INVALID';
14
16
 
@@ -35,10 +37,40 @@ const scriptGate = (script) => ({
35
37
  env: {},
36
38
  });
37
39
 
38
- /** The gates derived from the managed package scripts: the app check first, then every script the sync renders that the app has. */
39
- function defaultGates(scripts) {
40
+ /** Default gates run app-check, the enabled audit, then every managed script present in the app. */
41
+ function defaultGates(scripts, audit) {
40
42
  const present = SCRIPT_GATES.filter((script) => typeof scripts?.[script] === 'string');
41
- return [{ name: 'app-check', command: ['npx', 'starci', 'app', 'check'], summary: null, env: {} }, ...present.map(scriptGate)];
43
+ return [{ name: 'app-check', command: ['npx', 'starci', 'app', 'check'], summary: null, env: {} }, ...(audit ? [audit] : []), ...present.map(scriptGate)];
44
+ }
45
+
46
+ /** Shared quality settings retain the release reader's audit validation, disabled mode and legacy string host compatibility. */
47
+ function releaseServices(services, options) {
48
+ const audit = services?.audit ?? {};
49
+ if (!plain(audit) || (audit.in !== undefined && (!Array.isArray(audit.in) || !audit.in.every((scope) => typeof scope === 'string'))) || (audit.omitDev !== undefined && typeof audit.omitDev !== 'boolean')) throw refusal(RELEASE_CODE, 'services.audit must be a mapping with a string-list in and boolean omitDev');
50
+ const sonar = services?.sonar;
51
+ const compatible = typeof sonar?.host === 'string' ? { ...sonar, host: { public: sonar.host } } : sonar;
52
+ const quality = qualityServices({ services: { ...services, sonar: compatible } }, options);
53
+ if (audit.mode === 'disabled') quality.audit = null;
54
+ return quality;
55
+ }
56
+
57
+ /** The shared audit settings preserve release scope, level and development-dependency selection. */
58
+ function auditGate(audit) {
59
+ if (!audit || !audit.in.includes('release-gate')) return null;
60
+ if (!['info', 'low', 'moderate', 'high', 'critical'].includes(audit.level)) throw refusal(RELEASE_CODE, 'services.audit.level must be an npm audit level');
61
+ return { name: 'audit', command: ['npm', 'audit', ...(audit.omitDev ? ['--omit=dev'] : []), `--audit-level=${audit.level}`], summary: null, env: {} };
62
+ }
63
+
64
+ /** A requested local Sonar scan uses only the declared host and token variable; the token never enters argv. */
65
+ function sonarGate(services, env) {
66
+ const sonar = services?.sonar;
67
+ const tokenName = sonar?.token;
68
+ 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
+ const command = ['npx', '--yes', '@sonar/scan@4.3.0', `-Dsonar.host.url=${sonar.host}`];
70
+ const organization = sonar.organization ?? (sonar.organizationVar ? env[sonar.organizationVar] : null);
71
+ if (organization) command.push(`-Dsonar.organization=${organization}`);
72
+ 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] } };
42
74
  }
43
75
 
44
76
  function gateProblems(entry, at, seen) {
@@ -74,7 +106,7 @@ function gatesOf(list, problems) {
74
106
  }
75
107
 
76
108
  /** Judges the optional `release:` block. Absence is fine (defaults). Returns { config } or { problems }. */
77
- export function normalizeRelease(block, scripts = {}) {
109
+ export function normalizeRelease(block, scripts = {}, services = {}, options = {}) {
78
110
  const raw = block ?? {};
79
111
  if (!plain(raw)) return { problems: ['release: must be a mapping'] };
80
112
  const problems = Object.keys(raw).filter((key) => !FIELDS.has(key)).map((key) => `release.${key}: is not a field of the release block`);
@@ -84,8 +116,11 @@ export function normalizeRelease(block, scripts = {}) {
84
116
  if (typeof tagPrefix !== 'string' || !PREFIX.test(tagPrefix)) problems.push(`release.tagPrefix: must match ${PREFIX}`);
85
117
  if (!refName(defaultBranch)) problems.push(`release.defaultBranch: must match ${REF_NAME}`);
86
118
  if (typeof tagMatchesVersion !== 'boolean') problems.push('release.tagMatchesVersion: must be true or false');
87
- const gates = raw.gates === undefined ? defaultGates(scripts) : gatesOf(raw.gates, problems);
88
- return problems.length === 0 ? { config: { gates, tagPrefix, defaultBranch, tagMatchesVersion } } : { problems };
119
+ const quality = releaseServices(services, options);
120
+ const audit = auditGate(quality.audit);
121
+ const gates = raw.gates === undefined ? defaultGates(scripts, audit) : gatesOf(raw.gates, problems);
122
+ if (raw.gates !== undefined && audit && !gates.some((gate) => gate.name === 'audit')) gates.push(audit);
123
+ return problems.length === 0 ? { config: { gates, tagPrefix, defaultBranch, tagMatchesVersion, services: quality } } : { problems };
89
124
  }
90
125
 
91
126
  /* ---- git through the seam */
@@ -179,9 +214,11 @@ function runGate(ctx, gate, { dir, platform }) {
179
214
  const started = ctx.now();
180
215
  const [command, args] = launch(gate.command, platform);
181
216
  const run = tool(ctx, command, args, { cwd: ctx.cwd, env: { ...ctx.env, ...gate.env } });
182
- const text = `${run.stdout ?? ''}${run.stderr ?? ''}`;
217
+ const output = `${run.stdout ?? ''}${run.stderr ?? ''}`;
218
+ const text = gate.name === 'sonar' && gate.env.SONAR_TOKEN ? output.replaceAll(gate.env.SONAR_TOKEN, '[REDACTED]') : output;
183
219
  fs.writeFileSync(log, text);
184
220
  const verdict = judgeVerdict(run, gate, text);
221
+ if (gate.name === 'sonar' && gate.env.SONAR_TOKEN) verdict.detail = verdict.detail.replaceAll(gate.env.SONAR_TOKEN, '[REDACTED]');
185
222
  const seconds = Math.round((ctx.now() - started) / 1000);
186
223
  return {
187
224
  name: gate.name, command: gate.command.join(' '), pass: verdict.pass, detail: verdict.detail, seconds,
@@ -274,15 +311,18 @@ const summaryOf = (result) => `${result.pass ? 'PASS' : 'FAIL'} ${result.name} (
274
311
 
275
312
  /**
276
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,
277
- * writes the L4 record and creates the annotated tag. options: { execute, source, tagPrefix, platform }.
314
+ * writes the L4 record and creates the annotated tag. options: { execute, sonar, source, tagPrefix, platform }.
278
315
  */
279
316
  export async function releaseGate(ctx, config, options = {}) {
317
+ if (options.sonar) config = { ...config, gates: [...config.gates.filter((gate) => gate.name !== 'sonar'), sonarGate(config.services, ctx.env)] };
318
+ const ci = latestDefaultBranchCi({ spawn: ctx.spawn, cwd: ctx.cwd, defaultBranch: config.defaultBranch });
319
+ const ciLines = requireGreenCi(ctx, ci);
280
320
  const facts = inspect(ctx, config, options);
281
321
  const common = releaseDir(ctx);
282
322
  const recordPath = path.join(common, `${facts.head}.l4.json`);
283
323
  const dir = path.join(common, facts.head.slice(0, 12));
284
- const plan = [...facts.out, ...planLines(config, facts, dir, recordPath)];
285
- const base = { head: facts.head, tag: facts.chosen.tag, record: recordPath, gates: config.gates.map((gate) => gate.name) };
324
+ const plan = [...ciLines, ...facts.out, ...planLines(config, facts, dir, recordPath)];
325
+ const base = { head: facts.head, tag: facts.chosen.tag, record: recordPath, gates: config.gates.map((gate) => gate.name), ci: { state: ci.state, notices: ciLines } };
286
326
  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 } };
287
327
  if (options.execute !== true) return { ok: true, lines: [...plan, '', 'dry run: no gate was run, nothing written. preconditions OK.'], data: { ...base, red: [], executed: false } };
288
328
  const results = runAll(ctx, config, { dir, platform: options.platform ?? process.platform });
package/release/index.mjs CHANGED
@@ -1,12 +1,13 @@
1
1
  // index.mjs - `starci app release <gate|push>` and the plumbing the release and database verbs share: the context built from the app's IO, strict flag
2
2
  // parsing, the optional `release:` / `database:` blocks of .starcistacks/application-stacks.yaml, text and --json output and the exit-code mapping.
3
- // starci app release gate [--execute] [--source <ref>] [--tag-prefix <prefix>] [--json]
3
+ // starci app release gate [--execute] [--sonar] [--source <ref>] [--tag-prefix <prefix>] [--json]
4
4
  // starci app release push [--execute] [--remote <name>] [--json]
5
5
  // Every action is a dry run unless --execute is given. A refusal is a StackError (exit 2); a failed step returns ok false (exit 1).
6
6
  import fs from 'node:fs';
7
7
  import path from 'node:path';
8
8
  import { isPlainObject as plain } from '../runtime/engine/plain-object.mjs';
9
9
  import { parseYaml } from '../runtime/engine/yaml.mjs';
10
+ import { isRuntimeExampleApp } from '../runtime/scripts/lib/runtime-example.mjs';
10
11
  import { STACKS_DIR, STACKS_FILE } from '../stack/config.mjs';
11
12
  import { stackContext } from '../stack/context.mjs';
12
13
  import { EXIT, StackError, refusal } from '../stack/errors.mjs';
@@ -56,7 +57,8 @@ function readPackage(root) {
56
57
  function loadReleaseConfig(root) {
57
58
  const pkg = readPackage(root);
58
59
  if (typeof pkg.version !== 'string' || !VERSION.test(pkg.version)) throw refusal(RELEASE_CODE, 'package.json needs a version like 1.2.3 (the release tag derives from it)');
59
- const { config, problems } = normalizeRelease(readDeclaration(root).release, pkg.scripts);
60
+ const declaration = readDeclaration(root);
61
+ const { config, problems } = normalizeRelease(declaration.release, pkg.scripts, declaration.services, { example: isRuntimeExampleApp(root) });
60
62
  if (!config) throw refusal(RELEASE_CODE, `${problems.length} problem(s) in release: ${problems.slice(0, 6).join('; ')}`);
61
63
  return { root, version: pkg.version, ...config };
62
64
  }
@@ -83,8 +85,8 @@ export async function runVerb({ label, name, io, argv, spec, run, code = 'RELEAS
83
85
 
84
86
  const ACTIONS = {
85
87
  gate: {
86
- spec: { boolean: ['--execute', '--json'], value: ['--source', '--tag-prefix'] },
87
- run: (ctx, flags) => releaseGate(ctx, loadReleaseConfig(ctx.cwd), { execute: flags.execute === true, source: flags.source, tagPrefix: flags['tag-prefix'] }),
88
+ spec: { boolean: ['--execute', '--json', '--sonar'], value: ['--source', '--tag-prefix'] },
89
+ run: (ctx, flags) => releaseGate(ctx, loadReleaseConfig(ctx.cwd), { execute: flags.execute === true, sonar: flags.sonar === true, source: flags.source, tagPrefix: flags['tag-prefix'] }),
88
90
  },
89
91
  push: {
90
92
  spec: { boolean: ['--execute', '--json'], value: ['--remote'] },
@@ -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.1
32
+ version: 3.4.2
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.1
39
+ version: 8.4.2
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.4.1
79
+ version: 1.5.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.4.1
86
+ version: 4.5.0
87
87
  group: starci
88
88
  install: registry
89
89
  side: both