session-orchestrator 5.9.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 +65 -2
- package/README.md +11 -9
- package/SECURITY.md +1 -1
- package/agents/code-implementer.md +7 -1
- package/docs/USER-GUIDE.md +2 -0
- package/docs/agent-authoring.md +2 -0
- package/docs/ci-setup.md +46 -1
- package/docs/events-schema.md +37 -6
- package/docs/fleet-checkin-audit.md +172 -0
- package/docs/owner-config-schema.md +18 -1
- package/docs/owner-privacy-patterns.md +85 -0
- package/docs/role-profile-contract.md +139 -0
- package/docs/scope-collision-guard.md +15 -2
- 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/enforce-commands.mjs +9 -19
- package/hooks/enforce-scope.mjs +23 -42
- 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 +54 -24
- 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 +2 -2
- package/rules/opt-in-stack/security-web.md +1 -1
- package/scripts/emit-session.mjs +28 -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/gates/gate-full.mjs +6 -0
- package/scripts/lib/gates/gate-helpers.mjs +2 -0
- package/scripts/lib/memory-proposals/sink.mjs +79 -17
- package/scripts/lib/memory-proposals/store.mjs +245 -9
- package/scripts/lib/named-vault-resolver.mjs +1 -1
- package/scripts/lib/owner-yaml.mjs +33 -3
- package/scripts/lib/platform.mjs +70 -1
- package/scripts/lib/pre-push-remote-gate.mjs +107 -0
- package/scripts/lib/project-hygiene.mjs +37 -0
- package/scripts/lib/quality-gate.mjs +6 -1
- package/scripts/lib/redact-spans.mjs +8 -17
- package/scripts/lib/role-profile/schema.mjs +219 -0
- package/scripts/lib/scope-gate.mjs +68 -5
- 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/lib/validate/check-owner-leakage.mjs +198 -1015
- package/scripts/lib/vault-backfill/template.mjs +1 -1
- package/scripts/lib/vault-mirror/render-sessions.mjs +15 -3
- package/scripts/lib/vault-status/board-writer.mjs +15 -7
- package/scripts/measure-context-overhead.sh +4 -21
- package/scripts/migrate-vault-paths.mjs +57 -60
- package/scripts/release.mjs +463 -48
- package/scripts/run-quality-gate.mjs +47 -0
- package/scripts/validate-wave-scope.mjs +59 -6
- package/scripts/vault-consolidate.mjs +19 -4
- package/skills/claude-md-drift-check/SKILL.md +1 -1
- package/skills/claude-md-drift-check/checker.mjs +22 -9
- 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 +28 -4
- 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/skills/wave-executor/references/wave-loop-review.md +26 -2
package/scripts/release.mjs
CHANGED
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
// + Unreleased folded, drift sweep, tag collision
|
|
24
24
|
// (local, origin, github), github/main mirror parity,
|
|
25
25
|
// npm registry collision, npm token liveness, CI green
|
|
26
|
-
// on HEAD, leakage gate over
|
|
26
|
+
// on HEAD, leakage gate over retained archive filenames and contents.
|
|
27
27
|
// --publish Runs --check first, then token publish via temp
|
|
28
28
|
// userconfig (NPM_TOKEN from .env.local). A
|
|
29
29
|
// target-confirmed npm receipt is the irreversible
|
|
@@ -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
|
//
|
|
@@ -80,15 +88,20 @@ import {
|
|
|
80
88
|
chmodSync,
|
|
81
89
|
rmSync,
|
|
82
90
|
realpathSync,
|
|
91
|
+
mkdirSync,
|
|
92
|
+
lstatSync,
|
|
83
93
|
} from 'node:fs';
|
|
84
|
-
import { join, relative as relativePath, sep } from 'node:path';
|
|
94
|
+
import { join, relative as relativePath, sep, basename, isAbsolute } from 'node:path';
|
|
85
95
|
import { tmpdir } from 'node:os';
|
|
86
96
|
import { spawnSync } from 'node:child_process';
|
|
97
|
+
import { createHash } from 'node:crypto';
|
|
98
|
+
import { packedFilename } from './lib/plugin-package-stage.mjs';
|
|
87
99
|
import { parseArgs } from 'node:util';
|
|
88
100
|
import { fileURLToPath } from 'node:url';
|
|
89
101
|
|
|
90
102
|
import { resolveRepoSpec } from './lib/vcs-repo-spec.mjs';
|
|
91
103
|
import { enumerateRepoFiles } from './lib/validate/enumerate-repo-files.mjs';
|
|
104
|
+
import { writeJsonAtomicSync } from './lib/io.mjs';
|
|
92
105
|
|
|
93
106
|
const PACKAGE_NAME = 'session-orchestrator';
|
|
94
107
|
const SPAWN_OPTS = { encoding: 'utf8', maxBuffer: 32 * 1024 * 1024 };
|
|
@@ -828,6 +841,93 @@ export function evaluateLeakageGate(pack, { minEntries = MIN_PACKED_ENTRIES } =
|
|
|
828
841
|
: { ok: true, detail: `${entries} packed entries, 0 leaks` };
|
|
829
842
|
}
|
|
830
843
|
|
|
844
|
+
/** Strict npm 11/12 inventory, preserving the existing filename policy/floor. */
|
|
845
|
+
export function evaluatePackedInventory(pack, { minEntries = MIN_PACKED_ENTRIES } = {}) {
|
|
846
|
+
if (pack.status !== 0) return { ok: false, detail: 'npm pack failed' };
|
|
847
|
+
try {
|
|
848
|
+
const json = JSON.parse(pack.stdout);
|
|
849
|
+
const records = Array.isArray(json) ? json : json && typeof json === 'object' ? Object.values(json) : [];
|
|
850
|
+
if (records.length !== 1) throw new Error('inventory-shape');
|
|
851
|
+
const record = records[0];
|
|
852
|
+
const filename = packedFilename([record]);
|
|
853
|
+
if (!filename || basename(filename) !== filename || !/^[A-Za-z0-9][A-Za-z0-9._-]*\.tgz$/.test(filename)) throw new Error('archive-name');
|
|
854
|
+
if (!Array.isArray(record.files) || record.files.length > 20000 || record.files.length < minEntries) throw new Error('inventory-floor');
|
|
855
|
+
const paths = record.files.map((entry) => entry?.path);
|
|
856
|
+
if (paths.some((name) => typeof name !== 'string' || !name || name.length > 4096 || isAbsolute(name) || name.split('/').some((part) => !part || part === '.' || part === '..') || (name.includes('\\') || [...name].some((c) => c.charCodeAt(0) < 32 || c.charCodeAt(0) === 127))) || new Set(paths).size !== paths.length) throw new Error('inventory-path');
|
|
857
|
+
const violations = checkLeakage(paths.map((name) => `npm notice 1B ${name}`));
|
|
858
|
+
if (violations.length) return { ok: false, detail: 'forbidden packed filename class: ' + [...new Set(violations.map((v) => v.name))].join(', ') };
|
|
859
|
+
return { ok: true, detail: `${paths.length} packed entries, 0 filename leaks`, record, filename, paths };
|
|
860
|
+
} catch { return { ok: false, detail: 'invalid packed inventory or insufficient entry count' }; }
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
function archiveDigest(tarballPath) {
|
|
864
|
+
if (!lstatSync(tarballPath).isFile()) throw new Error('checked archive is not a regular file');
|
|
865
|
+
return createHash('sha256').update(readFileSync(tarballPath)).digest('hex');
|
|
866
|
+
}
|
|
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
|
+
|
|
881
|
+
/**
|
|
882
|
+
* Scan extracted archive bytes, retain that archive for the callback, and always
|
|
883
|
+
* clean up. Command/scan failures never call the publication callback. The
|
|
884
|
+
* 20,000-entry inventory ceiling matches the scanner; revisit for larger packs.
|
|
885
|
+
*/
|
|
886
|
+
export async function withCheckedPackage(repoRoot, callback, {
|
|
887
|
+
runImpl = run, env = process.env, minEntries = MIN_PACKED_ENTRIES, cleanupImpl = rmSync,
|
|
888
|
+
} = {}) {
|
|
889
|
+
const work = mkdtempSync(join(tmpdir(), 'so-release-pack-'));
|
|
890
|
+
try {
|
|
891
|
+
let artifact;
|
|
892
|
+
let detail;
|
|
893
|
+
try {
|
|
894
|
+
const opts = { cwd: repoRoot, env: { ...env, npm_config_loglevel: 'notice' }, timeout: 60000 };
|
|
895
|
+
const pack = runImpl('npm', ['pack', '--json', '--ignore-scripts', '--pack-destination', work], opts);
|
|
896
|
+
const inventory = evaluatePackedInventory(pack, { minEntries });
|
|
897
|
+
if (!inventory.ok) return inventory;
|
|
898
|
+
const tarballPath = join(work, inventory.filename);
|
|
899
|
+
const digest = archiveDigest(tarballPath);
|
|
900
|
+
// Refuse links/special members BEFORE extraction and check the file census.
|
|
901
|
+
const members = runImpl('tar', ['-tzf', tarballPath], opts);
|
|
902
|
+
const types = runImpl('tar', ['-tvzf', tarballPath], opts);
|
|
903
|
+
if (members.status !== 0 || types.status !== 0 || types.stdout.trim().split('\n').some((line) => !/^[-d]/.test(line))) throw new Error('archive-list');
|
|
904
|
+
const names = members.stdout.trim().split('\n');
|
|
905
|
+
const files = names.filter((name) => !name.endsWith('/'));
|
|
906
|
+
const expected = new Set(inventory.paths.map((name) => `package/${name}`));
|
|
907
|
+
if (names.some((name) => !name.startsWith('package/') || name.split('/').some((part) => part === '..' || part === '.')) || files.length !== expected.size || new Set(files).size !== files.length || files.some((name) => !expected.has(name))) throw new Error('archive-census');
|
|
908
|
+
const unpacked = join(work, 'unpacked');
|
|
909
|
+
mkdirSync(unpacked);
|
|
910
|
+
const extracted = runImpl('tar', ['-xzf', tarballPath, '-C', unpacked], opts);
|
|
911
|
+
if (extracted.status !== 0) throw new Error('archive-extraction');
|
|
912
|
+
const inventoryPath = join(work, 'inventory.json');
|
|
913
|
+
writeFileSync(inventoryPath, JSON.stringify([inventory.record]), { mode: 0o600 });
|
|
914
|
+
const scanner = fileURLToPath(new URL('./lib/validate/check-owner-leakage.mjs', import.meta.url));
|
|
915
|
+
const scan = runImpl(process.execPath, [scanner, join(unpacked, 'package'), '--require-owner-patterns', '--packed-files', inventoryPath], opts);
|
|
916
|
+
const summaries = [...(scan.stdout || '').matchAll(/^Results: (\d+) passed, (\d+) failed \((\d+) scanned files\)$/gm)];
|
|
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' };
|
|
918
|
+
if (archiveDigest(tarballPath) !== digest) throw new Error('archive-changed');
|
|
919
|
+
chmodSync(tarballPath, 0o400);
|
|
920
|
+
artifact = { tarballPath, digest, integrity: archiveIntegrity(tarballPath) };
|
|
921
|
+
detail = `${inventory.paths.length} packed files, filenames and contents verified`;
|
|
922
|
+
} catch { return { ok: false, detail: 'packed archive preparation or content verification failed' }; }
|
|
923
|
+
return { ok: true, detail, value: await callback(artifact) };
|
|
924
|
+
} finally {
|
|
925
|
+
// Cleanup must never replace an irreversible receipt or the original error.
|
|
926
|
+
try { cleanupImpl(work, { recursive: true, force: true }); }
|
|
927
|
+
catch { console.error('release: temporary package cleanup failed; release outcome unchanged'); }
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
|
|
831
931
|
/**
|
|
832
932
|
* Remote-branch parity verdict over `git ls-remote <remote> refs/heads/<branch>`.
|
|
833
933
|
*
|
|
@@ -979,6 +1079,22 @@ export function validateFlags(values) {
|
|
|
979
1079
|
'Run `--check --skip-ci` to inspect the other surfaces, then `--publish` once CI is actually green on HEAD.',
|
|
980
1080
|
};
|
|
981
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
|
+
}
|
|
982
1098
|
return { ok: true };
|
|
983
1099
|
}
|
|
984
1100
|
|
|
@@ -1209,22 +1325,6 @@ async function preflight(repoRoot, target, { skipCi = false } = {}) {
|
|
|
1209
1325
|
add('ci-green-on-head', ciRows.gitlab.ok, ciRows.gitlab.detail);
|
|
1210
1326
|
add('ci-green-on-head-github', ciRows.github.ok, ciRows.github.detail);
|
|
1211
1327
|
|
|
1212
|
-
// 7. Leakage gate over the actual pack file list.
|
|
1213
|
-
// `npm_config_loglevel` is INHERITED, and `npm pack --dry-run` writes its whole
|
|
1214
|
-
// file listing as `npm notice` lines. Any ancestor that ran under `npm run
|
|
1215
|
-
// --silent` (the pre-push gate does exactly that) therefore hands this child a
|
|
1216
|
-
// silent loglevel and the listing is EMPTY with exit 0 — measured: 818 notice
|
|
1217
|
-
// lines normally, 0 under `npm_config_loglevel=silent`. The blind-scan floor
|
|
1218
|
-
// turns that into a correct fail-closed verdict, but a red leakage row that
|
|
1219
|
-
// means "we could not look" is not the row anyone reads it as. Pin the level
|
|
1220
|
-
// rather than inherit it, so the gate's input never depends on its caller.
|
|
1221
|
-
const pack = run('npm', ['pack', '--dry-run'], {
|
|
1222
|
-
cwd: repoRoot,
|
|
1223
|
-
env: { ...process.env, npm_config_loglevel: 'notice' },
|
|
1224
|
-
});
|
|
1225
|
-
const leakage = evaluateLeakageGate(pack);
|
|
1226
|
-
add('leakage-gate', leakage.ok, leakage.detail);
|
|
1227
|
-
|
|
1228
1328
|
return checks;
|
|
1229
1329
|
}
|
|
1230
1330
|
|
|
@@ -1386,15 +1486,271 @@ export function waitForRegistryPropagation(repoRoot, target, deps = {}) {
|
|
|
1386
1486
|
};
|
|
1387
1487
|
}
|
|
1388
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
|
+
|
|
1389
1547
|
/**
|
|
1390
|
-
*
|
|
1391
|
-
*
|
|
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
|
+
|
|
1620
|
+
/**
|
|
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`.
|
|
1392
1673
|
*
|
|
1393
1674
|
* @param {string} repoRoot
|
|
1394
1675
|
* @param {string} target
|
|
1395
|
-
* @param {{runImpl?: Function, waitImpl?: Function,
|
|
1396
|
-
* @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}}
|
|
1397
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
|
+
|
|
1398
1754
|
/**
|
|
1399
1755
|
* The exact `npm publish` invocation: argv plus spawn options.
|
|
1400
1756
|
*
|
|
@@ -1407,7 +1763,7 @@ export function waitForRegistryPropagation(repoRoot, target, deps = {}) {
|
|
|
1407
1763
|
* reads as unconfirmed, `publish()` throws, and `main()` exits 2 = "pre-receipt,
|
|
1408
1764
|
* safe to rerun" while the registry already holds the version. That is the worst
|
|
1409
1765
|
* failure this file can produce, and it is the same inherited-silent trap the
|
|
1410
|
-
* leakage gate's `npm pack --
|
|
1766
|
+
* leakage gate's `npm pack --json` was already pinned against (see preflight
|
|
1411
1767
|
* step 7). The pin makes the receipt independent of the caller's environment.
|
|
1412
1768
|
*
|
|
1413
1769
|
* Exported because `publish()` itself deliberately is not (it is the
|
|
@@ -1418,10 +1774,11 @@ export function waitForRegistryPropagation(repoRoot, target, deps = {}) {
|
|
|
1418
1774
|
* @param {string} userconfigPath — the 0600 temp npmrc carrying the token
|
|
1419
1775
|
* @returns {{cmd: string, args: string[], opts: {cwd: string, env: object}}}
|
|
1420
1776
|
*/
|
|
1421
|
-
export function publishInvocation(repoRoot, userconfigPath) {
|
|
1777
|
+
export function publishInvocation(repoRoot, userconfigPath, artifact) {
|
|
1778
|
+
if (!artifact?.tarballPath || !artifact.digest || archiveDigest(artifact.tarballPath) !== artifact.digest) throw new Error('refusing publish without unchanged checked archive');
|
|
1422
1779
|
return {
|
|
1423
1780
|
cmd: 'npm',
|
|
1424
|
-
args: ['publish', '--access', 'public', '--userconfig', userconfigPath],
|
|
1781
|
+
args: ['publish', artifact.tarballPath, '--ignore-scripts', '--access', 'public', '--userconfig', userconfigPath],
|
|
1425
1782
|
opts: { cwd: repoRoot, env: { ...process.env, npm_config_loglevel: 'notice' } },
|
|
1426
1783
|
};
|
|
1427
1784
|
}
|
|
@@ -1434,19 +1791,48 @@ function publish(repoRoot, target, deps = {}) {
|
|
|
1434
1791
|
const token = loadNpmToken(repoRoot);
|
|
1435
1792
|
const runImpl = deps.runImpl ?? run;
|
|
1436
1793
|
const res = withTempUserconfig(token, (tmpRc) => {
|
|
1437
|
-
const call = publishInvocation(repoRoot, tmpRc);
|
|
1794
|
+
const call = publishInvocation(repoRoot, tmpRc, deps.artifact);
|
|
1438
1795
|
return runImpl(call.cmd, call.args, call.opts);
|
|
1439
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;
|
|
1440
1815
|
const receipt = evaluatePublishReceipt(res, target);
|
|
1441
1816
|
if (!receipt.confirmed) throw new Error(receipt.detail);
|
|
1442
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
|
+
|
|
1443
1829
|
const propagation = waitForRegistryPropagation(repoRoot, target, {
|
|
1444
1830
|
attempts: deps.attempts,
|
|
1445
1831
|
delaySeconds: deps.delaySeconds,
|
|
1446
1832
|
runImpl,
|
|
1447
1833
|
waitImpl: deps.waitImpl,
|
|
1448
1834
|
});
|
|
1449
|
-
return { receipt, propagation };
|
|
1835
|
+
return { receipt, propagation, proof };
|
|
1450
1836
|
}
|
|
1451
1837
|
|
|
1452
1838
|
function tagAndPush(repoRoot, target) {
|
|
@@ -1540,6 +1926,7 @@ export async function runPublishRelease(repoRoot, target, deps = {}) {
|
|
|
1540
1926
|
release: { ok: false, skipped: true, state: 'skipped-prerequisite', detail: `GitHub release ${prerequisite}` },
|
|
1541
1927
|
live: { ok: false, skipped: true, state: 'skipped-prerequisite', detail: `live-site verification ${prerequisite}` },
|
|
1542
1928
|
propagation,
|
|
1929
|
+
proof: publication.proof,
|
|
1543
1930
|
reconciliation: [
|
|
1544
1931
|
{ phase: 'tag-and-push', kind: 'failed', detail: err instanceof Error ? err.message : String(err) },
|
|
1545
1932
|
{ phase: 'github-release', kind: 'skipped-prerequisite', detail: `GitHub release ${prerequisite}` },
|
|
@@ -1570,6 +1957,7 @@ export async function runPublishRelease(repoRoot, target, deps = {}) {
|
|
|
1570
1957
|
release,
|
|
1571
1958
|
live,
|
|
1572
1959
|
propagation,
|
|
1960
|
+
proof: publication.proof,
|
|
1573
1961
|
reconciliation,
|
|
1574
1962
|
};
|
|
1575
1963
|
}
|
|
@@ -1628,10 +2016,17 @@ export function printPublishOutcome(outcome, target, io = {}) {
|
|
|
1628
2016
|
const tagAndPushFailed = outcome.reconciliation.some((item) => item.phase === 'tag-and-push');
|
|
1629
2017
|
|
|
1630
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
|
+
}
|
|
1631
2023
|
if (outcome.propagation.ok) {
|
|
1632
2024
|
log(` registry verified (${outcome.propagation.detail}).`);
|
|
1633
2025
|
} else {
|
|
1634
|
-
|
|
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}`);
|
|
1635
2030
|
}
|
|
1636
2031
|
if (!tagAndPushFailed) {
|
|
1637
2032
|
log(` tagged ${outcome.tag} (AFTER publish) and pushed main+tag to: ${outcome.pushed.join(', ')}.`);
|
|
@@ -1681,6 +2076,7 @@ export function printPublishOutcome(outcome, target, io = {}) {
|
|
|
1681
2076
|
error(` - ${item.phase}${item.kind ? ` (${item.kind})` : ''}: ${item.detail}`);
|
|
1682
2077
|
}
|
|
1683
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).`);
|
|
1684
2080
|
return 1;
|
|
1685
2081
|
}
|
|
1686
2082
|
|
|
@@ -1860,6 +2256,8 @@ async function main() {
|
|
|
1860
2256
|
check: { type: 'boolean', default: false },
|
|
1861
2257
|
publish: { type: 'boolean', default: false },
|
|
1862
2258
|
'skip-ci': { type: 'boolean', default: false },
|
|
2259
|
+
reconcile: { type: 'boolean', default: false },
|
|
2260
|
+
target: { type: 'string' },
|
|
1863
2261
|
json: { type: 'boolean', default: false },
|
|
1864
2262
|
help: { type: 'boolean', default: false },
|
|
1865
2263
|
version: { type: 'boolean', default: false },
|
|
@@ -1867,12 +2265,14 @@ async function main() {
|
|
|
1867
2265
|
});
|
|
1868
2266
|
|
|
1869
2267
|
if (values.help) {
|
|
1870
|
-
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]');
|
|
1871
2269
|
console.log('Release als ein Dispatch: surface sync, preflight checks, token publish, tag AFTER publish.');
|
|
1872
2270
|
console.log(' Receipt boundary: before the confirmed npm receipt, failure aborts; after it, never rerun --publish.');
|
|
1873
2271
|
console.log(' Tag/push failure after receipt skips GitHub-release and site phases and returns reconciliation guidance.');
|
|
1874
2272
|
console.log(' --skip-ci allowed with --check only; REFUSED with --publish (it verifies nothing).');
|
|
1875
|
-
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.');
|
|
1876
2276
|
return 0;
|
|
1877
2277
|
}
|
|
1878
2278
|
|
|
@@ -1888,6 +2288,17 @@ async function main() {
|
|
|
1888
2288
|
|
|
1889
2289
|
const repoRoot = repoRootOf();
|
|
1890
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
|
+
|
|
1891
2302
|
if (values['set-version']) {
|
|
1892
2303
|
const target = values['set-version'];
|
|
1893
2304
|
if (!/^\d+\.\d+\.\d+$/.test(target)) {
|
|
@@ -1919,23 +2330,27 @@ async function main() {
|
|
|
1919
2330
|
if (values.check || values.publish) {
|
|
1920
2331
|
const target = readPackageVersion(repoRoot);
|
|
1921
2332
|
const checks = await preflight(repoRoot, target, { skipCi: values['skip-ci'] });
|
|
1922
|
-
const
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
2333
|
+
const checked = await withCheckedPackage(repoRoot, async (artifact) => {
|
|
2334
|
+
checks.push({ name: 'leakage-gate', ok: true, detail: 'packed filenames and required owner-policy content scan verified' });
|
|
2335
|
+
const ok = printChecks(checks, values.json && !values.publish, target);
|
|
2336
|
+
if (!ok) return 1;
|
|
2337
|
+
if (!values.publish) return 0;
|
|
2338
|
+
console.log(`\nPublishing ${PACKAGE_NAME}@${target} ...`);
|
|
2339
|
+
console.log(' Publish and post-receipt reconciliation may take several minutes; do not kill it mid-run.');
|
|
2340
|
+
const outcome = await runPublishRelease(repoRoot, target, {
|
|
2341
|
+
publishImpl: (root, version) => publish(root, version, { artifact }),
|
|
2342
|
+
});
|
|
2343
|
+
return printPublishOutcome(outcome, target);
|
|
2344
|
+
});
|
|
2345
|
+
if (!checked.ok) {
|
|
2346
|
+
checks.push({ name: 'leakage-gate', ok: false, detail: checked.detail });
|
|
2347
|
+
printChecks(checks, values.json && !values.publish, target);
|
|
2348
|
+
return 1;
|
|
2349
|
+
}
|
|
2350
|
+
return checked.value;
|
|
2351
|
+
}
|
|
2352
|
+
|
|
2353
|
+
console.error('Nothing to do — pass --check, --publish, --reconcile, or --set-version X.Y.Z (see --help).');
|
|
1939
2354
|
return 2;
|
|
1940
2355
|
}
|
|
1941
2356
|
|