@starci/hfs 4.9.8 → 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.
- package/CHANGELOG.md +28 -0
- package/README.md +14 -0
- package/db/drift.mjs +289 -0
- package/db/dump.mjs +1 -1
- package/db/entity-list.mjs +9 -0
- package/db/migrate.mjs +1 -1
- package/lint/run.mjs +12 -2
- package/package.json +5 -3
- package/release/gate.mjs +81 -18
- package/release/index.mjs +19 -8
- package/release/push.mjs +4 -4
- package/release/records.mjs +54 -0
- package/release/urgent.mjs +129 -0
- package/runtime/knowledge/hfs/canon-pins.yaml +16 -6
- package/runtime/knowledge/hfs/rules.yaml +74 -3
- package/runtime/knowledge/hfs/slots.yaml +36 -3
- package/runtime/modules/kernel/failure-codes.yaml +21 -0
- package/runtime/scripts/hfs/app-wiring.mjs +23 -2
- package/runtime/scripts/hfs/check.mjs +2 -2
- package/runtime/scripts/hfs/manifest-shape.mjs +2 -1
- package/runtime/scripts/hfs/rules/a15-findings.mjs +1 -1
- package/scaffold/app.mjs +3 -1
- package/src/main.mjs +7 -4
- package/stack/cli.mjs +10 -2
- package/stack/deploy.mjs +21 -4
- package/stack/urgent-floor.mjs +147 -0
- package/sync/ci-commands.mjs +3 -1
- package/templates/app/ci-workflows/github/workflows/stack-deploy.yml +4 -1
- package/templates/app/ci-workflows-lite/github/workflows/stack-deploy.yml +4 -1
- package/templates/app/skeleton-lite/supabase/migrations/__timestamp___baseline.sql +41 -0
- package/templates/be/skeleton/apps/__app__/src/compose/platform.ts +14 -2
- package/templates/be/skeleton/apps/cli/src/compose/platform.ts +3 -2
- package/templates/be/skeleton/src/modules/domain/identity/identity.decorators.ts +18 -7
- package/templates/be/skeleton/src/modules/platform/composition/composition.decorators.ts +3 -0
- package/templates/be/skeleton/src/modules/platform/composition/index.ts +1 -1
- package/templates/be/skeleton/src/modules/platform/errors/errors.filter.spec.ts +15 -1
- package/templates/be/skeleton/src/modules/platform/errors/errors.filter.ts +10 -9
- package/templates/be/skeleton/src/modules/platform/http-security/index.ts +2 -1
- package/templates/be/skeleton/src/modules/platform/http-security/origin.guard.spec.ts +34 -18
- package/templates/be/skeleton/src/modules/platform/http-security/origin.guard.ts +29 -8
- package/templates/be/skeleton/src/modules/platform/http-security/persistence/connection.ts +8 -0
- package/templates/be/skeleton/src/modules/platform/http-security/persistence/entities/rate-window.entity.ts +17 -0
- package/templates/be/skeleton/src/modules/platform/http-security/persistence/migrations/1789900000000-create-rate-windows.ts +24 -0
- package/templates/be/skeleton/src/modules/platform/http-security/persistence/rate-limit.rows.ts +5 -0
- package/templates/be/skeleton/src/modules/platform/http-security/persistence/rate-limit.sql.ts +20 -0
- package/templates/be/skeleton/src/modules/platform/http-security/rate-limit.guard.spec.ts +149 -47
- package/templates/be/skeleton/src/modules/platform/http-security/rate-limit.guard.ts +57 -34
- package/templates/be/skeleton-lite/src/modules/domain/identity/identity.decorators.ts +18 -8
- package/templates/be/skeleton-lite/src/modules/platform/database/errors/database.error.ts +3 -0
- package/templates/be/skeleton-lite/src/modules/platform/database/index.ts +2 -1
- 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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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), {
|
|
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) =>
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|