session-orchestrator 5.10.0 → 5.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor-plugin/plugin.json +1 -1
- package/AGENTS.md +2 -0
- package/CHANGELOG.md +32 -0
- package/README.md +9 -8
- package/SECURITY.md +1 -1
- package/agents/code-implementer.md +7 -1
- package/docs/agent-authoring.md +2 -0
- package/docs/events-schema.md +2 -1
- package/docs/fleet-checkin-audit.md +172 -0
- package/docs/role-profile-contract.md +139 -0
- package/docs/scope-collision-guard.md +3 -0
- package/docs/session-config-reference.md +1 -1
- package/evals/instruction-ablation/run.mjs +11 -6
- package/hooks/_lib/hook-import-set.json +27 -2
- package/hooks/_lib/orchestrator-ignore.mjs +112 -0
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks.json +1 -1
- package/hooks/on-session-end.mjs +8 -1
- package/hooks/on-session-start.mjs +7 -0
- package/hooks/post-bash-write-verify.mjs +16 -3
- package/hooks/pre-task-scope-disjoint.mjs +12 -0
- package/hooks/subagent-telemetry.mjs +8 -3
- package/hooks/wave-scope-commit-guard.mjs +4 -1
- package/package.json +1 -1
- package/rules/opt-in-stack/security-web.md +1 -1
- package/scripts/emit-session.mjs +13 -0
- package/scripts/fleet-checkin-audit.mjs +138 -0
- package/scripts/lib/common.mjs +2 -4
- package/scripts/lib/context-overhead-format.mjs +57 -0
- package/scripts/lib/fleet-checkin-audit.mjs +244 -0
- package/scripts/lib/memory-proposals/sink.mjs +79 -17
- package/scripts/lib/memory-proposals/store.mjs +245 -9
- package/scripts/lib/project-hygiene.mjs +37 -0
- package/scripts/lib/role-profile/schema.mjs +219 -0
- package/scripts/lib/session-lock.mjs +57 -10
- package/scripts/lib/session-record-repair.mjs +63 -30
- package/scripts/lib/session-schema/constants.mjs +8 -0
- package/scripts/lib/session-schema/validator.mjs +33 -0
- package/scripts/lib/subagents-schema.mjs +9 -0
- package/scripts/lib/telemetry/pricing.mjs +11 -0
- package/scripts/measure-context-overhead.sh +4 -21
- package/scripts/release.mjs +359 -11
- package/scripts/run-quality-gate.mjs +47 -0
- package/scripts/validate-wave-scope.mjs +59 -6
- package/skills/claude-md-drift-check/checker.mjs +8 -6
- package/skills/npm-publish/SKILL.md +3 -3
- package/skills/release/SKILL.md +2 -2
- package/skills/repo-audit/SKILL.md +1 -1
- package/skills/session-end/metrics-collection.md +2 -2
- package/skills/session-end/session-metrics-write.md +1 -1
- package/skills/session-start/references/phase-1-2-session-lock.md +1 -1
- package/skills/wave-executor/circuit-breaker.md +1 -0
package/scripts/release.mjs
CHANGED
|
@@ -35,17 +35,25 @@
|
|
|
35
35
|
// propagation, and polls the live site. A tag/push
|
|
36
36
|
// failure skips the tag-dependent GitHub-release and
|
|
37
37
|
// site phases and returns reconciliation guidance.
|
|
38
|
+
// After the receipt it writes the publish proof
|
|
39
|
+
// .orchestrator/runtime/release-<target>.json (#1537).
|
|
40
|
+
// --reconcile Read-only resume after an accepted upload: from the
|
|
41
|
+
// proof, verify the exact version is served as latest
|
|
42
|
+
// with the checked integrity, then that a consumer
|
|
43
|
+
// download hashes to it. Never publishes, tags or pushes.
|
|
38
44
|
//
|
|
39
45
|
// USAGE:
|
|
40
46
|
// node scripts/release.mjs --check [--json] [--skip-ci]
|
|
41
47
|
// node scripts/release.mjs --set-version 3.19.0
|
|
42
48
|
// node scripts/release.mjs --publish [--json]
|
|
49
|
+
// node scripts/release.mjs --reconcile [--target X.Y.Z] [--json]
|
|
43
50
|
//
|
|
44
51
|
// EXIT CODES:
|
|
45
52
|
// 0 success
|
|
46
53
|
// 1 preflight/check failure (stale surface, missing CHANGELOG entry,
|
|
47
54
|
// tag/registry collision, mirror behind, dead token, CI not green,
|
|
48
|
-
// leakage-gate hit) OR post-publish reconciliation required
|
|
55
|
+
// leakage-gate hit) OR post-publish reconciliation required OR
|
|
56
|
+
// --reconcile not yet installable (pending / failed / proof missing)
|
|
49
57
|
// 2 system/usage error before the npm receipt (git/npm spawn failure,
|
|
50
58
|
// missing NPM_TOKEN, unknown flag, --skip-ci combined with --publish)
|
|
51
59
|
//
|
|
@@ -93,6 +101,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
93
101
|
|
|
94
102
|
import { resolveRepoSpec } from './lib/vcs-repo-spec.mjs';
|
|
95
103
|
import { enumerateRepoFiles } from './lib/validate/enumerate-repo-files.mjs';
|
|
104
|
+
import { writeJsonAtomicSync } from './lib/io.mjs';
|
|
96
105
|
|
|
97
106
|
const PACKAGE_NAME = 'session-orchestrator';
|
|
98
107
|
const SPAWN_OPTS = { encoding: 'utf8', maxBuffer: 32 * 1024 * 1024 };
|
|
@@ -856,6 +865,19 @@ function archiveDigest(tarballPath) {
|
|
|
856
865
|
return createHash('sha256').update(readFileSync(tarballPath)).digest('hex');
|
|
857
866
|
}
|
|
858
867
|
|
|
868
|
+
/**
|
|
869
|
+
* npm's Subresource-Integrity form of an archive (`sha512-<base64>`), the exact
|
|
870
|
+
* shape npm serves as `dist.integrity` — so the checked bytes can later be
|
|
871
|
+
* compared with what the registry and a consumer download actually carry.
|
|
872
|
+
*
|
|
873
|
+
* @param {string} tarballPath
|
|
874
|
+
* @returns {string}
|
|
875
|
+
*/
|
|
876
|
+
export function archiveIntegrity(tarballPath) {
|
|
877
|
+
if (!lstatSync(tarballPath).isFile()) throw new Error('archive is not a regular file');
|
|
878
|
+
return `sha512-${createHash('sha512').update(readFileSync(tarballPath)).digest('base64')}`;
|
|
879
|
+
}
|
|
880
|
+
|
|
859
881
|
/**
|
|
860
882
|
* Scan extracted archive bytes, retain that archive for the callback, and always
|
|
861
883
|
* clean up. Command/scan failures never call the publication callback. The
|
|
@@ -895,7 +917,7 @@ export async function withCheckedPackage(repoRoot, callback, {
|
|
|
895
917
|
if (scan.status !== 0 || summaries.length !== 1 || Number(summaries[0][1]) < 1 || Number(summaries[0][2]) !== 0 || Number(summaries[0][3]) !== inventory.paths.length) return { ok: false, detail: 'required owner policy / packed content scan failed or incomplete' };
|
|
896
918
|
if (archiveDigest(tarballPath) !== digest) throw new Error('archive-changed');
|
|
897
919
|
chmodSync(tarballPath, 0o400);
|
|
898
|
-
artifact = { tarballPath, digest };
|
|
920
|
+
artifact = { tarballPath, digest, integrity: archiveIntegrity(tarballPath) };
|
|
899
921
|
detail = `${inventory.paths.length} packed files, filenames and contents verified`;
|
|
900
922
|
} catch { return { ok: false, detail: 'packed archive preparation or content verification failed' }; }
|
|
901
923
|
return { ok: true, detail, value: await callback(artifact) };
|
|
@@ -1057,6 +1079,22 @@ export function validateFlags(values) {
|
|
|
1057
1079
|
'Run `--check --skip-ci` to inspect the other surfaces, then `--publish` once CI is actually green on HEAD.',
|
|
1058
1080
|
};
|
|
1059
1081
|
}
|
|
1082
|
+
// --reconcile is the read-only resume path after an accepted upload (#1537).
|
|
1083
|
+
// Combining it with a mode that writes would blur exactly the separation it
|
|
1084
|
+
// exists for, so every combination is a usage error rather than a precedence rule.
|
|
1085
|
+
if (values.reconcile && (values.publish || values.check || values['set-version'] || values['skip-ci'])) {
|
|
1086
|
+
return {
|
|
1087
|
+
ok: false,
|
|
1088
|
+
code: 2,
|
|
1089
|
+
message: '--reconcile cannot be combined with --publish, --check, --set-version or --skip-ci: it only verifies an already accepted upload.',
|
|
1090
|
+
};
|
|
1091
|
+
}
|
|
1092
|
+
if (values.target !== undefined && !values.reconcile) {
|
|
1093
|
+
return { ok: false, code: 2, message: '--target is only valid with --reconcile.' };
|
|
1094
|
+
}
|
|
1095
|
+
if (values.target !== undefined && !/^\d+\.\d+\.\d+$/.test(values.target)) {
|
|
1096
|
+
return { ok: false, code: 2, message: `invalid --target: ${values.target} (expected X.Y.Z)` };
|
|
1097
|
+
}
|
|
1060
1098
|
return { ok: true };
|
|
1061
1099
|
}
|
|
1062
1100
|
|
|
@@ -1448,15 +1486,271 @@ export function waitForRegistryPropagation(repoRoot, target, deps = {}) {
|
|
|
1448
1486
|
};
|
|
1449
1487
|
}
|
|
1450
1488
|
|
|
1489
|
+
// ---------------------------------------------------------------------------
|
|
1490
|
+
// Accepted -> available -> installable (#1537).
|
|
1491
|
+
//
|
|
1492
|
+
// npm's PUT answers HTTP 202 and the CLI prints the target receipt BEFORE the
|
|
1493
|
+
// version is publicly served: publish-time scanning runs asynchronously and can
|
|
1494
|
+
// hold a version for far longer than any in-process poll should wait (5.10.0
|
|
1495
|
+
// stayed 404 for more than 25 minutes after its receipt). So the receipt is
|
|
1496
|
+
// persisted as a proof file, and `--reconcile` resumes the verification later
|
|
1497
|
+
// from that proof alone. That path is structurally separate from publishing: it
|
|
1498
|
+
// never reaches publish(), tagAndPush(), a push, or an `npm pack` of the repo.
|
|
1499
|
+
// ---------------------------------------------------------------------------
|
|
1500
|
+
|
|
1501
|
+
const RELEASE_PROOF_DIR = join('.orchestrator', 'runtime');
|
|
1502
|
+
const INTEGRITY_RE = /^sha512-[A-Za-z0-9+/]{86}==$/;
|
|
1503
|
+
|
|
1504
|
+
/** Path of the persisted publish proof for `target` (gitignored runtime dir). */
|
|
1505
|
+
export function releaseProofPath(repoRoot, target) {
|
|
1506
|
+
return join(repoRoot, RELEASE_PROOF_DIR, `release-${target}.json`);
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
/**
|
|
1510
|
+
* The evidence `--reconcile` needs to verify an accepted upload later without
|
|
1511
|
+
* the original archive: which bytes were checked (sha256 + npm-style sha512
|
|
1512
|
+
* integrity), when npm issued the receipt, and which commit/tag they belong to.
|
|
1513
|
+
*
|
|
1514
|
+
* @param {{target: string, artifact: {digest: string, integrity: string}, commit: string|null, receiptAt: string}} input
|
|
1515
|
+
*/
|
|
1516
|
+
export function buildReleaseProof({ target, artifact, commit, receiptAt }) {
|
|
1517
|
+
return {
|
|
1518
|
+
schema: 1,
|
|
1519
|
+
package: PACKAGE_NAME,
|
|
1520
|
+
target,
|
|
1521
|
+
tag: `v${target}`,
|
|
1522
|
+
commit: commit || null,
|
|
1523
|
+
receiptAt,
|
|
1524
|
+
sha256: artifact.digest,
|
|
1525
|
+
integrity: artifact.integrity,
|
|
1526
|
+
};
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
/**
|
|
1530
|
+
* Persist the proof atomically. Never throws: it runs after the irreversible
|
|
1531
|
+
* receipt, where a write failure must be reported, not turned into a pre-receipt
|
|
1532
|
+
* style abort.
|
|
1533
|
+
*
|
|
1534
|
+
* @returns {{ok: boolean, path: string, detail: string}}
|
|
1535
|
+
*/
|
|
1536
|
+
export function writeReleaseProof(repoRoot, proof, { writeImpl = writeJsonAtomicSync } = {}) {
|
|
1537
|
+
const path = releaseProofPath(repoRoot, proof.target);
|
|
1538
|
+
try {
|
|
1539
|
+
const res = writeImpl(path, proof, { tmpPrefix: '.release-proof' });
|
|
1540
|
+
if (res?.ok) return { ok: true, path, detail: `publish proof written to ${relativePath(repoRoot, path)}` };
|
|
1541
|
+
return { ok: false, path, detail: `publish proof NOT written (${res?.error ?? 'unknown error'})` };
|
|
1542
|
+
} catch (err) {
|
|
1543
|
+
return { ok: false, path, detail: `publish proof NOT written (${err?.message ?? err})` };
|
|
1544
|
+
}
|
|
1545
|
+
}
|
|
1546
|
+
|
|
1547
|
+
/**
|
|
1548
|
+
* Read and validate the proof for `target`. A missing or malformed proof is a
|
|
1549
|
+
* refusal — success is never derived from `latest` alone.
|
|
1550
|
+
*
|
|
1551
|
+
* @returns {{ok: true, proof: object, path: string} | {ok: false, path: string, detail: string}}
|
|
1552
|
+
*/
|
|
1553
|
+
export function readReleaseProof(repoRoot, target, { readImpl = readFileSync } = {}) {
|
|
1554
|
+
const path = releaseProofPath(repoRoot, target);
|
|
1555
|
+
let proof;
|
|
1556
|
+
try {
|
|
1557
|
+
proof = JSON.parse(readImpl(path, 'utf8'));
|
|
1558
|
+
} catch (err) {
|
|
1559
|
+
const missing = err?.code === 'ENOENT';
|
|
1560
|
+
return { ok: false, path, detail: missing ? `proof missing: ${relativePath(repoRoot, path)} does not exist` : `proof unreadable: ${err?.message ?? err}` };
|
|
1561
|
+
}
|
|
1562
|
+
if (!proof || typeof proof !== 'object' || proof.package !== PACKAGE_NAME || proof.target !== target || !INTEGRITY_RE.test(proof.integrity ?? '') || !/^[0-9a-f]{64}$/.test(proof.sha256 ?? '')) {
|
|
1563
|
+
return { ok: false, path, detail: `proof invalid: ${relativePath(repoRoot, path)} does not describe ${PACKAGE_NAME}@${target} with sha256 + sha512 integrity` };
|
|
1564
|
+
}
|
|
1565
|
+
return { ok: true, proof, path };
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
/**
|
|
1569
|
+
* Judge one `npm view <pkg>@<target> --json` answer against the proof.
|
|
1570
|
+
* A 404 is `pending` — never read as "held" or "blocked": the package token
|
|
1571
|
+
* cannot see npm's internal scan state, so a 404 alone carries no diagnosis.
|
|
1572
|
+
*
|
|
1573
|
+
* `superseded` = exact version + integrity served, but `latest` already points
|
|
1574
|
+
* at a semver-greater release.
|
|
1575
|
+
*
|
|
1576
|
+
* @returns {{state: 'available'|'superseded'|'pending'|'failed', reason: string, detail: string}}
|
|
1577
|
+
*/
|
|
1578
|
+
export function evaluateRegistryManifest(view, target, proof) {
|
|
1579
|
+
const raw = (view?.stdout || '').trim();
|
|
1580
|
+
let json;
|
|
1581
|
+
try { json = raw ? JSON.parse(raw) : null; } catch { json = undefined; }
|
|
1582
|
+
if (view?.status !== 0) {
|
|
1583
|
+
if (json?.error?.code === 'E404' || /\bE404\b/.test(`${view?.stderr || ''}`)) {
|
|
1584
|
+
return { state: 'pending', reason: 'not-visible', detail: `registry answers 404 for ${PACKAGE_NAME}@${target} — not yet publicly served; a 404 alone is not evidence of a hold or block` };
|
|
1585
|
+
}
|
|
1586
|
+
return { state: 'pending', reason: 'query-failed', detail: `npm view exited ${view?.status}: ${(json?.error?.summary || view?.stderr || raw).trim().slice(0, 300)}` };
|
|
1587
|
+
}
|
|
1588
|
+
if (json === null) return { state: 'pending', reason: 'not-visible', detail: `registry returned no manifest for ${PACKAGE_NAME}@${target}` };
|
|
1589
|
+
if (!json || typeof json !== 'object' || Array.isArray(json)) return { state: 'pending', reason: 'unparseable', detail: 'npm view output is not a single manifest object' };
|
|
1590
|
+
if (json.version !== target) return { state: 'pending', reason: 'not-visible', detail: `registry manifest reports version ${json.version ?? 'none'}, not ${target}` };
|
|
1591
|
+
const integrity = json.dist?.integrity;
|
|
1592
|
+
if (!integrity) return { state: 'pending', reason: 'integrity-missing', detail: 'registry manifest carries no dist.integrity yet' };
|
|
1593
|
+
if (integrity !== proof.integrity) {
|
|
1594
|
+
return { state: 'failed', reason: 'integrity-mismatch', detail: `registry dist.integrity ${integrity} differs from the checked archive ${proof.integrity}` };
|
|
1595
|
+
}
|
|
1596
|
+
const latest = json['dist-tags']?.latest;
|
|
1597
|
+
if (latest !== target) {
|
|
1598
|
+
// A later release can only have been published after this one was
|
|
1599
|
+
// accepted, so `latest` will never come back to the target. Keeping that
|
|
1600
|
+
// `pending` forever would be a standing false alarm; the exact version and
|
|
1601
|
+
// its bytes are still verified (here, and by the download that follows).
|
|
1602
|
+
if (isNewerRelease(latest, target)) {
|
|
1603
|
+
return { state: 'superseded', reason: 'superseded', detail: `registry serves ${target} with the checked integrity; dist-tags.latest is already the later ${latest}` };
|
|
1604
|
+
}
|
|
1605
|
+
return { state: 'pending', reason: 'latest-not-target', detail: `dist-tags.latest is ${latest ?? 'unset'}, not ${target}` };
|
|
1606
|
+
}
|
|
1607
|
+
return { state: 'available', reason: 'available', detail: `registry serves ${target} as latest with the checked integrity` };
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1610
|
+
/** True when `candidate` is a plain X.Y.Z strictly greater than `target`; prereleases never count. */
|
|
1611
|
+
function isNewerRelease(candidate, target) {
|
|
1612
|
+
const parse = (v) => (/^\d+\.\d+\.\d+$/.test(v ?? '') ? v.split('.').map(Number) : null);
|
|
1613
|
+
const a = parse(candidate);
|
|
1614
|
+
const b = parse(target);
|
|
1615
|
+
if (!a || !b) return false;
|
|
1616
|
+
for (let i = 0; i < 3; i++) if (a[i] !== b[i]) return a[i] > b[i];
|
|
1617
|
+
return false;
|
|
1618
|
+
}
|
|
1619
|
+
|
|
1451
1620
|
/**
|
|
1452
|
-
*
|
|
1453
|
-
*
|
|
1621
|
+
* Download the published tarball the way a consumer resolves it (`npm pack
|
|
1622
|
+
* <pkg>@<target> --ignore-scripts` into an empty temp dir), hash it, and compare
|
|
1623
|
+
* with the proof. Nothing from the download is extracted or executed.
|
|
1624
|
+
*
|
|
1625
|
+
* @returns {{state: 'installable'|'pending'|'failed', reason: string, detail: string}}
|
|
1626
|
+
*/
|
|
1627
|
+
export function checkInstallable(target, proof, deps = {}) {
|
|
1628
|
+
const runImpl = deps.runImpl ?? run;
|
|
1629
|
+
const integrityImpl = deps.integrityImpl ?? archiveIntegrity;
|
|
1630
|
+
const dir = (deps.mkTempImpl ?? (() => mkdtempSync(join(tmpdir(), 'so-release-reconcile-'))))();
|
|
1631
|
+
try {
|
|
1632
|
+
let pack;
|
|
1633
|
+
try {
|
|
1634
|
+
pack = runImpl('npm', ['pack', `${PACKAGE_NAME}@${target}`, '--json', '--ignore-scripts', '--prefer-online', '--pack-destination', dir], { cwd: dir, timeout: 120000 });
|
|
1635
|
+
} catch (err) {
|
|
1636
|
+
return { state: 'pending', reason: 'download-failed', detail: `npm pack ${PACKAGE_NAME}@${target} failed: ${err?.message ?? err}` };
|
|
1637
|
+
}
|
|
1638
|
+
if (pack?.status !== 0) {
|
|
1639
|
+
return { state: 'pending', reason: 'download-failed', detail: `npm pack ${PACKAGE_NAME}@${target} exited ${pack?.status}: ${(pack?.stderr || '').trim().slice(0, 300)}` };
|
|
1640
|
+
}
|
|
1641
|
+
let filename;
|
|
1642
|
+
try {
|
|
1643
|
+
const records = JSON.parse(pack.stdout);
|
|
1644
|
+
filename = Array.isArray(records) && records.length === 1 ? records[0]?.filename : null;
|
|
1645
|
+
} catch { filename = null; }
|
|
1646
|
+
if (typeof filename !== 'string' || basename(filename) !== filename || !filename.endsWith('.tgz')) {
|
|
1647
|
+
return { state: 'pending', reason: 'download-unparseable', detail: 'npm pack output did not name exactly one downloaded tarball' };
|
|
1648
|
+
}
|
|
1649
|
+
let integrity;
|
|
1650
|
+
try { integrity = integrityImpl(join(dir, filename)); } catch (err) {
|
|
1651
|
+
return { state: 'pending', reason: 'download-unreadable', detail: `downloaded tarball unreadable: ${err?.message ?? err}` };
|
|
1652
|
+
}
|
|
1653
|
+
if (integrity !== proof.integrity) {
|
|
1654
|
+
return { state: 'failed', reason: 'integrity-mismatch', detail: `downloaded tarball ${integrity} differs from the checked archive ${proof.integrity}` };
|
|
1655
|
+
}
|
|
1656
|
+
return { state: 'installable', reason: 'installable', detail: `downloaded ${filename} matches the checked archive integrity` };
|
|
1657
|
+
} finally {
|
|
1658
|
+
try { (deps.cleanupImpl ?? rmSync)(dir, { recursive: true, force: true }); } catch { /* temp download dir only */ }
|
|
1659
|
+
}
|
|
1660
|
+
}
|
|
1661
|
+
|
|
1662
|
+
/**
|
|
1663
|
+
* Resume verification of an accepted upload: accepted (proof exists) ->
|
|
1664
|
+
* available (exact version served as latest with the checked integrity) ->
|
|
1665
|
+
* installable (a consumer download hashes to the checked integrity).
|
|
1666
|
+
*
|
|
1667
|
+
* Budget defaults to 20 attempts 60 s apart (19 min between first and last
|
|
1668
|
+
* query): npm's changelog (2026-07-28, read 2026-10-08) names ~5 min typical
|
|
1669
|
+
* and "up to 15 minutes or more" for publish-time scanning, without guarantee.
|
|
1670
|
+
* The budget is therefore a bound, not the fix — it is resumable: a `pending`
|
|
1671
|
+
* result is not a failure, the next `--reconcile` starts over from the same
|
|
1672
|
+
* proof. Only an integrity mismatch is `failed`.
|
|
1454
1673
|
*
|
|
1455
1674
|
* @param {string} repoRoot
|
|
1456
1675
|
* @param {string} target
|
|
1457
|
-
* @param {{runImpl?: Function, waitImpl?: Function,
|
|
1458
|
-
* @returns {{
|
|
1676
|
+
* @param {{attempts?: number, delaySeconds?: number, runImpl?: Function, waitImpl?: Function, readImpl?: Function, integrityImpl?: Function, mkTempImpl?: Function, cleanupImpl?: Function}} [deps]
|
|
1677
|
+
* @returns {{ok: boolean, state: 'installable'|'superseded'|'pending'|'failed'|'proof-missing', target: string, accepted: boolean, available: boolean, installable: boolean, attempts: number, reason: string, detail: string, proofPath: string}}
|
|
1459
1678
|
*/
|
|
1679
|
+
export function reconcileRelease(repoRoot, target, deps = {}) {
|
|
1680
|
+
const attempts = deps.attempts ?? 20;
|
|
1681
|
+
const delaySeconds = deps.delaySeconds ?? 60;
|
|
1682
|
+
const runImpl = deps.runImpl ?? run;
|
|
1683
|
+
const waitImpl = deps.waitImpl ?? (() => runImpl('sleep', [String(delaySeconds)], { cwd: repoRoot }));
|
|
1684
|
+
const base = { target, accepted: false, available: false, installable: false, attempts: 0 };
|
|
1685
|
+
|
|
1686
|
+
const read = readReleaseProof(repoRoot, target, { readImpl: deps.readImpl });
|
|
1687
|
+
if (!read.ok) {
|
|
1688
|
+
return { ...base, ok: false, state: 'proof-missing', reason: 'proof-missing', detail: read.detail, proofPath: read.path };
|
|
1689
|
+
}
|
|
1690
|
+
const { proof } = read;
|
|
1691
|
+
const done = (state, step, attempt, extra) => ({ ...base, accepted: true, ...extra, ok: state === 'installable' || state === 'superseded', state, attempts: attempt, reason: step.reason, detail: step.detail, proofPath: read.path });
|
|
1692
|
+
|
|
1693
|
+
let last = { reason: 'not-run', detail: 'no attempt ran' };
|
|
1694
|
+
let available = false;
|
|
1695
|
+
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
1696
|
+
let view;
|
|
1697
|
+
try {
|
|
1698
|
+
view = runImpl('npm', ['view', `${PACKAGE_NAME}@${target}`, '--json'], { cwd: repoRoot, timeout: 60000 });
|
|
1699
|
+
} catch (err) {
|
|
1700
|
+
view = { status: null, stdout: '', stderr: err?.message ?? String(err) };
|
|
1701
|
+
}
|
|
1702
|
+
const manifest = evaluateRegistryManifest(view, target, proof);
|
|
1703
|
+
if (manifest.state === 'failed') return done('failed', manifest, attempt, { available: false });
|
|
1704
|
+
if (manifest.state === 'available' || manifest.state === 'superseded') {
|
|
1705
|
+
available = true;
|
|
1706
|
+
const install = checkInstallable(target, proof, deps);
|
|
1707
|
+
if (install.state === 'installable') {
|
|
1708
|
+
// Superseded keeps its own state (and exit 0): the release's bytes are
|
|
1709
|
+
// verified installable, only the `latest` tag moved on — not a failure,
|
|
1710
|
+
// but worth saying so the operator does not read it as "is latest".
|
|
1711
|
+
if (manifest.state === 'superseded') return done('superseded', { reason: 'superseded', detail: `${manifest.detail}; ${install.detail}` }, attempt, { available: true, installable: true });
|
|
1712
|
+
return done('installable', install, attempt, { available: true, installable: true });
|
|
1713
|
+
}
|
|
1714
|
+
if (install.state === 'failed') return done('failed', install, attempt, { available: true });
|
|
1715
|
+
last = install;
|
|
1716
|
+
} else {
|
|
1717
|
+
available = false;
|
|
1718
|
+
last = manifest;
|
|
1719
|
+
}
|
|
1720
|
+
if (attempt < attempts) {
|
|
1721
|
+
let wait;
|
|
1722
|
+
try { wait = waitImpl({ attempt, delaySeconds }); } catch (err) { wait = { status: null, error: err }; }
|
|
1723
|
+
if (!wait || wait.status !== 0 || wait.error) {
|
|
1724
|
+
return done('pending', { reason: 'wait-failed', detail: `wait failed after attempt ${attempt}/${attempts}; last state: ${last.detail}` }, attempt, { available });
|
|
1725
|
+
}
|
|
1726
|
+
}
|
|
1727
|
+
}
|
|
1728
|
+
return done('pending', { reason: last.reason, detail: `${last.detail} (after ${attempts} attempts; run \`--reconcile --target ${target}\` again later)` }, attempts, { available });
|
|
1729
|
+
}
|
|
1730
|
+
|
|
1731
|
+
/** Print a reconcile result and return the exit code (0 only when installable or superseded). */
|
|
1732
|
+
export function printReconcileResult(result, { json = false, log = console.log, error = console.error } = {}) {
|
|
1733
|
+
if (json) {
|
|
1734
|
+
log(JSON.stringify(result, null, 2));
|
|
1735
|
+
return result.ok ? 0 : 1;
|
|
1736
|
+
}
|
|
1737
|
+
const mark = (flag) => (flag ? 'yes' : 'no');
|
|
1738
|
+
log(`${PACKAGE_NAME}@${result.target}: ${result.state} — accepted ${mark(result.accepted)}, available ${mark(result.available)}, installable ${mark(result.installable)}`);
|
|
1739
|
+
if (result.ok) {
|
|
1740
|
+
log(` ${result.detail}`);
|
|
1741
|
+
if (result.state === 'superseded') log(` Note: ${result.target} is installable but no longer \`latest\` — a later release took the tag.`);
|
|
1742
|
+
return 0;
|
|
1743
|
+
}
|
|
1744
|
+
error(` ${result.reason}: ${result.detail}`);
|
|
1745
|
+
if (result.state === 'pending') {
|
|
1746
|
+
error(` Still pending, not failed. Run \`node scripts/release.mjs --reconcile --target ${result.target}\` again later;`);
|
|
1747
|
+
error(' the owner-visible package state is on npmjs.com (package page / account notifications). Never rerun --publish.');
|
|
1748
|
+
} else if (result.state === 'proof-missing') {
|
|
1749
|
+
error(' Without the publish proof there is nothing to verify the registry bytes against; success is never inferred from `latest`.');
|
|
1750
|
+
}
|
|
1751
|
+
return 1;
|
|
1752
|
+
}
|
|
1753
|
+
|
|
1460
1754
|
/**
|
|
1461
1755
|
* The exact `npm publish` invocation: argv plus spawn options.
|
|
1462
1756
|
*
|
|
@@ -1500,16 +1794,45 @@ function publish(repoRoot, target, deps = {}) {
|
|
|
1500
1794
|
const call = publishInvocation(repoRoot, tmpRc, deps.artifact);
|
|
1501
1795
|
return runImpl(call.cmd, call.args, call.opts);
|
|
1502
1796
|
});
|
|
1797
|
+
return settlePublishReceipt(repoRoot, target, res, { ...deps, runImpl });
|
|
1798
|
+
}
|
|
1799
|
+
|
|
1800
|
+
/**
|
|
1801
|
+
* Everything `publish()` does AFTER `npm publish` returned: judge the receipt,
|
|
1802
|
+
* persist the publish proof, then wait for the registry. Split out of the
|
|
1803
|
+
* unexported publisher so the order "proof before registry wait" is testable
|
|
1804
|
+
* (#1537) — this function receives an npm result and cannot publish anything.
|
|
1805
|
+
* Pre-receipt failures throw; post-receipt propagation failures return.
|
|
1806
|
+
*
|
|
1807
|
+
* @param {string} repoRoot
|
|
1808
|
+
* @param {string} target
|
|
1809
|
+
* @param {{status: number|null, stdout?: string, stderr?: string}} res — the `npm publish` result
|
|
1810
|
+
* @param {{artifact: {digest: string, integrity: string}, runImpl?: Function, waitImpl?: Function, attempts?: number, delaySeconds?: number}} deps
|
|
1811
|
+
* @returns {{receipt: {confirmed: boolean, target: string, detail: string}, propagation: ReturnType<typeof waitForRegistryPropagation>, proof: ReturnType<typeof writeReleaseProof>}}
|
|
1812
|
+
*/
|
|
1813
|
+
export function settlePublishReceipt(repoRoot, target, res, deps = {}) {
|
|
1814
|
+
const runImpl = deps.runImpl ?? run;
|
|
1503
1815
|
const receipt = evaluatePublishReceipt(res, target);
|
|
1504
1816
|
if (!receipt.confirmed) throw new Error(receipt.detail);
|
|
1505
1817
|
|
|
1818
|
+
// Persist the proof BEFORE the registry wait: if this process dies while
|
|
1819
|
+
// waiting, `--reconcile` can still resume from it (#1537). Never throws.
|
|
1820
|
+
let commit = null;
|
|
1821
|
+
try {
|
|
1822
|
+
const head = runImpl('git', ['rev-parse', 'HEAD'], { cwd: repoRoot });
|
|
1823
|
+
if (head.status === 0) commit = head.stdout.trim();
|
|
1824
|
+
} catch { /* commit stays null; the proof is still useful */ }
|
|
1825
|
+
const proof = writeReleaseProof(repoRoot, buildReleaseProof({
|
|
1826
|
+
target, artifact: deps.artifact, commit, receiptAt: new Date().toISOString(),
|
|
1827
|
+
}));
|
|
1828
|
+
|
|
1506
1829
|
const propagation = waitForRegistryPropagation(repoRoot, target, {
|
|
1507
1830
|
attempts: deps.attempts,
|
|
1508
1831
|
delaySeconds: deps.delaySeconds,
|
|
1509
1832
|
runImpl,
|
|
1510
1833
|
waitImpl: deps.waitImpl,
|
|
1511
1834
|
});
|
|
1512
|
-
return { receipt, propagation };
|
|
1835
|
+
return { receipt, propagation, proof };
|
|
1513
1836
|
}
|
|
1514
1837
|
|
|
1515
1838
|
function tagAndPush(repoRoot, target) {
|
|
@@ -1603,6 +1926,7 @@ export async function runPublishRelease(repoRoot, target, deps = {}) {
|
|
|
1603
1926
|
release: { ok: false, skipped: true, state: 'skipped-prerequisite', detail: `GitHub release ${prerequisite}` },
|
|
1604
1927
|
live: { ok: false, skipped: true, state: 'skipped-prerequisite', detail: `live-site verification ${prerequisite}` },
|
|
1605
1928
|
propagation,
|
|
1929
|
+
proof: publication.proof,
|
|
1606
1930
|
reconciliation: [
|
|
1607
1931
|
{ phase: 'tag-and-push', kind: 'failed', detail: err instanceof Error ? err.message : String(err) },
|
|
1608
1932
|
{ phase: 'github-release', kind: 'skipped-prerequisite', detail: `GitHub release ${prerequisite}` },
|
|
@@ -1633,6 +1957,7 @@ export async function runPublishRelease(repoRoot, target, deps = {}) {
|
|
|
1633
1957
|
release,
|
|
1634
1958
|
live,
|
|
1635
1959
|
propagation,
|
|
1960
|
+
proof: publication.proof,
|
|
1636
1961
|
reconciliation,
|
|
1637
1962
|
};
|
|
1638
1963
|
}
|
|
@@ -1691,10 +2016,17 @@ export function printPublishOutcome(outcome, target, io = {}) {
|
|
|
1691
2016
|
const tagAndPushFailed = outcome.reconciliation.some((item) => item.phase === 'tag-and-push');
|
|
1692
2017
|
|
|
1693
2018
|
log(` + ${PACKAGE_NAME}@${target} — target-confirmed npm receipt.`);
|
|
2019
|
+
if (outcome.proof) {
|
|
2020
|
+
if (outcome.proof.ok) log(` ${outcome.proof.detail}.`);
|
|
2021
|
+
else error(`\nWARN: ${outcome.proof.detail} — \`--reconcile\` cannot verify this release without it.`);
|
|
2022
|
+
}
|
|
1694
2023
|
if (outcome.propagation.ok) {
|
|
1695
2024
|
log(` registry verified (${outcome.propagation.detail}).`);
|
|
1696
2025
|
} else {
|
|
1697
|
-
|
|
2026
|
+
// An accepted upload can stay unserved for many minutes while npm's
|
|
2027
|
+
// publish-time checks run (#1537) — this is not a short propagation delay.
|
|
2028
|
+
error(`\nRECONCILIATION: registry availability is not yet verified — ${outcome.propagation.detail}`);
|
|
2029
|
+
error(` npm accepted the upload; resume verification with: node scripts/release.mjs --reconcile --target ${target}`);
|
|
1698
2030
|
}
|
|
1699
2031
|
if (!tagAndPushFailed) {
|
|
1700
2032
|
log(` tagged ${outcome.tag} (AFTER publish) and pushed main+tag to: ${outcome.pushed.join(', ')}.`);
|
|
@@ -1744,6 +2076,7 @@ export function printPublishOutcome(outcome, target, io = {}) {
|
|
|
1744
2076
|
error(` - ${item.phase}${item.kind ? ` (${item.kind})` : ''}: ${item.detail}`);
|
|
1745
2077
|
}
|
|
1746
2078
|
error(' Do NOT rerun `--publish`; reconcile the listed post-publish state directly.');
|
|
2079
|
+
if (!outcome.propagation.ok) error(` Registry state: \`node scripts/release.mjs --reconcile --target ${target}\` (read-only, resumable).`);
|
|
1747
2080
|
return 1;
|
|
1748
2081
|
}
|
|
1749
2082
|
|
|
@@ -1923,6 +2256,8 @@ async function main() {
|
|
|
1923
2256
|
check: { type: 'boolean', default: false },
|
|
1924
2257
|
publish: { type: 'boolean', default: false },
|
|
1925
2258
|
'skip-ci': { type: 'boolean', default: false },
|
|
2259
|
+
reconcile: { type: 'boolean', default: false },
|
|
2260
|
+
target: { type: 'string' },
|
|
1926
2261
|
json: { type: 'boolean', default: false },
|
|
1927
2262
|
help: { type: 'boolean', default: false },
|
|
1928
2263
|
version: { type: 'boolean', default: false },
|
|
@@ -1930,12 +2265,14 @@ async function main() {
|
|
|
1930
2265
|
});
|
|
1931
2266
|
|
|
1932
2267
|
if (values.help) {
|
|
1933
|
-
console.log('Usage: node scripts/release.mjs [--set-version X.Y.Z | --check | --publish] [--skip-ci] [--json]');
|
|
2268
|
+
console.log('Usage: node scripts/release.mjs [--set-version X.Y.Z | --check | --publish | --reconcile [--target X.Y.Z]] [--skip-ci] [--json]');
|
|
1934
2269
|
console.log('Release als ein Dispatch: surface sync, preflight checks, token publish, tag AFTER publish.');
|
|
1935
2270
|
console.log(' Receipt boundary: before the confirmed npm receipt, failure aborts; after it, never rerun --publish.');
|
|
1936
2271
|
console.log(' Tag/push failure after receipt skips GitHub-release and site phases and returns reconciliation guidance.');
|
|
1937
2272
|
console.log(' --skip-ci allowed with --check only; REFUSED with --publish (it verifies nothing).');
|
|
1938
|
-
console.log('
|
|
2273
|
+
console.log(' --reconcile read-only resume after an accepted upload: proof -> available -> installable. Never publishes, tags or pushes.');
|
|
2274
|
+
console.log(' --target defaults to package.json version; needs .orchestrator/runtime/release-<target>.json from --publish.');
|
|
2275
|
+
console.log('Exit codes: 0 success (--reconcile: installable or superseded), 1 preflight/check failure, post-publish reconciliation or --reconcile pending/failed/proof missing, 2 pre-receipt system/usage error.');
|
|
1939
2276
|
return 0;
|
|
1940
2277
|
}
|
|
1941
2278
|
|
|
@@ -1951,6 +2288,17 @@ async function main() {
|
|
|
1951
2288
|
|
|
1952
2289
|
const repoRoot = repoRootOf();
|
|
1953
2290
|
|
|
2291
|
+
if (values.reconcile) {
|
|
2292
|
+
// Deliberately before and apart from the --check/--publish branch: no
|
|
2293
|
+
// preflight, no withCheckedPackage (no pack of the repo), no publisher.
|
|
2294
|
+
const target = values.target ?? readPackageVersion(repoRoot);
|
|
2295
|
+
if (!/^\d+\.\d+\.\d+$/.test(target)) {
|
|
2296
|
+
console.error(`invalid package.json version for --reconcile: ${target} (expected X.Y.Z; pass --target)`);
|
|
2297
|
+
return 2;
|
|
2298
|
+
}
|
|
2299
|
+
return printReconcileResult(reconcileRelease(repoRoot, target), { json: values.json });
|
|
2300
|
+
}
|
|
2301
|
+
|
|
1954
2302
|
if (values['set-version']) {
|
|
1955
2303
|
const target = values['set-version'];
|
|
1956
2304
|
if (!/^\d+\.\d+\.\d+$/.test(target)) {
|
|
@@ -2002,7 +2350,7 @@ async function main() {
|
|
|
2002
2350
|
return checked.value;
|
|
2003
2351
|
}
|
|
2004
2352
|
|
|
2005
|
-
console.error('Nothing to do — pass --check, --publish, or --set-version X.Y.Z (see --help).');
|
|
2353
|
+
console.error('Nothing to do — pass --check, --publish, --reconcile, or --set-version X.Y.Z (see --help).');
|
|
2006
2354
|
return 2;
|
|
2007
2355
|
}
|
|
2008
2356
|
|
|
@@ -226,6 +226,49 @@ function failedFilesFromGateStdout(stdout) {
|
|
|
226
226
|
return named.length > 0 ? named : null;
|
|
227
227
|
}
|
|
228
228
|
|
|
229
|
+
/**
|
|
230
|
+
* The reporting variants — they exit 0 even after a failing check, so their
|
|
231
|
+
* event NAME never carries the check verdict (#1459). `full-gate` exits non-zero
|
|
232
|
+
* on a failing check, so the name already says it there.
|
|
233
|
+
*/
|
|
234
|
+
const REPORTING_VARIANTS = new Set(['baseline', 'incremental', 'per-file']);
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Did any check of a reporting-variant envelope fail? (#1487 point 3)
|
|
238
|
+
*
|
|
239
|
+
* Three states, never two — the same absent-is-not-zero contract as `counts`:
|
|
240
|
+
* - `true` — `typecheck` or `test` carries `status: 'fail'` (a killed command
|
|
241
|
+
* is published as `fail` too, see `runCheck`);
|
|
242
|
+
* - `false` — both command objects are present, neither is stubbed, at least
|
|
243
|
+
* one actually `pass`ed and none failed — the envelope MEASURED
|
|
244
|
+
* that nothing failed;
|
|
245
|
+
* - `null` — the envelope is unparseable or a command object is missing or
|
|
246
|
+
* carries an unknown status, a command is a stub (echo/noop
|
|
247
|
+
* reports `pass` without running), or both were skipped. The
|
|
248
|
+
* caller then OMITS the key: a
|
|
249
|
+
* default `false` would read as "measured green" for a run that
|
|
250
|
+
* measured nothing.
|
|
251
|
+
*
|
|
252
|
+
* Never throws.
|
|
253
|
+
*
|
|
254
|
+
* @param {string} stdout — the gate sub-script's captured stdout.
|
|
255
|
+
* @returns {boolean|null}
|
|
256
|
+
*/
|
|
257
|
+
function checkFailedFromGateStdout(stdout) {
|
|
258
|
+
if (typeof stdout !== 'string' || !stdout.trim()) return null;
|
|
259
|
+
let parsed;
|
|
260
|
+
try {
|
|
261
|
+
parsed = JSON.parse(stdout);
|
|
262
|
+
} catch {
|
|
263
|
+
return null;
|
|
264
|
+
}
|
|
265
|
+
const statuses = ['typecheck', 'test'].map((name) => parsed?.[name]?.status);
|
|
266
|
+
if (statuses.includes('fail')) return true;
|
|
267
|
+
if (parsed?.stubbed?.typecheck || parsed?.stubbed?.test) return null;
|
|
268
|
+
if (!statuses.every((st) => st === 'pass' || st === 'skip')) return null;
|
|
269
|
+
return statuses.includes('pass') ? false : null;
|
|
270
|
+
}
|
|
271
|
+
|
|
229
272
|
/**
|
|
230
273
|
* Validate and resolve the `--ledger-root` flag (see the telemetry block below).
|
|
231
274
|
*
|
|
@@ -570,12 +613,16 @@ async function main() {
|
|
|
570
613
|
// record that run as `passed`. Their exit code stays 0 on purpose: it has
|
|
571
614
|
// never carried the check verdict there (a failing check exits 0 too).
|
|
572
615
|
const failed = exitCode !== 0 || killFields.timed_out === true;
|
|
616
|
+
// #1487 point 3 (owner decision, option a): the reporting variants keep
|
|
617
|
+
// `.passed` and their exit code, but carry the check verdict as a field.
|
|
618
|
+
const checkFailed = REPORTING_VARIANTS.has(variant) ? checkFailedFromGateStdout(gateStdout) : null;
|
|
573
619
|
await emitEvent(
|
|
574
620
|
`orchestrator.quality_gate.${failed ? 'failed' : 'passed'}`,
|
|
575
621
|
{
|
|
576
622
|
variant,
|
|
577
623
|
exit_code: exitCode,
|
|
578
624
|
...killFields,
|
|
625
|
+
...(checkFailed !== null ? { check_failed: checkFailed } : {}),
|
|
579
626
|
...(counts ? { counts } : {}),
|
|
580
627
|
...(failedFiles ? { failed_files: failedFiles } : {}),
|
|
581
628
|
...(waveNumber !== null ? { wave_number: waveNumber } : {}),
|