@starci/hfs 4.9.9 → 4.9.11

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 (40) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +14 -0
  3. package/db/drift.mjs +289 -0
  4. package/db/dump.mjs +1 -1
  5. package/db/entity-list.mjs +9 -0
  6. package/db/migrate.mjs +1 -1
  7. package/lint/run.mjs +12 -2
  8. package/package.json +5 -3
  9. package/release/gate.mjs +81 -18
  10. package/release/index.mjs +19 -8
  11. package/release/push.mjs +4 -4
  12. package/release/records.mjs +54 -0
  13. package/release/urgent.mjs +129 -0
  14. package/runtime/knowledge/hfs/canon-pins.yaml +16 -6
  15. package/runtime/knowledge/hfs/rules.yaml +74 -3
  16. package/runtime/knowledge/hfs/slots.yaml +36 -3
  17. package/runtime/modules/kernel/failure-codes.yaml +21 -0
  18. package/runtime/scripts/hfs/app-wiring.mjs +23 -2
  19. package/runtime/scripts/hfs/check.mjs +2 -2
  20. package/runtime/scripts/hfs/manifest-shape.mjs +2 -1
  21. package/runtime/scripts/hfs/rules/a15-findings.mjs +1 -1
  22. package/src/main.mjs +7 -4
  23. package/stack/cli.mjs +10 -2
  24. package/stack/deploy.mjs +21 -4
  25. package/stack/urgent-floor.mjs +147 -0
  26. package/sync/ci-commands.mjs +3 -1
  27. package/templates/app/ci-workflows/github/workflows/stack-deploy.yml +4 -1
  28. package/templates/app/ci-workflows-lite/github/workflows/stack-deploy.yml +4 -1
  29. package/templates/be/skeleton/src/modules/domain/identity/identity.decorators.ts +18 -7
  30. package/templates/be/skeleton/src/modules/platform/composition/composition.decorators.ts +3 -0
  31. package/templates/be/skeleton/src/modules/platform/composition/index.ts +1 -1
  32. package/templates/be/skeleton/src/modules/platform/errors/errors.filter.spec.ts +15 -1
  33. package/templates/be/skeleton/src/modules/platform/errors/errors.filter.ts +10 -9
  34. package/templates/be/skeleton/src/modules/platform/http-security/index.ts +1 -1
  35. package/templates/be/skeleton/src/modules/platform/http-security/origin.guard.spec.ts +34 -18
  36. package/templates/be/skeleton/src/modules/platform/http-security/origin.guard.ts +29 -8
  37. package/templates/be/skeleton/src/modules/platform/http-security/rate-limit.guard.spec.ts +80 -4
  38. package/templates/be/skeleton/src/modules/platform/http-security/rate-limit.guard.ts +39 -11
  39. package/templates/be/skeleton-lite/src/modules/domain/identity/identity.decorators.ts +18 -8
  40. package/templates/be/skeleton-lite/src/modules/platform/http-security/index.ts +1 -1
package/release/gate.mjs CHANGED
@@ -8,9 +8,11 @@ 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
10
  import { qualityServices } from '../runtime/scripts/hfs/ci-declaration.mjs';
11
+ import { openHfs } from '../runtime/scripts/hfs/slots.mjs';
11
12
  import { firstLine, lines, tool } from '../stack/context.mjs';
12
13
  import { failure, refusal } from '../stack/errors.mjs';
13
14
  import { produceLintReports } from './lint-report.mjs';
15
+ import { URGENT_RULING, l4Path, readUrgent, recordRow, writeUrgent } from './records.mjs';
14
16
  import { sonarQualityGate } from './sonar-quality-gate.mjs';
15
17
 
16
18
  export const RELEASE_CODE = 'RELEASE_CONFIG_INVALID';
@@ -38,6 +40,8 @@ const SCRIPT_GATES = ['format:check', 'typecheck', 'build:be', 'lint', 'db:lint'
38
40
  const WORLD_SCRIPTS = ['test:integration', 'test:e2e', 'test:contract'];
39
41
  const DB_TYPES_SCRIPTS = ['db:start', 'db:reset', 'db:types', 'db:stop'];
40
42
  const DB_TYPES_COMMITTED = { name: 'db-types-committed', command: ['git', 'diff', '--exit-code', '--', 'supabase/types/database.types.ts'], summary: null, env: {} };
43
+ // An app with an entity list (db/entity-list.mjs) checks its entities against the migrated schema: a row of every gate, declared or default.
44
+ const DB_DRIFT = { name: 'db-drift', command: ['npx', 'starci', 'app', 'db', 'drift'], summary: null, env: {} };
41
45
 
42
46
  /** True for a branch or remote name git can take as one argument (no leading dash, only path-like characters). */
43
47
  export const refName = (value) => typeof value === 'string' && REF_NAME.test(value);
@@ -59,11 +63,11 @@ function dbTypesGates(scripts) {
59
63
  return [start, reset, types, DB_TYPES_COMMITTED, { ...stop, teardownOf: start.name }];
60
64
  }
61
65
 
62
- /** Default gates run app-check, the enabled audit, then every managed script present in the app and the database-types rows, Sonar following when it is declared. */
63
- function defaultGates(scripts, audit) {
66
+ /** Default gates run app-check, the enabled audit, then every managed script present in the app, the database-types rows and the entity drift row, Sonar following when it is declared. */
67
+ function defaultGates(scripts, audit, entities) {
64
68
  const present = SCRIPT_GATES.filter((script) => typeof scripts?.[script] === 'string');
65
69
  const world = typeof scripts?.['test:e2e'] === 'string' ? WORLD_SCRIPTS.filter((script) => typeof scripts?.[script] === 'string') : [];
66
- return [{ name: 'app-check', command: ['npx', 'starci', 'app', 'check'], summary: null, env: {} }, ...(audit ? [audit] : []), ...present.map(scriptGate), ...world.map(scriptGate), ...dbTypesGates(scripts)];
70
+ return [{ name: 'app-check', command: ['npx', 'starci', 'app', 'check'], summary: null, env: {} }, ...(audit ? [audit] : []), ...present.map(scriptGate), ...world.map(scriptGate), ...dbTypesGates(scripts), ...(entities ? [DB_DRIFT] : [])];
67
71
  }
68
72
 
69
73
  /** Shared quality settings retain the release reader's audit validation, disabled mode and legacy string host compatibility. */
@@ -148,8 +152,9 @@ export function normalizeRelease(block, scripts = {}, services = {}, options = {
148
152
  if (typeof requireCi !== 'boolean') problems.push('release.requireCi: must be true or false');
149
153
  const quality = releaseServices(services, options);
150
154
  const audit = auditGate(quality.audit);
151
- const gates = raw.gates === undefined ? defaultGates(scripts, audit) : gatesOf(raw.gates, problems);
155
+ const gates = raw.gates === undefined ? defaultGates(scripts, audit, options.entities === true) : gatesOf(raw.gates, problems);
152
156
  if (raw.gates !== undefined && audit && !gates.some((gate) => gate.name === 'audit')) gates.push(audit);
157
+ if (raw.gates !== undefined && options.entities === true && !gates.some((gate) => gate.name === DB_DRIFT.name)) gates.push(DB_DRIFT);
153
158
  return problems.length === 0 ? { config: { gates, tagPrefix, defaultBranch, requireCi, services: quality } } : { problems };
154
159
  }
155
160
 
@@ -175,7 +180,22 @@ export function annotatedOnHead(ctx, prefix) {
175
180
  /** Every tag on HEAD whose name ends in a version, annotated or not, under any prefix: a release commit carries exactly one. */
176
181
  const versionTagsOnHead = (ctx) => lines(git(ctx, ['tag', '--points-at', 'HEAD']).stdout).filter((name) => /[0-9]+[.][0-9]+[.][0-9]+$/u.test(name));
177
182
 
178
- const dirtyPaths = (ctx) => lines(git(ctx, ['status', '--porcelain', '--untracked-files=normal']).stdout);
183
+ /** The rewrittenBy predicate of the app's slots: true for a path a dev server writes (hfs slots `rewrittenBy`); without a readable hfs.json, for none. */
184
+ function rewrittenByDevServer(root) {
185
+ let hfs;
186
+ try { hfs = openHfs({ repoRoot: root }); } catch { return () => false; }
187
+ return (file) => typeof hfs.slotOf(file)?.rewrittenBy === 'string';
188
+ }
189
+
190
+ /**
191
+ * The paths that make the tree dirty: every changed tracked path and every untracked one, except an untracked file the app's slots declare a dev
192
+ * server rewrites (`rewrittenBy`, e.g. the AGENTS.md `next dev` writes): that one is known generated output, never a change of the release.
193
+ */
194
+ export function dirtyPaths(ctx) {
195
+ const rewritten = rewrittenByDevServer(ctx.cwd);
196
+ return lines(git(ctx, ['status', '--porcelain', '--untracked-files=all']).stdout)
197
+ .filter((line) => !(line.startsWith('?? ') && rewritten(line.slice(3).replace(/^"|"$/gu, ''))));
198
+ }
179
199
 
180
200
  /** Tag names that exist locally or on the remote. */
181
201
  function takenTags(ctx, prefix, remote) {
@@ -243,7 +263,7 @@ async function sonarAvailability(ctx, gate) {
243
263
  }
244
264
 
245
265
  /** An unreachable Sonar host stays a red row with a real log and no scan invocation. */
246
- async function runAvailableGate(ctx, gate, options) {
266
+ export async function runAvailableGate(ctx, gate, options) {
247
267
  const availability = await sonarAvailability(ctx, gate);
248
268
  const log = path.join(options.dir, `${gate.name}.log`);
249
269
  if (!availability) {
@@ -274,12 +294,12 @@ async function judgeQualityGate(ctx, gate, row) {
274
294
  return { ...row, pass: verdict.pass, detail: verdict.detail, seconds: row.seconds + Math.round((ctx.now() - started) / 1000), logSha256: sha256(row.log) };
275
295
  }
276
296
 
277
- const withoutCredentials = (values, credentialNames) => {
297
+ export const withoutCredentials = (values, credentialNames) => {
278
298
  const selected = new Set(credentialNames);
279
299
  return Object.fromEntries(Object.entries(values ?? {}).filter(([key]) => !selected.has(key.toUpperCase())));
280
300
  };
281
301
 
282
- function runGate(ctx, gate, { dir, platform, credentialNames, preface = '' }) {
302
+ export function runGate(ctx, gate, { dir, platform, credentialNames, preface = '' }) {
283
303
  const log = path.join(dir, `${gate.name}.log`);
284
304
  const started = ctx.now();
285
305
  const [command, args] = launch(gate.command, platform);
@@ -323,7 +343,7 @@ function tagLine({ tag, state }) {
323
343
  }
324
344
 
325
345
  /** Everything that can be known without running a gate: the facts, the red preconditions and the lines that describe them. */
326
- function inspect(ctx, config, options) {
346
+ export function inspect(ctx, config, options) {
327
347
  const head = git(ctx, ['rev-parse', 'HEAD']).stdout;
328
348
  const branch = git(ctx, ['rev-parse', '--abbrev-ref', 'HEAD'], { allowFail: true }).stdout;
329
349
  const out = [`checkout ${ctx.cwd}`, `HEAD ${head} (${branch})`];
@@ -354,18 +374,19 @@ function planLines(config, facts, dir, recordPath) {
354
374
  return out;
355
375
  }
356
376
 
357
- function assertUnmoved(ctx, head) {
377
+ export function assertUnmoved(ctx, head) {
358
378
  const now = git(ctx, ['rev-parse', 'HEAD']).stdout;
359
379
  if (now !== head) throw failure('RELEASE_HEAD_MOVED', `HEAD moved during the gates (${head.slice(0, 10)} -> ${now.slice(0, 10)}). No record.`);
360
380
  const dirty = dirtyPaths(ctx);
361
381
  if (dirty.length > 0) throw failure('RELEASE_TREE_DIRTY', `the gates left ${dirty.length} changed or untracked path(s) (first: ${dirty[0].slice(0, 100)}): generated files differ from HEAD. Commit or ignore them properly, then rerun. No record.`);
362
382
  }
363
383
 
364
- function writeRecord(ctx, { head, tag, results, recordPath, requireCi }) {
384
+ function writeRecord(ctx, { head, tag, results, recordPath, requireCi, promotedFrom = null }) {
365
385
  const record = {
366
386
  schema: 'starci-app-l4-record/1', head, tag, createdAt: new Date(ctx.now()).toISOString(), producedBy: 'starci app release gate',
367
387
  note: 'Every gate passed on a clean HEAD; every entry links its log and sha256.',
368
388
  ci: requireCi === true ? { required: true, note: 'starci app release push waits for the candidate CI of this sha (release.requireCi=true)' } : { required: false, note: CI_NOT_REQUIRED },
389
+ ...(promotedFrom ? { promotedFrom } : {}),
369
390
  gates: results,
370
391
  };
371
392
  fs.mkdirSync(path.dirname(recordPath), { recursive: true });
@@ -381,17 +402,48 @@ async function runAll(ctx, config, options) {
381
402
  return config.gates.reduce(async (pending, gate) => {
382
403
  const results = await pending;
383
404
  if (results.some((result) => !result.pass) && !(gate.teardownOf && results.some((result) => result.name === gate.teardownOf))) return results;
384
- return [...results, await runAvailableGate(ctx, gate, options)];
405
+ const result = await runAvailableGate(ctx, gate, options);
406
+ recordRow(options.common, options.head, result);
407
+ return [...results, result];
385
408
  }, Promise.resolve([]));
386
409
  }
387
410
 
388
- function summaryOf(result) {
411
+ export function summaryOf(result) {
389
412
  return `${result.pass ? 'PASS' : 'FAIL'} ${result.name} (${result.detail}, ${result.seconds}s) log ${result.log}`;
390
413
  }
391
414
 
415
+ /**
416
+ * The urgent release a back-fill judges: `tag` must name HEAD (the back-fill runs on the very commit that shipped, never a re-release) and HEAD must
417
+ * carry an urgent record of that tag. Returns the record; refuses otherwise.
418
+ */
419
+ function backfillTarget(ctx, common, tag, head) {
420
+ if (!refName(tag)) throw refusal('RELEASE_USAGE', `--backfill ${tag} is not a tag name`);
421
+ const resolved = git(ctx, ['rev-parse', '--verify', '--quiet', `refs/tags/${tag}^{commit}`], { allowFail: true });
422
+ if (resolved.code !== 0 || resolved.stdout === '') throw refusal('RELEASE_BACKFILL_TAG_UNKNOWN', `no tag ${tag} in this repository`);
423
+ if (resolved.stdout !== head) throw refusal('RELEASE_BACKFILL_NOT_ON_TAG', `${tag} is ${resolved.stdout.slice(0, 10)} but HEAD is ${head.slice(0, 10)}: run the back-fill in a clean checkout of the tag (git worktree add --detach <dir> ${tag}), so the full gate judges exactly what shipped`);
424
+ const urgent = readUrgent(common, head);
425
+ if (!urgent || urgent.tag !== tag) throw refusal('RELEASE_BACKFILL_NOT_URGENT', `${tag} has no urgent record on ${head.slice(0, 10)}: only a release pushed with release push --urgent is back-filled`);
426
+ return urgent;
427
+ }
428
+
429
+ /** A red back-fill: the urgent record keeps the verdict and the fix ticket (kind verify) the harness opened; nothing on the remote or on prod changes. */
430
+ function backfillRed(ctx, { common, urgent, failed, openTicket }) {
431
+ const names = failed.map((item) => item.name);
432
+ const title = `RELEASE_BACKFILL_RED: the back-fill of urgent release ${urgent.tag} (${urgent.head.slice(0, 12)}) is red at ${names.join(', ')}`;
433
+ const tail = failed.map((item) => `${item.name}: ${item.detail} (log ${item.log})`).join('\n');
434
+ const ticket = typeof openTicket === 'function' ? openTicket({ title, tail, code: 'RELEASE_BACKFILL_RED', tag: urgent.tag, head: urgent.head }) : null;
435
+ writeUrgent(common, { ...urgent, backfill: { state: 'red', at: new Date(ctx.now()).toISOString(), failed: names, ticket: ticket ?? null } });
436
+ const said = ticket
437
+ ? `back-fill red: fix ticket ${ticket} opened (kind verify); ${urgent.tag} stays an urgent release and nothing on the remote or on prod changed`
438
+ : `back-fill red: no ticket queue in this run (run it through the starci CLI to open one); ${urgent.tag} stays an urgent release and nothing on the remote or on prod changed`;
439
+ return { said, ticket: ticket ?? null };
440
+ }
441
+
392
442
  /**
393
443
  * `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,
394
- * writes the L4 record and creates the annotated tag. options: { execute, sonar, source, tagPrefix, platform }.
444
+ * writes the L4 record and creates the annotated tag. options: { execute, sonar, source, tagPrefix, platform, backfill, openTicket }.
445
+ * With `backfill` (a tag pushed by `release push --urgent`, checked out at HEAD) the same full gate judges that commit: green writes its L4 record
446
+ * (promotedFrom the urgent record) and marks the urgent record promoted, with no new tag and no push; red opens a fix ticket through `openTicket`.
395
447
  */
396
448
  export async function releaseGate(ctx, config, options = {}) {
397
449
  // Sonar is a row of the managed CI, so it is a row of the local gate whenever the app declares it (and when --sonar asks for it); a missing token is a refusal, never a skip.
@@ -402,18 +454,29 @@ export async function releaseGate(ctx, config, options = {}) {
402
454
  // the gate no longer refuses over the latest default-branch run.
403
455
  const facts = inspect(ctx, config, options);
404
456
  const common = releaseDir(ctx);
405
- const recordPath = path.join(common, `${facts.head}.l4.json`);
457
+ const urgent = options.backfill === undefined ? null : backfillTarget(ctx, common, options.backfill, facts.head);
458
+ const recordPath = l4Path(common, facts.head);
406
459
  const dir = path.join(common, facts.head.slice(0, 12));
407
460
  const plan = [...facts.out, ...planLines(config, facts, dir, recordPath)];
408
- const base = { head: facts.head, tag: facts.chosen.tag, record: recordPath, gates: config.gates.map((gate) => gate.name) };
461
+ if (urgent) plan.push(`back-fill urgent release ${urgent.tag} (${URGENT_RULING}): green promotes it to this L4 record with no new tag and no push; red opens a fix ticket and changes nothing on prod`);
462
+ const base = { head: facts.head, tag: facts.chosen.tag, record: recordPath, gates: config.gates.map((gate) => gate.name), ...(urgent ? { backfill: urgent.tag } : {}) };
463
+ if (urgent?.backfill?.state === 'promoted') return { ok: true, lines: [...plan, `back-fill already promoted ${urgent.tag} to ${urgent.backfill.l4}. Nothing to do.`], data: { ...base, red: [], executed: false, promoted: true } };
409
464
  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 } };
410
465
  if (options.execute !== true) return { ok: true, lines: [...plan, '', 'dry run: no gate was run, nothing written. preconditions OK.'], data: { ...base, red: [], executed: false } };
411
- const results = await runAll(ctx, config, { dir, platform: options.platform ?? process.platform, credentialNames });
466
+ const results = await runAll(ctx, config, { dir, common, head: facts.head, platform: options.platform ?? process.platform, credentialNames });
412
467
  const report = results.map(summaryOf);
413
468
  const failed = results.filter((result) => !result.pass);
469
+ if (failed.length > 0 && urgent) {
470
+ const { said, ticket } = backfillRed(ctx, { common, urgent, failed, openTicket: options.openTicket });
471
+ return { ok: false, lines: [...plan, ...report, `gate(s) red: ${failed.map((item) => item.name).join(', ')}. No L4 record.`, said], data: { ...base, executed: true, results, promoted: false, ticket } };
472
+ }
414
473
  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 } };
415
474
  assertUnmoved(ctx, facts.head);
416
- writeRecord(ctx, { head: facts.head, tag: facts.chosen.tag, results, recordPath, requireCi: config.requireCi });
475
+ writeRecord(ctx, { head: facts.head, tag: facts.chosen.tag, results, recordPath, requireCi: config.requireCi, promotedFrom: urgent ? { record: `${facts.head}.urgent.json`, reason: urgent.reason, role: urgent.role, at: urgent.createdAt } : null });
476
+ if (urgent) {
477
+ writeUrgent(common, { ...urgent, backfill: { state: 'promoted', at: new Date(ctx.now()).toISOString(), l4: recordPath } });
478
+ return { ok: true, lines: [...plan, ...report, `L4 record written: ${recordPath}`, `back-fill green: urgent release ${urgent.tag} is promoted to a normal L4 release (no re-release, no push)`], data: { ...base, executed: true, results, promoted: true } };
479
+ }
417
480
  const created = facts.chosen.state !== 'on-head';
418
481
  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]);
419
482
  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)}`;
package/release/index.mjs CHANGED
@@ -1,18 +1,22 @@
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] [--sonar] [--source <ref>] [--tag-prefix <prefix>] [--json]
4
- // starci app release push [--execute] [--remote <name>] [--json]
3
+ // starci app release gate [--execute] [--sonar] [--source <ref>] [--tag-prefix <prefix>] [--backfill <tag>] [--json]
4
+ // starci app release push [--execute] [--remote <name>] [--urgent --reason <text>] [--json]
5
+ // `harness` of the app IO ({ version, role, openTicket }) is what the starci CLI hands in: the caller's role an urgent record names and the ticket
6
+ // queue a red back-fill files into.
5
7
  // 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
8
  import fs from 'node:fs';
7
9
  import path from 'node:path';
8
10
  import { isPlainObject as plain } from '../runtime/engine/plain-object.mjs';
9
11
  import { parseYaml } from '../runtime/engine/yaml.mjs';
10
12
  import { isRuntimeExampleApp } from '../runtime/scripts/lib/runtime-example.mjs';
13
+ import { hasEntityList } from '../db/entity-list.mjs';
11
14
  import { STACKS_DIR, STACKS_FILE } from '../stack/config.mjs';
12
15
  import { stackContext } from '../stack/context.mjs';
13
16
  import { EXIT, StackError, refusal } from '../stack/errors.mjs';
14
17
  import { RELEASE_CODE, VERSION, normalizeRelease, releaseGate } from './gate.mjs';
15
18
  import { releasePush } from './push.mjs';
19
+ import { releaseUrgentPush } from './urgent.mjs';
16
20
 
17
21
  const IO_KEYS = ['cwd', 'stdout', 'stderr', 'env', 'sops'];
18
22
 
@@ -58,7 +62,7 @@ function loadReleaseConfig(root) {
58
62
  const pkg = readPackage(root);
59
63
  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)');
60
64
  const declaration = readDeclaration(root);
61
- const { config, problems } = normalizeRelease(declaration.release, pkg.scripts, declaration.services, { example: isRuntimeExampleApp(root) });
65
+ const { config, problems } = normalizeRelease(declaration.release, pkg.scripts, declaration.services, { example: isRuntimeExampleApp(root), entities: hasEntityList(root) });
62
66
  if (!config) throw refusal(RELEASE_CODE, `${problems.length} problem(s) in release: ${problems.slice(0, 6).join('; ')}`);
63
67
  return { root, version: pkg.version, ...config };
64
68
  }
@@ -85,12 +89,19 @@ export async function runVerb({ label, name, io, argv, spec, run, code = 'RELEAS
85
89
 
86
90
  const ACTIONS = {
87
91
  gate: {
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'] }),
92
+ spec: { boolean: ['--execute', '--json', '--sonar'], value: ['--source', '--tag-prefix', '--backfill'] },
93
+ run: (ctx, flags, harness) => releaseGate(ctx, loadReleaseConfig(ctx.cwd), {
94
+ execute: flags.execute === true, sonar: flags.sonar === true, source: flags.source, tagPrefix: flags['tag-prefix'], backfill: flags.backfill, openTicket: harness?.openTicket,
95
+ }),
90
96
  },
91
97
  push: {
92
- spec: { boolean: ['--execute', '--json'], value: ['--remote'] },
93
- run: (ctx, flags) => releasePush(ctx, loadReleaseConfig(ctx.cwd), { execute: flags.execute === true, remote: flags.remote }),
98
+ spec: { boolean: ['--execute', '--json', '--urgent'], value: ['--remote', '--reason'] },
99
+ run: (ctx, flags, harness) => {
100
+ if (flags.reason !== undefined && flags.urgent !== true) throw refusal('RELEASE_USAGE', '--reason belongs to an urgent release (--urgent)');
101
+ const config = loadReleaseConfig(ctx.cwd);
102
+ if (flags.urgent === true) return releaseUrgentPush(ctx, config, { execute: flags.execute === true, remote: flags.remote, reason: flags.reason, role: harness?.role ?? ctx.env.STARCI_ROLE });
103
+ return releasePush(ctx, config, { execute: flags.execute === true, remote: flags.remote });
104
+ },
94
105
  },
95
106
  };
96
107
 
@@ -101,5 +112,5 @@ export async function releaseMain(argv, io = {}) {
101
112
  contextOf(io).stderr('starci app release: takes gate or push\n');
102
113
  return EXIT.refused;
103
114
  }
104
- return runVerb({ label: 'starci app release', name: argv[0], io, argv: argv.slice(1), spec: action.spec, run: action.run });
115
+ return runVerb({ label: 'starci app release', name: argv[0], io, argv: argv.slice(1), spec: action.spec, run: (ctx, flags) => action.run(ctx, flags, io.harness) });
105
116
  }
package/release/push.mjs CHANGED
@@ -49,7 +49,7 @@ function remoteTagObject(ctx, remote, tag) {
49
49
  return listed.map((line) => line.split('\t')).find((parts) => parts[1] === wanted)?.[0] ?? '';
50
50
  }
51
51
 
52
- function branchRefs(ctx, { head, remote, branch }, red) {
52
+ export function branchRefs(ctx, { head, remote, branch }, red) {
53
53
  const tip = remoteTip(ctx, remote, branch);
54
54
  if (tip === null || isAncestor(ctx, tip, head)) return { refs: tip === head ? [] : [`${head}:refs/heads/${branch}`], tip };
55
55
  if (isAncestor(ctx, head, tip)) return { refs: [], tip };
@@ -57,14 +57,14 @@ function branchRefs(ctx, { head, remote, branch }, red) {
57
57
  return { refs: [], tip };
58
58
  }
59
59
 
60
- function tagRefs(ctx, { remote, tag }, red) {
60
+ export function tagRefs(ctx, { remote, tag }, red) {
61
61
  const object = remoteTagObject(ctx, remote, tag);
62
62
  if (object === '') return [`refs/tags/${tag}`];
63
63
  if (object !== git(ctx, ['rev-parse', `refs/tags/${tag}`]).stdout) red.push(`tag ${tag} exists on ${remote} as a different object`);
64
64
  return [];
65
65
  }
66
66
 
67
- function probe(ctx, remote, refs, red) {
67
+ export function probe(ctx, remote, refs, red) {
68
68
  const run = git(ctx, ['push', '--dry-run', '--atomic', remote, ...refs], { allowFail: true });
69
69
  const verdict = `${run.stderr}\n${run.stdout}`.split('\n').filter((line) => VERDICT.test(line)).slice(0, 4);
70
70
  if (run.code !== 0) red.push('the pre-push hook refuses this push as things stand');
@@ -73,7 +73,7 @@ function probe(ctx, remote, refs, red) {
73
73
 
74
74
  const NAME_RULE = '^[A-Za-z0-9._/-]+$';
75
75
 
76
- function assertNames(remote, branch) {
76
+ export function assertNames(remote, branch) {
77
77
  if (!refName(remote)) throw refusal('RELEASE_USAGE', `--remote ${remote} must match ${NAME_RULE}`);
78
78
  if (!refName(branch)) throw refusal('RELEASE_USAGE', `the default branch ${branch} must match ${NAME_RULE}`);
79
79
  }
@@ -0,0 +1,54 @@
1
+ // records.mjs - the release records of an app, all under <git common dir>/starci-release (gate.mjs releaseDir):
2
+ // <head>.l4.json the L4 record: every gate of the release gate passed on a clean HEAD (gate.mjs writes it)
3
+ // <head>.urgent.json the URGENT record (owner ruling urgent-release, 2026-10-29): `release push --urgent` tagged and pushed HEAD after the
4
+ // fast local checks alone; it names who (role), why (reason), the checks with their verdicts and the commit, and
5
+ // carries the back-fill verdict once `release gate --backfill <tag>` has run the full gate on the same commit
6
+ // <head12>/rows.json every gate row run on that head (name, command, verdict, log and its sha256), so a later urgent push reuses a green
7
+ // row whose command and log are unchanged instead of running it again
8
+ import { createHash } from 'node:crypto';
9
+ import fs from 'node:fs';
10
+ import path from 'node:path';
11
+
12
+ export const URGENT_SCHEMA = 'starci-app-urgent-record/1';
13
+ /** The owner's ruling an urgent record cites: CI and the full gate wait for the back-fill, the safety floor of the deploy never does. */
14
+ export const URGENT_RULING = 'owner ruling urgent-release 2026-10-29: an urgent release ships after the fast local checks; the full gate runs later on the same tag (release gate --backfill) and the deploy safety floor is never skipped';
15
+
16
+ const readJson = (file) => {
17
+ try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; }
18
+ };
19
+
20
+ const writeJson = (file, value) => {
21
+ fs.mkdirSync(path.dirname(file), { recursive: true });
22
+ fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`);
23
+ };
24
+
25
+ /** The sha256 of a file, or null when it cannot be read. */
26
+ export const fileSha256 = (file) => {
27
+ try { return createHash('sha256').update(fs.readFileSync(file)).digest('hex'); } catch { return null; }
28
+ };
29
+
30
+ export const l4Path = (dir, head) => path.join(dir, `${head}.l4.json`);
31
+ export const urgentPath = (dir, head) => path.join(dir, `${head}.urgent.json`);
32
+ const rowsPath = (dir, head) => path.join(dir, head.slice(0, 12), 'rows.json');
33
+
34
+ /** The urgent record of `head`, or null when there is none or it is not an urgent record of that head. */
35
+ export function readUrgent(dir, head) {
36
+ const record = readJson(urgentPath(dir, head));
37
+ return record?.schema === URGENT_SCHEMA && record.head === head ? record : null;
38
+ }
39
+
40
+ export const writeUrgent = (dir, record) => writeJson(urgentPath(dir, record.head), record);
41
+
42
+ /** Appends a gate row to the rows of `head` (a row of the same name is replaced). */
43
+ export function recordRow(dir, head, row) {
44
+ const file = rowsPath(dir, head);
45
+ const rows = (readJson(file) ?? []).filter((known) => known?.name !== row.name);
46
+ writeJson(file, [...rows, row]);
47
+ }
48
+
49
+ /** The recorded green row of `head` whose name and command match and whose log still has the recorded sha256, or null. */
50
+ export function greenRow(dir, head, name, command) {
51
+ const rows = readJson(rowsPath(dir, head)) ?? [];
52
+ const row = rows.find((known) => known?.name === name && known.command === command && known.pass === true);
53
+ return row && row.log && fileSha256(row.log) === row.logSha256 ? row : null;
54
+ }
@@ -0,0 +1,129 @@
1
+ // urgent.mjs - `starci app release push --urgent --reason "<text>"`, the sanctioned urgent path (owner ruling urgent-release, 2026-10-29: an urgent
2
+ // deploy needs no CI). It runs only the fast local checks (app check, typecheck, lint of the files changed since the previous release tag, unit), or
3
+ // reuses a green row the gate already recorded for HEAD with the same command and an unchanged log; tags HEAD (annotated) and pushes the default
4
+ // branch and the tag in ONE atomic push through the repository's hook (never --no-verify, `git push --dry-run --atomic` first); then writes the
5
+ // URGENT record <git common dir>/starci-release/<HEAD>.urgent.json naming who (the caller's role), why (the reason), each check with its verdict and the
6
+ // commit. The candidate CI is not waited for. The full gate runs later on the same tag (`release gate --backfill <tag>`, gate.mjs), and the deploy
7
+ // safety floor (backup, migrate preview then execute, readiness, rollback) is never skipped (stack/urgent-floor.mjs).
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+ import { firstLine } from '../stack/context.mjs';
11
+ import { failure, refusal } from '../stack/errors.mjs';
12
+ import { assertUnmoved, git, inspect, releaseDir, runGate, summaryOf } from './gate.mjs';
13
+ import { assertNames, branchRefs, probe, tagRefs } from './push.mjs';
14
+ import { URGENT_RULING, URGENT_SCHEMA, greenRow, recordRow, writeUrgent } from './records.mjs';
15
+
16
+ /** The fast local checks of an urgent release, by gate name, in the order they run. */
17
+ export const URGENT_CHECKS = Object.freeze(['app-check', 'typecheck', 'lint', 'test']);
18
+ const REASON_MAX = 500;
19
+ const CHANGED_MAX = 200;
20
+ const PATH_WORD = /^[A-Za-z0-9_@%+=:,./\\-]+$/u;
21
+ const MANAGED_LINT = 'npm run lint';
22
+
23
+ /** The reason, one line of plain text; an urgent release without one is refused. */
24
+ function reasonOf(value) {
25
+ const reason = String(value ?? '').replaceAll(/\s+/gu, ' ').trim();
26
+ if (reason === '') throw refusal('RELEASE_URGENT_REASON_MISSING', 'an urgent release says why: --reason "<text>"');
27
+ if (reason.length > REASON_MAX) throw refusal('RELEASE_URGENT_REASON_MISSING', `--reason is one line of at most ${REASON_MAX} characters`);
28
+ return reason;
29
+ }
30
+
31
+ /** The newest release tag reachable from HEAD's parent, or null on a first release. */
32
+ function previousTag(ctx, prefix) {
33
+ const found = git(ctx, ['describe', '--tags', '--abbrev=0', '--match', `${prefix}[0-9]*`, 'HEAD^'], { allowFail: true });
34
+ return found.code === 0 && found.stdout !== '' ? found.stdout : null;
35
+ }
36
+
37
+ /** The lint row of an urgent release: the managed whole-app lint narrowed to the source files changed since the previous release tag. */
38
+ function changedLint(ctx, gate, prefix) {
39
+ if (gate.command.join(' ') !== MANAGED_LINT) return { gate, note: null };
40
+ const since = previousTag(ctx, prefix);
41
+ if (!since) return { gate, note: 'no previous release tag: the whole app is linted' };
42
+ const files = git(ctx, ['diff', '--name-only', '--diff-filter=d', `${since}..HEAD`]).stdout.split('\n').map((file) => file.trim())
43
+ .filter((file) => /\.(?:[cm]?[jt]sx?|css)$/u.test(file));
44
+ if (files.length === 0) return { gate: null, note: `no source file changed since ${since}` };
45
+ if (files.length > CHANGED_MAX || !files.every((file) => PATH_WORD.test(file))) return { gate, note: `${files.length} changed file(s) since ${since}: the whole app is linted` };
46
+ return { gate: { ...gate, command: ['npx', 'starci', 'app', 'lint', '--changed', ...files] }, note: `${files.length} file(s) changed since ${since}` };
47
+ }
48
+
49
+ /** The urgent checks this app declares, from its release gates; a lint row is narrowed to the changed files. */
50
+ function urgentGates(ctx, config, prefix) {
51
+ const declared = URGENT_CHECKS.map((name) => config.gates.find((gate) => gate.name === name)).filter(Boolean);
52
+ if (declared.length === 0) throw refusal('RELEASE_URGENT_NO_CHECKS', `the release gates declare none of ${URGENT_CHECKS.join(', ')}: an urgent release still runs its fast local checks`);
53
+ return declared.map((gate) => (gate.name === 'lint' ? { name: gate.name, ...changedLint(ctx, gate, prefix) } : { name: gate.name, gate, note: null }));
54
+ }
55
+
56
+ /** Runs (or reuses) each check in order and stops at the first red one. Returns the rows of the record. */
57
+ function runChecks(ctx, checks, { common, head, dir, platform }) {
58
+ fs.mkdirSync(dir, { recursive: true });
59
+ const rows = [];
60
+ for (const check of checks) {
61
+ if (!check.gate) {
62
+ rows.push({ name: check.name, command: null, pass: true, reused: false, detail: check.note, log: null, logSha256: null });
63
+ continue;
64
+ }
65
+ const command = check.gate.command.join(' ');
66
+ const reused = greenRow(common, head, check.name, command);
67
+ if (reused) {
68
+ rows.push({ ...reused, reused: true, ...(check.note ? { note: check.note } : {}) });
69
+ continue;
70
+ }
71
+ const row = runGate(ctx, check.gate, { dir, platform, credentialNames: [] });
72
+ recordRow(common, head, row);
73
+ rows.push({ ...row, reused: false, ...(check.note ? { note: check.note } : {}) });
74
+ if (!row.pass) break;
75
+ }
76
+ return rows;
77
+ }
78
+
79
+ const checkLine = (row) => (row.command === null ? `SKIP ${row.name} (${row.detail})` : `${row.reused ? 'REUSED ' : ''}${summaryOf(row)}`);
80
+
81
+ function planOf(checks) {
82
+ return ['urgent checks (each must pass; a green row recorded for HEAD with the same command and log is reused):',
83
+ ...checks.map((check) => ` ${check.name.padEnd(10)} ${check.gate ? check.gate.command.join(' ') : '(nothing to run)'}${check.note ? ` [${check.note}]` : ''}`)];
84
+ }
85
+
86
+ /**
87
+ * `starci app release push --urgent`. options: { execute, remote, reason, role, platform }. Dry run prints the checks, the tag and the push it would
88
+ * make; --execute runs them, tags, pushes atomically and writes the urgent record. A red check is RELEASE_URGENT_CHECK_RED: nothing tagged, nothing pushed.
89
+ */
90
+ export async function releaseUrgentPush(ctx, config, options = {}) {
91
+ const reason = reasonOf(options.reason);
92
+ const role = options.role ?? 'unset';
93
+ const remote = options.remote ?? 'origin';
94
+ const branch = config.defaultBranch;
95
+ assertNames(remote, branch);
96
+ const facts = inspect(ctx, config, {});
97
+ const { head, chosen } = facts;
98
+ const common = releaseDir(ctx);
99
+ const dir = path.join(common, head.slice(0, 12));
100
+ const checks = urgentGates(ctx, config, config.tagPrefix);
101
+ const out = [...facts.out, `urgent ${URGENT_RULING}`, `reason ${reason}`, `role ${role}`, ...planOf(checks)];
102
+ const data = { head, tag: chosen.tag, remote, branch, urgent: true, reason, role, checks: [], pushed: false };
103
+ if (facts.red.length > 0) return { ok: false, lines: [...out, ...facts.red.map((item) => `RED: ${item}`), 'nothing run, nothing pushed.'], data: { ...data, red: facts.red } };
104
+ const red = [];
105
+ const refs = [...branchRefs(ctx, { head, remote, branch }, red).refs, ...(chosen.state === 'on-head' ? tagRefs(ctx, { remote, tag: chosen.tag }, red) : [`refs/tags/${chosen.tag}`])];
106
+ out.push(`plan git tag -a ${chosen.tag}${chosen.state === 'on-head' ? ' (already on HEAD)' : ''}, then git push --atomic ${remote} ${refs.join(' ') || '(nothing)'}; no candidate CI`);
107
+ if (red.length > 0) return { ok: false, lines: [...out, ...red.map((item) => `RED: ${item}`), 'nothing run, nothing pushed.'], data: { ...data, red } };
108
+ if (options.execute !== true) return { ok: true, lines: [...out, 'dry run: no check run, nothing tagged or pushed. ready; re-run with --execute.'], data };
109
+ const rows = runChecks(ctx, checks, { common, head, dir, platform: options.platform ?? process.platform });
110
+ data.checks = rows;
111
+ out.push(...rows.map(checkLine));
112
+ const failed = rows.find((row) => !row.pass);
113
+ if (failed) throw failure('RELEASE_URGENT_CHECK_RED', `${failed.name} is red (${failed.detail}; log ${failed.log}): nothing tagged, nothing pushed`);
114
+ assertUnmoved(ctx, head);
115
+ if (chosen.state !== 'on-head') git(ctx, ['tag', '-a', chosen.tag, '-m', `URGENT release ${chosen.tag} on ${head.slice(0, 12)} by ${role}: ${reason} (fast local checks only; back-fill: starci app release gate --backfill ${chosen.tag})`, head]);
116
+ if (refs.length > 0) {
117
+ const hook = probe(ctx, remote, refs, red);
118
+ out.push(...hook.lines);
119
+ if (red.length > 0) return { ok: false, lines: [...out, ...red.map((item) => `RED: ${item}`), `nothing pushed; ${chosen.tag} stays local.`], data };
120
+ const run = git(ctx, ['push', '--atomic', remote, ...refs], { allowFail: true });
121
+ if (run.code !== 0) return { ok: false, lines: [...out, `push failed: ${firstLine(run.stderr)}; ${chosen.tag} stays local.`], data };
122
+ }
123
+ const record = {
124
+ schema: URGENT_SCHEMA, head, tag: chosen.tag, createdAt: new Date(ctx.now()).toISOString(), producedBy: 'starci app release push --urgent',
125
+ role, reason, ruling: URGENT_RULING, remote, branch, refs, pushed: true, checks: rows, backfill: null,
126
+ };
127
+ writeUrgent(common, record);
128
+ return { ok: true, lines: [...out, `pushed to ${remote}: ${refs.join(' ') || '(already there)'}`, `URGENT record written: ${path.join(common, `${head}.urgent.json`)}`, `back-fill later: starci app release gate --backfill ${chosen.tag} --execute (in a clean checkout of ${chosen.tag})`], data: { ...data, pushed: true, record: path.join(common, `${head}.urgent.json`) } };
129
+ }
@@ -31,35 +31,35 @@ pins:
31
31
  source: packages/event-stream/package.json
32
32
  why: 'the one server-sent-events client of every front end (fe-stack event-stream, owner:event-stream-package): the transport client layer (modules/api/sse/clients) creates one instance for the app with the bearer token source and the stream operations follow on it; it carries Last-Event-ID resume, the capped doubling backoff, the heartbeat timeout, the single 401 renewal and the useSWRSubscription adapter, so no app keeps a hand-written reader.'
33
33
  '@starci/grammar':
34
- version: 0.15.8
34
+ version: 0.15.10
35
35
  group: starci
36
36
  install: registry
37
37
  side: fe
38
38
  source: packages/grammar/package.json
39
39
  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.'
40
40
  '@starci/eslint-canon-be':
41
- version: 3.6.6
41
+ version: 3.6.8
42
42
  group: starci
43
43
  install: registry
44
44
  side: be
45
45
  source: packages/eslint-canon-be/package.json
46
46
  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.'
47
47
  '@starci/eslint-canon-fe':
48
- version: 9.4.7
48
+ version: 9.4.9
49
49
  group: starci
50
50
  install: registry
51
51
  side: fe
52
52
  source: packages/eslint-canon-fe/package.json
53
53
  why: 'the front-end ESLint canon: `loadHfs(import.meta.url)` of fe/eslint.config.mjs finds the app-root hfs.json (kind app) and gives every linted file the view of the fe 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; the front end has no tests (FE_NO_TESTS R97 in hfs) and no-vietnamese-in-source (R91) applies.'
54
54
  '@starci/eslint-grammar-core':
55
- version: 0.15.8
55
+ version: 0.15.10
56
56
  group: starci
57
57
  install: registry
58
58
  side: fe
59
59
  source: packages/eslint-grammar-core/package.json
60
60
  why: 'the StarCi Grammar component API lint, versioned with @starci/grammar; front ends compose this family package with the universal FE canon.'
61
61
  '@starci/eslint-grammar-offset-pop':
62
- version: 0.15.8
62
+ version: 0.15.10
63
63
  group: starci
64
64
  install: registry
65
65
  side: fe
@@ -106,7 +106,7 @@ pins:
106
106
  source: packages/ai-router/package.json
107
107
  why: 'the AI pool router of a back end: a NestJS module that routes OpenAI-compatible chat, stream and embeddings calls across keys, models and providers, with @starci/ai-router/testing for its specs; @starci/jest-preset compiles it as an ESM-only dependency.'
108
108
  '@starci/hfs':
109
- version: 4.9.9
109
+ version: 4.9.11
110
110
  group: starci
111
111
  install: registry
112
112
  side: both
@@ -241,6 +241,16 @@ pins:
241
241
  group: framework
242
242
  side: be
243
243
  why: readiness probes with dependency indicators use Terminus (the liveness route of the scaffolded skeleton does not, being a thin door over the liveness service); every measured product back end resolves 11.1.1.
244
+ '@langchain/core':
245
+ version: 1.3.1
246
+ group: framework
247
+ side: be
248
+ why: 'the LangChain core of the one AI integration (BE-AI-3): prompt templates, output parsers, runnables and BaseRetriever; @langchain/openai takes it as its peer. The registry latest on 2026-10-29.'
249
+ '@langchain/openai':
250
+ version: 1.8.0
251
+ group: framework
252
+ side: be
253
+ why: 'ChatOpenAI and OpenAIEmbeddings of the one AI integration, pointed at the ai-router gateway with a virtual key (BE-AI-1, BE-AI-3, R327); peer @langchain/core ^1.3.1. The registry latest on 2026-10-29.'
244
254
  '@nestjs/cqrs':
245
255
  version: 11.0.3
246
256
  group: framework