session-orchestrator 3.21.0 → 3.22.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/rules/000-session-orchestrator.mdc +3 -2
- package/.cursor/rules/040-discovery.mdc +6 -6
- package/.cursor/rules/050-plan.mdc +8 -8
- package/CHANGELOG.md +101 -0
- package/README.md +10 -10
- package/agents/memory-proposal-collector.md +6 -4
- package/commands/eli5.md +33 -0
- package/commands/release.md +5 -3
- package/commands/test.md +2 -2
- package/docs/components.md +6 -5
- package/docs/scope-collision-guard.md +3 -3
- package/docs/session-config-reference.md +31 -8
- package/hooks/_lib/lock-bootstrap.mjs +19 -13
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks.json +11 -1
- package/hooks/on-session-end.mjs +24 -92
- package/hooks/on-session-start.mjs +195 -104
- package/hooks/pre-auq-clarity.mjs +787 -0
- package/hooks/pre-bash-issue-budget.mjs +17 -18
- package/package.json +3 -1
- package/pi/prompts/eli5.md +12 -0
- package/scripts/auq-audit.mjs +825 -0
- package/scripts/autopilot.mjs +7 -8
- package/scripts/lib/auq/clarity.mjs +1314 -0
- package/scripts/lib/auq/parse.mjs +1006 -0
- package/scripts/lib/auq/schema.mjs +1457 -0
- package/scripts/lib/ci-status-banner.mjs +63 -57
- package/scripts/lib/config/dispatcher-autonomy-capture.mjs +32 -9
- package/scripts/lib/config/vault-integration.mjs +12 -1
- package/scripts/lib/dispatcher/rank.mjs +4 -7
- package/scripts/lib/gates/gate-full.mjs +3 -3
- package/scripts/lib/gates/gate-helpers.mjs +17 -6
- package/scripts/lib/io.mjs +239 -0
- package/scripts/lib/issue-budget.mjs +63 -9
- package/scripts/lib/owner-interview.mjs +78 -32
- package/scripts/lib/peer-discovery.mjs +73 -22
- package/scripts/lib/project-hygiene.mjs +64 -4
- package/scripts/lib/reconcile/renderer.mjs +17 -4
- package/scripts/lib/resource-probe/evaluate.mjs +330 -149
- package/scripts/lib/resource-probe/probe-platform.mjs +35 -0
- package/scripts/lib/resource-probe.mjs +18 -2
- package/scripts/lib/spiral-carryover.mjs +23 -2
- package/scripts/lib/state-md/mission-status.mjs +147 -50
- package/scripts/lib/validate/check-auq-clarity.mjs +274 -0
- package/scripts/lib/validate/check-hooks-symmetry.mjs +30 -0
- package/scripts/lib/validate/check-rules.mjs +153 -9
- package/scripts/lib/vault-backfill/glab.mjs +91 -58
- package/scripts/lib/vault-backfill/manifest.mjs +28 -8
- package/scripts/lib/vcs-repo-spec.mjs +182 -13
- package/scripts/lib/wave-resource-gate.mjs +67 -73
- package/scripts/materialize-wave-scope.mjs +281 -0
- package/scripts/release.mjs +443 -122
- package/scripts/run-quality-gate.mjs +14 -0
- package/scripts/validate-plugin.mjs +3 -0
- package/scripts/validate-wave-scope.mjs +6 -1
- package/scripts/vault-backfill.mjs +32 -5
- package/skills/_shared/parallel-aware-auq.md +30 -24
- package/skills/_shared/parallel-aware-preamble.md +31 -2
- package/skills/_shared/state-ownership.md +32 -6
- package/skills/bootstrap/SKILL.md +2 -1
- package/skills/brainstorm/SKILL.md +18 -18
- package/skills/brainstorm/soul.md +12 -0
- package/skills/discovery/SKILL.md +28 -24
- package/skills/eli5/SKILL.md +43 -0
- package/skills/evolve/SKILL.md +8 -9
- package/skills/gitlab-ops/SKILL.md +30 -26
- package/skills/grill/SKILL.md +6 -6
- package/skills/grill/soul.md +16 -0
- package/skills/memory-cleanup/SKILL.md +2 -2
- package/skills/npm-publish/SKILL.md +4 -4
- package/skills/peekaboo-driver/SKILL.md +3 -3
- package/skills/plan/SKILL.md +18 -16
- package/skills/plan/mode-feature.md +1 -1
- package/skills/plan/mode-new.md +35 -23
- package/skills/plan/soul.md +12 -0
- package/skills/reconcile/SKILL.md +3 -3
- package/skills/session-end/SKILL.md +53 -20
- package/skills/session-end/phase-3-6-tail.md +37 -2
- package/skills/session-start/SKILL.md +69 -35
- package/skills/session-start/phase-2-5-docs-planning.md +8 -8
- package/skills/session-start/phase-4-5-resource-health.md +82 -19
- package/skills/session-start/soul.md +110 -0
- package/skills/test-runner/SKILL.md +2 -2
- package/skills/using-orchestrator/SKILL.md +1 -1
- package/skills/wave-executor/wave-loop.md +27 -5
- package/skills/write-executable-plan/SKILL.md +6 -6
- package/scripts/tests/fixtures/fetch-baseline/sample-rule.md +0 -8
- package/skills/vault-sync/tests/fixtures/archive-test-vault/90-archive/bad-archived.md +0 -8
- package/skills/vault-sync/tests/fixtures/archive-test-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/archive-test-vault/live-note.md +0 -8
- package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/bad-type.md +0 -8
- package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/good-note.md +0 -8
- package/skills/vault-sync/tests/fixtures/clean-vault/.obsidian/config.md +0 -8
- package/skills/vault-sync/tests/fixtures/clean-vault/01-projects/foo/projects-baseline.md +0 -10
- package/skills/vault-sync/tests/fixtures/clean-vault/03-daily/daily-2026-04-13.md +0 -8
- package/skills/vault-sync/tests/fixtures/clean-vault/README.md +0 -3
- package/skills/vault-sync/tests/fixtures/clean-vault/hello-world.md +0 -11
- package/skills/vault-sync/tests/fixtures/dangling-link-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/dangling-link-vault/has-dangling.md +0 -9
- package/skills/vault-sync/tests/fixtures/dangling-link-vault/real-target.md +0 -8
- package/skills/vault-sync/tests/fixtures/empty-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/missing-field-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/missing-field-vault/missing-id.md +0 -7
- package/skills/vault-sync/tests/fixtures/nested-tag-vault/03-daily/daily-2026-04-13.md +0 -9
- package/skills/vault-sync/tests/fixtures/nested-tag-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/nested-tag-vault/nested-tags-note.md +0 -11
- package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/README.md +0 -3
- package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/_MOC.md +0 -3
- package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/with-moc-vault/_MOC.md +0 -11
- package/skills/vault-sync/tests/fixtures/with-moc-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/with-moc-vault/hello-world.md +0 -11
- package/skills/vault-sync/tests/schema-drift.test.mjs +0 -133
package/scripts/release.mjs
CHANGED
|
@@ -24,13 +24,17 @@
|
|
|
24
24
|
// (local, origin, github), github/main mirror parity,
|
|
25
25
|
// npm registry collision, npm token liveness, CI green
|
|
26
26
|
// on HEAD, leakage gate over `npm pack --dry-run`.
|
|
27
|
-
// --publish Runs --check first, then
|
|
28
|
-
// userconfig (NPM_TOKEN from .env.local)
|
|
29
|
-
//
|
|
27
|
+
// --publish Runs --check first, then token publish via temp
|
|
28
|
+
// userconfig (NPM_TOKEN from .env.local). A
|
|
29
|
+
// target-confirmed npm receipt is the irreversible
|
|
30
|
+
// boundary: before it, failure aborts normally; after it,
|
|
31
|
+
// never rerun --publish. The tail tags AFTER receipt
|
|
30
32
|
// (never before — eliminates "tagged but unpublished"),
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
33
|
+
// pushes main + tag to origin AND github, handles the
|
|
34
|
+
// GitHub release (`--verify-tag`), reconciles registry
|
|
35
|
+
// propagation, and polls the live site. A tag/push
|
|
36
|
+
// failure skips the tag-dependent GitHub-release and
|
|
37
|
+
// site phases and returns reconciliation guidance.
|
|
34
38
|
//
|
|
35
39
|
// USAGE:
|
|
36
40
|
// node scripts/release.mjs --check [--json] [--skip-ci]
|
|
@@ -39,10 +43,11 @@
|
|
|
39
43
|
//
|
|
40
44
|
// EXIT CODES:
|
|
41
45
|
// 0 success
|
|
42
|
-
// 1 check failure (stale surface, missing CHANGELOG entry,
|
|
43
|
-
// collision, mirror behind, dead token, CI not green,
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
+
// 1 preflight/check failure (stale surface, missing CHANGELOG entry,
|
|
47
|
+
// tag/registry collision, mirror behind, dead token, CI not green,
|
|
48
|
+
// leakage-gate hit) OR post-publish reconciliation required
|
|
49
|
+
// 2 system/usage error before the npm receipt (git/npm spawn failure,
|
|
50
|
+
// missing NPM_TOKEN, unknown flag, --skip-ci combined with --publish)
|
|
46
51
|
//
|
|
47
52
|
// FAIL-CLOSED IS THE HOUSE RULE (the defect class this file kept re-growing):
|
|
48
53
|
// A preflight check reports on evidence it GATHERED. When the gathering
|
|
@@ -293,23 +298,37 @@ export function checkChangelogEntry(text, target) {
|
|
|
293
298
|
return { ok: problems.length === 0, problems };
|
|
294
299
|
}
|
|
295
300
|
|
|
296
|
-
// The
|
|
297
|
-
// `npm
|
|
301
|
+
// The leak checks operate only on paths extracted from real packed-entry lines,
|
|
302
|
+
// never on arbitrary `npm notice` prose. `tests` and `.claude` are exact path
|
|
303
|
+
// segments: nested copies leak too, while `contest` and `.claude-plugin` do not.
|
|
304
|
+
function hasPathSegment(path, segment) {
|
|
305
|
+
return path.split('/').includes(segment);
|
|
306
|
+
}
|
|
307
|
+
|
|
298
308
|
export const LEAKAGE_PATTERNS = [
|
|
299
|
-
{ name: 'tests/',
|
|
300
|
-
{ name: '.orchestrator/',
|
|
301
|
-
{ name: '.claude/',
|
|
302
|
-
{ name: '.github/',
|
|
303
|
-
{ name: 'node_modules',
|
|
304
|
-
{ name: '.env',
|
|
305
|
-
{ name: 'owner.yaml',
|
|
309
|
+
{ name: 'tests/', matches: (path) => hasPathSegment(path, 'tests') },
|
|
310
|
+
{ name: '.orchestrator/', matches: (path) => /\.orchestrator\//.test(path) },
|
|
311
|
+
{ name: '.claude/', matches: (path) => hasPathSegment(path, '.claude') },
|
|
312
|
+
{ name: '.github/', matches: (path) => /\.github\//.test(path) },
|
|
313
|
+
{ name: 'node_modules', matches: (path) => /node_modules/.test(path) },
|
|
314
|
+
{ name: '.env', matches: (path) => /\.env/i.test(path) },
|
|
315
|
+
{ name: 'owner.yaml', matches: (path) => /owner\.yaml/i.test(path) },
|
|
306
316
|
// Claimed as checked by docs/distribution/npm-publish-checklist.md long before
|
|
307
317
|
// any code checked it (measured 2026-08-19: 3 leakage lists, 3 different sets).
|
|
308
318
|
// `files` in package.json overrides .gitignore, so a stray .DS_Store inside a
|
|
309
319
|
// shipped directory reaches the tarball.
|
|
310
|
-
{ name: '.DS_Store',
|
|
320
|
+
{ name: '.DS_Store', matches: (path) => /\.DS_Store/.test(path) },
|
|
311
321
|
];
|
|
312
322
|
|
|
323
|
+
// Bootstrap's public Standard path copies each selected template in full, so
|
|
324
|
+
// these two sanity tests are intentional scaffold assets, not package-internal
|
|
325
|
+
// test material. This is exact by path and applies only to the `tests/` class:
|
|
326
|
+
// do not turn it into a templates/** or segment-level bypass.
|
|
327
|
+
const INTENTIONAL_TEST_ASSET_PATHS = new Set([
|
|
328
|
+
'templates/node-minimal/tests/sanity.test.ts',
|
|
329
|
+
'templates/python-uv/tests/test_sanity.py',
|
|
330
|
+
]);
|
|
331
|
+
|
|
313
332
|
// `commands/release.md` quotes the `npm view` OUTPUT that proves the 3.18.0 gap,
|
|
314
333
|
// dated at the line. Bumping it would destroy the evidence it exists to carry —
|
|
315
334
|
// the registry state on that date is the whole point of the paragraph.
|
|
@@ -324,12 +343,15 @@ export const LEAKAGE_PATTERNS = [
|
|
|
324
343
|
// exempts exactly the lines marked `site-numbers:historical`.
|
|
325
344
|
export const HISTORY_ALLOWLIST = /^(CHANGELOG\.md|README\.md|docs\/|tests\/|skills\/npm-publish\/|scripts\/release\.mjs|\.orchestrator\/|site\/leaderboard\.json|site\/guide\/index\.html|commands\/release\.md)/;
|
|
326
345
|
|
|
327
|
-
/** Pure check over
|
|
346
|
+
/** Pure check over packed-entry lines. Returns violations: {name, line}[]. */
|
|
328
347
|
export function checkLeakage(lines) {
|
|
329
348
|
const violations = [];
|
|
330
349
|
for (const line of lines) {
|
|
331
|
-
|
|
332
|
-
|
|
350
|
+
const entry = parsePackedEntry(line);
|
|
351
|
+
if (!entry) continue;
|
|
352
|
+
for (const { name, matches } of LEAKAGE_PATTERNS) {
|
|
353
|
+
if (name === 'tests/' && INTENTIONAL_TEST_ASSET_PATHS.has(entry.path)) continue;
|
|
354
|
+
if (matches(entry.path)) violations.push({ name, line: line.trim() });
|
|
333
355
|
}
|
|
334
356
|
}
|
|
335
357
|
return violations;
|
|
@@ -339,27 +361,47 @@ export function checkLeakage(lines) {
|
|
|
339
361
|
* One packed tarball entry in `npm pack --dry-run` output:
|
|
340
362
|
* `npm notice 1.3kB .claude-plugin/marketplace.json`.
|
|
341
363
|
*
|
|
342
|
-
*
|
|
343
|
-
*
|
|
344
|
-
*
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
364
|
+
* This grammar intentionally excludes npm's package metadata and summary
|
|
365
|
+
* notices. Leakage decisions must be made over a file path, not a sentence
|
|
366
|
+
* that happens to mention one.
|
|
367
|
+
*/
|
|
368
|
+
export const PACKED_ENTRY_RE = /^npm notice\s+(\d+(?:\.\d+)?\s*(?:B|kB|MB|GB))\s+(\S.*)$/;
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Parse an npm packed-entry notice into its path. Returns null for every other
|
|
372
|
+
* npm notice line, including package metadata and summaries.
|
|
373
|
+
*
|
|
374
|
+
* @param {string} line
|
|
375
|
+
* @returns {{path: string}|null}
|
|
348
376
|
*/
|
|
349
|
-
export
|
|
377
|
+
export function parsePackedEntry(line) {
|
|
378
|
+
const match = line.match(PACKED_ENTRY_RE);
|
|
379
|
+
return match ? { path: match[2].trim() } : null;
|
|
380
|
+
}
|
|
350
381
|
|
|
351
382
|
/**
|
|
352
383
|
* Floor on parsed packed entries, below which the leak scan is presumed BLIND
|
|
353
384
|
* rather than clean.
|
|
354
385
|
*
|
|
355
|
-
* Measured
|
|
356
|
-
* `total files:
|
|
386
|
+
* Measured 2026-08-21 with `npm pack --dry-run`: npm's own summary reports
|
|
387
|
+
* `total files: 805` and {@link PACKED_ENTRY_RE} independently counts 805 —
|
|
357
388
|
* two differently-shaped measurements agreeing. Package size 2.9 MB, unpacked
|
|
358
|
-
*
|
|
359
|
-
*
|
|
389
|
+
* 9.0 MB. The count dropped from 830 when `package.json` `files` gained
|
|
390
|
+
* `!scripts/tests/**` and `!skills/vault-sync/tests/**`; those 28 entries were
|
|
391
|
+
* shipped in 3.21.0 (scanned: no secrets, no owner data — ballast, not an
|
|
392
|
+
* incident). Independently confirmable after the fact:
|
|
393
|
+
* `npm view session-orchestrator@3.21.0 dist.fileCount` returns 832 — npm's own
|
|
394
|
+
* count of what the registry accepted for THAT version, from outside this repo,
|
|
395
|
+
* and therefore still the pre-exclusion number.
|
|
360
396
|
*
|
|
361
|
-
*
|
|
362
|
-
*
|
|
397
|
+
* (An earlier revision of this comment claimed the checklist "still records the
|
|
398
|
+
* older ~750 files" baseline. It did not: commit a2e495c rewrote that line to
|
|
399
|
+
* the measured 830/2.9/8.9 at the same SHA this comment was written. The claim
|
|
400
|
+
* was a second copy of a fact, contradicting the first, inside the file that
|
|
401
|
+
* argues against second copies. Caught by the post-publish review panel.)
|
|
402
|
+
*
|
|
403
|
+
* 400 is a FLOOR, not a pin — deliberately ~50% of today's count. It cannot
|
|
404
|
+
* break on growth (the pack only grows), and it is far enough below 805 that a
|
|
363
405
|
* deliberate docs/skills prune would not trip it. What it does catch is the
|
|
364
406
|
* whole failure class in one number: an npm output-format change, an
|
|
365
407
|
* `npm notice` prefix rename, a `files`/`.npmignore` edit that drops entire
|
|
@@ -481,7 +523,7 @@ export function evaluateLeakageGate(pack, { minEntries = MIN_PACKED_ENTRIES } =
|
|
|
481
523
|
return { ok: false, detail: `npm pack failed (exit ${pack.status}): ${(pack.stderr || '').trim().slice(-200)}` };
|
|
482
524
|
}
|
|
483
525
|
const lines = `${pack.stdout || ''}\n${pack.stderr || ''}`.split('\n');
|
|
484
|
-
const entries = lines.filter(
|
|
526
|
+
const entries = lines.filter(parsePackedEntry).length;
|
|
485
527
|
if (entries < minEntries) {
|
|
486
528
|
return {
|
|
487
529
|
ok: false,
|
|
@@ -619,8 +661,14 @@ async function preflight(repoRoot, target, { skipCi = false } = {}) {
|
|
|
619
661
|
// FAILED status call is indistinguishable from a genuinely clean tree, and
|
|
620
662
|
// the empty-reads-as-all-clear shape is exactly the fail-open this file was
|
|
621
663
|
// hardened against elsewhere. Same reasoning for the two `git tag -l` reads
|
|
622
|
-
// below
|
|
623
|
-
//
|
|
664
|
+
// below. A third subprocess shares the shape — the `git ls-remote --tags`
|
|
665
|
+
// collision probe further down reads an empty stdout as "no collision" — but
|
|
666
|
+
// that one guards it with an explicit `ls.status === 0`, so it is not
|
|
667
|
+
// fail-open. Emptiness-means-all-clear is the shape to look for; reading the
|
|
668
|
+
// status is what makes it safe. (Census: 14 `run(` call sites in this file.
|
|
669
|
+
// It does NOT cover the raw `spawnSync` calls — a payload-keyed census misses
|
|
670
|
+
// the consumer that uses a different channel, which is how the unchecked
|
|
671
|
+
// propagation wait stayed invisible to it.)
|
|
624
672
|
const status = run('git', ['status', '--porcelain'], { cwd: repoRoot });
|
|
625
673
|
const dirty = (status.stdout || '').trim();
|
|
626
674
|
add(
|
|
@@ -726,7 +774,18 @@ async function preflight(repoRoot, target, { skipCi = false } = {}) {
|
|
|
726
774
|
}
|
|
727
775
|
|
|
728
776
|
// 7. Leakage gate over the actual pack file list.
|
|
729
|
-
|
|
777
|
+
// `npm_config_loglevel` is INHERITED, and `npm pack --dry-run` writes its whole
|
|
778
|
+
// file listing as `npm notice` lines. Any ancestor that ran under `npm run
|
|
779
|
+
// --silent` (the pre-push gate does exactly that) therefore hands this child a
|
|
780
|
+
// silent loglevel and the listing is EMPTY with exit 0 — measured: 818 notice
|
|
781
|
+
// lines normally, 0 under `npm_config_loglevel=silent`. The blind-scan floor
|
|
782
|
+
// turns that into a correct fail-closed verdict, but a red leakage row that
|
|
783
|
+
// means "we could not look" is not the row anyone reads it as. Pin the level
|
|
784
|
+
// rather than inherit it, so the gate's input never depends on its caller.
|
|
785
|
+
const pack = run('npm', ['pack', '--dry-run'], {
|
|
786
|
+
cwd: repoRoot,
|
|
787
|
+
env: { ...process.env, npm_config_loglevel: 'notice' },
|
|
788
|
+
});
|
|
730
789
|
const leakage = evaluateLeakageGate(pack);
|
|
731
790
|
add('leakage-gate', leakage.ok, leakage.detail);
|
|
732
791
|
|
|
@@ -770,49 +829,336 @@ function withTempUserconfig(token, fn) {
|
|
|
770
829
|
chmodSync(tmpRc, 0o600);
|
|
771
830
|
return fn(tmpRc);
|
|
772
831
|
} finally {
|
|
773
|
-
|
|
832
|
+
// NEVER let cleanup decide the release outcome. This finally runs AFTER
|
|
833
|
+
// `npm publish` has already published and BEFORE the caller evaluates the
|
|
834
|
+
// receipt, so a throwing rmSync (EPERM/EBUSY -- `force` only swallows
|
|
835
|
+
// ENOENT) surfaced as a pre-receipt system failure: published, untagged,
|
|
836
|
+
// unpushed, and reported as "safe to re-run". That is the #1088 F1 shape at
|
|
837
|
+
// its last remaining site. A surviving 0600 token file is a hygiene problem,
|
|
838
|
+
// so it is announced rather than swallowed.
|
|
839
|
+
try {
|
|
840
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
841
|
+
} catch (err) {
|
|
842
|
+
process.stderr.write(
|
|
843
|
+
`WARN: could not remove temporary npm userconfig ${tmpDir} (${err?.message ?? err}). ` +
|
|
844
|
+
`It contains a write token — delete it and rotate the token.\n`,
|
|
845
|
+
);
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
/**
|
|
851
|
+
* Evaluate npm publish output for the receipt that makes the release immutable.
|
|
852
|
+
* A successful process alone is insufficient: the receipt must name this package
|
|
853
|
+
* and this exact target version.
|
|
854
|
+
*
|
|
855
|
+
* @param {{status: number|null, stdout?: string, stderr?: string}} result
|
|
856
|
+
* @param {string} target
|
|
857
|
+
* @returns {{confirmed: boolean, target: string, detail: string}}
|
|
858
|
+
*/
|
|
859
|
+
export function evaluatePublishReceipt(result, target) {
|
|
860
|
+
const output = `${result.stdout || ''}\n${result.stderr || ''}`;
|
|
861
|
+
const receipt = new RegExp(`(?:^|\\n)\\+ ${PACKAGE_NAME.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}@${target.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?:\\r?$|\\s)`);
|
|
862
|
+
const confirmed = result.status === 0 && receipt.test(output);
|
|
863
|
+
return {
|
|
864
|
+
confirmed,
|
|
865
|
+
target,
|
|
866
|
+
detail: confirmed
|
|
867
|
+
? `${PACKAGE_NAME}@${target} receipt confirmed`
|
|
868
|
+
: `npm publish did not emit a target-confirmed receipt for ${PACKAGE_NAME}@${target} (exit ${result.status})`,
|
|
869
|
+
};
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
/**
|
|
873
|
+
* Poll the registry after npm has issued a target-confirmed receipt. Every
|
|
874
|
+
* failure remains visible, but none invalidates the already irreversible npm
|
|
875
|
+
* publish; callers must reconcile rather than retry `--publish`.
|
|
876
|
+
*
|
|
877
|
+
* @param {string} repoRoot
|
|
878
|
+
* @param {string} target
|
|
879
|
+
* @param {{attempts?: number, delaySeconds?: number, runImpl?: Function, waitImpl?: Function}} [deps]
|
|
880
|
+
* @returns {{ok: boolean, kind: 'verified'|'timeout'|'query-failed'|'wait-failed', attempts: number, detail: string}}
|
|
881
|
+
*/
|
|
882
|
+
export function waitForRegistryPropagation(repoRoot, target, deps = {}) {
|
|
883
|
+
const attempts = deps.attempts ?? 5;
|
|
884
|
+
const delaySeconds = deps.delaySeconds ?? 3;
|
|
885
|
+
const runImpl = deps.runImpl ?? run;
|
|
886
|
+
const waitImpl = deps.waitImpl ?? (() => runImpl('sleep', [String(delaySeconds)], { cwd: repoRoot }));
|
|
887
|
+
|
|
888
|
+
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
889
|
+
let view;
|
|
890
|
+
try {
|
|
891
|
+
view = runImpl('npm', ['view', PACKAGE_NAME, 'version'], { cwd: repoRoot });
|
|
892
|
+
} catch (err) {
|
|
893
|
+
return {
|
|
894
|
+
ok: false,
|
|
895
|
+
kind: 'query-failed',
|
|
896
|
+
attempts: attempt,
|
|
897
|
+
detail: `registry query failed on attempt ${attempt}/${attempts}: ${err.message}`,
|
|
898
|
+
};
|
|
899
|
+
}
|
|
900
|
+
if (view.status === 0 && (view.stdout || '').trim() === target) {
|
|
901
|
+
return {
|
|
902
|
+
ok: true,
|
|
903
|
+
kind: 'verified',
|
|
904
|
+
attempts: attempt,
|
|
905
|
+
detail: `registry reports ${target} on attempt ${attempt}/${attempts}`,
|
|
906
|
+
};
|
|
907
|
+
}
|
|
908
|
+
if (view.status !== 0) {
|
|
909
|
+
return {
|
|
910
|
+
ok: false,
|
|
911
|
+
kind: 'query-failed',
|
|
912
|
+
attempts: attempt,
|
|
913
|
+
detail: `registry query failed on attempt ${attempt}/${attempts} (exit ${view.status}): ${(view.stderr || view.stdout || '').trim().slice(0, 300)}`,
|
|
914
|
+
};
|
|
915
|
+
}
|
|
916
|
+
if (attempt < attempts) {
|
|
917
|
+
let wait;
|
|
918
|
+
try {
|
|
919
|
+
wait = waitImpl({ attempt, delaySeconds });
|
|
920
|
+
} catch (err) {
|
|
921
|
+
return {
|
|
922
|
+
ok: false,
|
|
923
|
+
kind: 'wait-failed',
|
|
924
|
+
attempts: attempt,
|
|
925
|
+
detail: `registry propagation wait failed after attempt ${attempt}/${attempts}: ${err.message}`,
|
|
926
|
+
};
|
|
927
|
+
}
|
|
928
|
+
if (!wait || wait.status !== 0 || wait.error) {
|
|
929
|
+
return {
|
|
930
|
+
ok: false,
|
|
931
|
+
kind: 'wait-failed',
|
|
932
|
+
attempts: attempt,
|
|
933
|
+
detail: `registry propagation wait failed after attempt ${attempt}/${attempts} (exit ${wait?.status ?? 'unknown'}): ${(wait?.error?.message || wait?.stderr || wait?.stdout || '').trim().slice(0, 300)}`,
|
|
934
|
+
};
|
|
935
|
+
}
|
|
936
|
+
}
|
|
774
937
|
}
|
|
938
|
+
return {
|
|
939
|
+
ok: false,
|
|
940
|
+
kind: 'timeout',
|
|
941
|
+
attempts,
|
|
942
|
+
detail: `registry did not report ${target} after ${attempts} attempts`,
|
|
943
|
+
};
|
|
775
944
|
}
|
|
776
945
|
|
|
777
|
-
|
|
946
|
+
/**
|
|
947
|
+
* Publish and return the receipt boundary plus the registry reconciliation
|
|
948
|
+
* result. Pre-receipt failures throw; post-receipt propagation failures return.
|
|
949
|
+
*
|
|
950
|
+
* @param {string} repoRoot
|
|
951
|
+
* @param {string} target
|
|
952
|
+
* @param {{runImpl?: Function, waitImpl?: Function, attempts?: number, delaySeconds?: number}} [deps]
|
|
953
|
+
* @returns {{receipt: {confirmed: boolean, target: string, detail: string}, propagation: ReturnType<typeof waitForRegistryPropagation>}}
|
|
954
|
+
*/
|
|
955
|
+
// Deliberately NOT exported: this is the irreversible act, and every production
|
|
956
|
+
// path to it runs through main() -> preflight() (leakage gate, CI gate, dirty-tree
|
|
957
|
+
// gate). Exporting it made the whole gate chain bypassable by any importer, and no
|
|
958
|
+
// consumer needs it -- the tests drive runPublishRelease with an injected publisher.
|
|
959
|
+
function publish(repoRoot, target, deps = {}) {
|
|
778
960
|
const token = loadNpmToken(repoRoot);
|
|
961
|
+
const runImpl = deps.runImpl ?? run;
|
|
779
962
|
const res = withTempUserconfig(token, (tmpRc) =>
|
|
780
|
-
|
|
963
|
+
runImpl('npm', ['publish', '--access', 'public', '--userconfig', tmpRc], { cwd: repoRoot }),
|
|
781
964
|
);
|
|
782
|
-
const
|
|
783
|
-
if (
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
if (view.status === 0 && view.stdout.trim() === target) return;
|
|
793
|
-
if (attempt < 5) spawnSync('sleep', ['3']);
|
|
794
|
-
}
|
|
795
|
-
throw new Error(`registry verify failed: npm view does not report ${target} after 5 attempts`);
|
|
965
|
+
const receipt = evaluatePublishReceipt(res, target);
|
|
966
|
+
if (!receipt.confirmed) throw new Error(receipt.detail);
|
|
967
|
+
|
|
968
|
+
const propagation = waitForRegistryPropagation(repoRoot, target, {
|
|
969
|
+
attempts: deps.attempts,
|
|
970
|
+
delaySeconds: deps.delaySeconds,
|
|
971
|
+
runImpl,
|
|
972
|
+
waitImpl: deps.waitImpl,
|
|
973
|
+
});
|
|
974
|
+
return { receipt, propagation };
|
|
796
975
|
}
|
|
797
976
|
|
|
798
977
|
function tagAndPush(repoRoot, target) {
|
|
799
978
|
const tag = `v${target}`;
|
|
800
|
-
const
|
|
801
|
-
const msgDir = mkdtempSync(join(tmpdir(), 'release-tagmsg-'));
|
|
802
|
-
const msgFile = join(msgDir, 'msg');
|
|
979
|
+
const progress = { tag, localTagCreated: false, pushed: [], remotes: [] };
|
|
803
980
|
try {
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
981
|
+
const excerpt = changelogExcerpt(repoRoot, target);
|
|
982
|
+
const msgDir = mkdtempSync(join(tmpdir(), 'release-tagmsg-'));
|
|
983
|
+
const msgFile = join(msgDir, 'msg');
|
|
984
|
+
try {
|
|
985
|
+
writeFileSync(msgFile, `${tag}\n\n${excerpt}\n`);
|
|
986
|
+
mustRun('git', ['tag', '-a', tag, '-F', msgFile], { cwd: repoRoot });
|
|
987
|
+
progress.localTagCreated = true;
|
|
988
|
+
} finally {
|
|
989
|
+
rmSync(msgDir, { recursive: true, force: true });
|
|
990
|
+
}
|
|
991
|
+
for (const remote of ['origin', 'github']) {
|
|
992
|
+
const remoteProgress = { remote, mainPushed: false, tagPushed: false };
|
|
993
|
+
progress.remotes.push(remoteProgress);
|
|
994
|
+
mustRun('git', ['push', remote, 'main'], { cwd: repoRoot });
|
|
995
|
+
remoteProgress.mainPushed = true;
|
|
996
|
+
mustRun('git', ['push', remote, tag], { cwd: repoRoot });
|
|
997
|
+
remoteProgress.tagPushed = true;
|
|
998
|
+
progress.pushed.push(remote);
|
|
999
|
+
}
|
|
1000
|
+
return { tag, pushed: progress.pushed };
|
|
1001
|
+
} catch (err) {
|
|
1002
|
+
const failure = err instanceof Error ? err : new Error(String(err));
|
|
1003
|
+
failure.releaseProgress = progress;
|
|
1004
|
+
throw failure;
|
|
808
1005
|
}
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
/**
|
|
1009
|
+
* Run the irreversible-release tail after npm's target-confirmed receipt.
|
|
1010
|
+
* A tag/push failure stops tag-dependent phases; every other post-receipt
|
|
1011
|
+
* finding remains a returned reconciliation result rather than a retry signal.
|
|
1012
|
+
*
|
|
1013
|
+
* @param {string} repoRoot
|
|
1014
|
+
* @param {string} target
|
|
1015
|
+
* @param {{publishImpl?: Function, tagAndPushImpl?: Function, ensureGithubReleaseImpl?: Function, verifyLiveSiteImpl?: Function}} [deps]
|
|
1016
|
+
* @returns {Promise<{status: 'complete'|'post-publish-reconciliation', receipt: object, tag: string|null, pushed: string[], tagProgress?: object, release: object, live: object, propagation: object, reconciliation: Array<{phase: string, kind?: string, detail: string}>}>}
|
|
1017
|
+
*/
|
|
1018
|
+
export async function runPublishRelease(repoRoot, target, deps = {}) {
|
|
1019
|
+
// Fail-closed: the real publisher must be handed in explicitly. Defaulting to
|
|
1020
|
+
// the live `npm publish --access public` meant an importer that merely forgot
|
|
1021
|
+
// `publishImpl` performed an irreversible public release with the token from
|
|
1022
|
+
// .env.local and no preflight. main() wires it at the one call site that sits
|
|
1023
|
+
// behind the gate chain.
|
|
1024
|
+
const publishImpl = deps.publishImpl;
|
|
1025
|
+
if (typeof publishImpl !== 'function') {
|
|
1026
|
+
throw new Error('runPublishRelease requires an explicit publishImpl — refusing to publish by default');
|
|
1027
|
+
}
|
|
1028
|
+
const tagAndPushImpl = deps.tagAndPushImpl ?? tagAndPush;
|
|
1029
|
+
const ensureGithubReleaseImpl = deps.ensureGithubReleaseImpl ?? ensureGithubRelease;
|
|
1030
|
+
const verifyLiveSiteImpl = deps.verifyLiveSiteImpl ?? verifyLiveSite;
|
|
1031
|
+
const publication = publishImpl(repoRoot, target);
|
|
1032
|
+
|
|
1033
|
+
if (!publication?.receipt?.confirmed || publication.receipt.target !== target) {
|
|
1034
|
+
throw new Error(`refusing release tail without a target-confirmed npm publish receipt for ${PACKAGE_NAME}@${target}`);
|
|
814
1035
|
}
|
|
815
|
-
|
|
1036
|
+
|
|
1037
|
+
const propagation = publication.propagation;
|
|
1038
|
+
let tagAndPushResult;
|
|
1039
|
+
try {
|
|
1040
|
+
tagAndPushResult = tagAndPushImpl(repoRoot, target);
|
|
1041
|
+
} catch (err) {
|
|
1042
|
+
const rawProgress = err?.releaseProgress;
|
|
1043
|
+
const remotes = Array.isArray(rawProgress?.remotes)
|
|
1044
|
+
? rawProgress.remotes
|
|
1045
|
+
.filter((remote) => typeof remote?.remote === 'string')
|
|
1046
|
+
.map((remote) => ({
|
|
1047
|
+
remote: remote.remote,
|
|
1048
|
+
mainPushed: remote.mainPushed === true,
|
|
1049
|
+
tagPushed: remote.tagPushed === true,
|
|
1050
|
+
}))
|
|
1051
|
+
: [];
|
|
1052
|
+
const tagProgress = {
|
|
1053
|
+
tag: typeof rawProgress?.tag === 'string' ? rawProgress.tag : null,
|
|
1054
|
+
localTagCreated: rawProgress?.localTagCreated === true,
|
|
1055
|
+
remotes,
|
|
1056
|
+
};
|
|
1057
|
+
const pushed = remotes.filter((remote) => remote.mainPushed && remote.tagPushed).map((remote) => remote.remote);
|
|
1058
|
+
const prerequisite = 'skipped because tag-and-push did not complete';
|
|
1059
|
+
return {
|
|
1060
|
+
status: 'post-publish-reconciliation',
|
|
1061
|
+
receipt: publication.receipt,
|
|
1062
|
+
tag: tagProgress.tag,
|
|
1063
|
+
pushed,
|
|
1064
|
+
tagProgress,
|
|
1065
|
+
release: { ok: false, skipped: true, state: 'skipped-prerequisite', detail: `GitHub release ${prerequisite}` },
|
|
1066
|
+
live: { ok: false, skipped: true, state: 'skipped-prerequisite', detail: `live-site verification ${prerequisite}` },
|
|
1067
|
+
propagation,
|
|
1068
|
+
reconciliation: [
|
|
1069
|
+
{ phase: 'tag-and-push', kind: 'failed', detail: err instanceof Error ? err.message : String(err) },
|
|
1070
|
+
{ phase: 'github-release', kind: 'skipped-prerequisite', detail: `GitHub release ${prerequisite}` },
|
|
1071
|
+
{ phase: 'live-site', kind: 'skipped-prerequisite', detail: `live-site verification ${prerequisite}` },
|
|
1072
|
+
],
|
|
1073
|
+
};
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
const { tag, pushed } = tagAndPushResult;
|
|
1077
|
+
const release = ensureGithubReleaseImpl(repoRoot, target);
|
|
1078
|
+
const live = await verifyLiveSiteImpl(target);
|
|
1079
|
+
const reconciliation = [];
|
|
1080
|
+
if (!propagation?.ok) {
|
|
1081
|
+
reconciliation.push({
|
|
1082
|
+
phase: 'registry-propagation',
|
|
1083
|
+
kind: propagation?.kind ?? 'unknown',
|
|
1084
|
+
detail: propagation?.detail ?? 'registry propagation was not verified',
|
|
1085
|
+
});
|
|
1086
|
+
}
|
|
1087
|
+
if (!release.ok) reconciliation.push({ phase: 'github-release', detail: release.detail });
|
|
1088
|
+
if (!live.ok) reconciliation.push({ phase: 'live-site', detail: live.detail });
|
|
1089
|
+
|
|
1090
|
+
return {
|
|
1091
|
+
status: reconciliation.length === 0 ? 'complete' : 'post-publish-reconciliation',
|
|
1092
|
+
receipt: publication.receipt,
|
|
1093
|
+
tag,
|
|
1094
|
+
pushed,
|
|
1095
|
+
release,
|
|
1096
|
+
live,
|
|
1097
|
+
propagation,
|
|
1098
|
+
reconciliation,
|
|
1099
|
+
};
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
/**
|
|
1103
|
+
* Print a completed publish-tail outcome and return the CLI exit code.
|
|
1104
|
+
*
|
|
1105
|
+
* @param {{status: string, propagation: object, tag: string|null, pushed: string[], release: object, live: object, reconciliation: Array<{phase: string, kind?: string, detail: string}>}} outcome
|
|
1106
|
+
* @param {string} target
|
|
1107
|
+
* @param {{log?: Function, error?: Function}} [io]
|
|
1108
|
+
* @returns {number}
|
|
1109
|
+
*/
|
|
1110
|
+
export function printPublishOutcome(outcome, target, io = {}) {
|
|
1111
|
+
const log = io.log ?? console.log;
|
|
1112
|
+
const error = io.error ?? console.error;
|
|
1113
|
+
const tagAndPushFailed = outcome.reconciliation.some((item) => item.phase === 'tag-and-push');
|
|
1114
|
+
|
|
1115
|
+
log(` + ${PACKAGE_NAME}@${target} — target-confirmed npm receipt.`);
|
|
1116
|
+
if (outcome.propagation.ok) {
|
|
1117
|
+
log(` registry verified (${outcome.propagation.detail}).`);
|
|
1118
|
+
} else {
|
|
1119
|
+
error(`\nRECONCILIATION: registry propagation is not yet verified — ${outcome.propagation.detail}`);
|
|
1120
|
+
}
|
|
1121
|
+
if (!tagAndPushFailed) {
|
|
1122
|
+
log(` tagged ${outcome.tag} (AFTER publish) and pushed main+tag to: ${outcome.pushed.join(', ')}.`);
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
if (outcome.release.skipped) {
|
|
1126
|
+
error(`\nSKIPPED: ${outcome.release.detail}.`);
|
|
1127
|
+
} else if (outcome.release.ok) {
|
|
1128
|
+
log(` ${outcome.release.detail}.`);
|
|
1129
|
+
} else {
|
|
1130
|
+
error(`\nRECONCILIATION: ${outcome.release.detail}`);
|
|
1131
|
+
if (outcome.release.state === 'create-failed') {
|
|
1132
|
+
error(` Recover with: gh release create ${outcome.tag} --verify-tag --title ${outcome.tag} --notes-file <changelog excerpt>`);
|
|
1133
|
+
} else {
|
|
1134
|
+
error(' Inspect `gh release view` and its authentication/network state before attempting any create.');
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
if (outcome.live.skipped) {
|
|
1139
|
+
error(`\nSKIPPED: ${outcome.live.detail}.`);
|
|
1140
|
+
} else if (!outcome.live.ok) {
|
|
1141
|
+
error(`\nRECONCILIATION: live site did not reach ${target}.`);
|
|
1142
|
+
error(` ${outcome.live.detail}`);
|
|
1143
|
+
error(' Check https://vercel.com/kanevrys-projects/session-orchestrator for the deploy.');
|
|
1144
|
+
} else {
|
|
1145
|
+
log(` site live at ${target} (${outcome.live.detail}).`);
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
if (outcome.status === 'post-publish-reconciliation') {
|
|
1149
|
+
error('\nPost-publish reconciliation required: npm has accepted the target release.');
|
|
1150
|
+
for (const item of outcome.reconciliation) {
|
|
1151
|
+
error(` - ${item.phase}${item.kind ? ` (${item.kind})` : ''}: ${item.detail}`);
|
|
1152
|
+
}
|
|
1153
|
+
error(' Do NOT rerun `--publish`; reconcile the listed post-publish state directly.');
|
|
1154
|
+
return 1;
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
log(`\nRelease complete: ${PACKAGE_NAME}@${target} is published, tagged, released and live.`);
|
|
1158
|
+
log('\nPost-release checklist (manual):');
|
|
1159
|
+
log(' 1. Rotate/delete the npm token: https://www.npmjs.com/settings/<user>/tokens');
|
|
1160
|
+
log(' 2. pi.dev gallery indexes asynchronously — do not block on it.');
|
|
1161
|
+
return 0;
|
|
816
1162
|
}
|
|
817
1163
|
|
|
818
1164
|
/**
|
|
@@ -831,9 +1177,9 @@ function tagAndPush(repoRoot, target) {
|
|
|
831
1177
|
* - `--verify-tag` makes gh refuse when the tag is not on the remote, so
|
|
832
1178
|
* "release without a tag" is structurally impossible rather than merely
|
|
833
1179
|
* discouraged.
|
|
834
|
-
* - The `gh release view` probe
|
|
835
|
-
*
|
|
836
|
-
*
|
|
1180
|
+
* - The `gh release view` probe avoids a duplicate-release error when a
|
|
1181
|
+
* release is already present. It does not authorize rerunning `--publish`:
|
|
1182
|
+
* post-receipt failures are reconciled directly.
|
|
837
1183
|
* - The `-R` spec comes from `resolveRepoSpec({vcs:'github'})` (#1039), not a
|
|
838
1184
|
* hardcoded owner/repo, so a fork or a renamed remote targets its own repo.
|
|
839
1185
|
*
|
|
@@ -844,7 +1190,7 @@ function tagAndPush(repoRoot, target) {
|
|
|
844
1190
|
* @param {string} repoRoot
|
|
845
1191
|
* @param {string} target
|
|
846
1192
|
* @param {{runImpl?: Function, repoSpec?: string}} [deps] — injection seam for tests
|
|
847
|
-
* @returns {{ok: boolean, created: boolean, tag: string, detail: string, argv?: string[]}}
|
|
1193
|
+
* @returns {{ok: boolean, created: boolean, tag: string, state: 'exists'|'created'|'unknown'|'create-failed', detail: string, argv?: string[]}}
|
|
848
1194
|
*/
|
|
849
1195
|
export function ensureGithubRelease(repoRoot, target, deps = {}) {
|
|
850
1196
|
const runImpl = deps.runImpl ?? run;
|
|
@@ -856,8 +1202,21 @@ export function ensureGithubRelease(repoRoot, target, deps = {}) {
|
|
|
856
1202
|
|
|
857
1203
|
try {
|
|
858
1204
|
const existing = runImpl('gh', ['release', 'view', tag, ...repoFlag], { cwd: repoRoot });
|
|
859
|
-
|
|
860
|
-
|
|
1205
|
+
const viewOutput = `${existing.stdout || ''}\n${existing.stderr || ''}`.trim();
|
|
1206
|
+
if (existing.status === 0 && viewOutput) {
|
|
1207
|
+
return { ok: true, created: false, tag, state: 'exists', detail: `GitHub release ${tag} already exists — no-op` };
|
|
1208
|
+
}
|
|
1209
|
+
// `gh release view` is tri-state. Only its documented absence response is
|
|
1210
|
+
// permission to create; auth, network, empty and malformed responses leave
|
|
1211
|
+
// release state unknown and must not trigger a write to GitHub.
|
|
1212
|
+
if (!(existing.status === 1 && /^release not found$/i.test(viewOutput))) {
|
|
1213
|
+
return {
|
|
1214
|
+
ok: false,
|
|
1215
|
+
created: false,
|
|
1216
|
+
tag,
|
|
1217
|
+
state: 'unknown',
|
|
1218
|
+
detail: `could not determine whether GitHub release ${tag} exists (gh release view exited ${existing.status}: ${viewOutput.slice(0, 300) || 'empty output'})`,
|
|
1219
|
+
};
|
|
861
1220
|
}
|
|
862
1221
|
|
|
863
1222
|
const notesDir = mkdtempSync(join(tmpdir(), 'release-ghnotes-'));
|
|
@@ -872,16 +1231,17 @@ export function ensureGithubRelease(repoRoot, target, deps = {}) {
|
|
|
872
1231
|
ok: false,
|
|
873
1232
|
created: false,
|
|
874
1233
|
tag,
|
|
1234
|
+
state: 'create-failed',
|
|
875
1235
|
argv,
|
|
876
1236
|
detail: `gh release create exited ${created.status}: ${(created.stderr || created.stdout || '').trim().slice(0, 300)}`,
|
|
877
1237
|
};
|
|
878
1238
|
}
|
|
879
|
-
return { ok: true, created: true, tag, argv, detail: `GitHub release ${tag} created (--verify-tag)` };
|
|
1239
|
+
return { ok: true, created: true, tag, state: 'created', argv, detail: `GitHub release ${tag} created (--verify-tag)` };
|
|
880
1240
|
} finally {
|
|
881
1241
|
rmSync(notesDir, { recursive: true, force: true });
|
|
882
1242
|
}
|
|
883
1243
|
} catch (err) {
|
|
884
|
-
return { ok: false, created: false, tag, detail: `gh could not be run: ${err.message}` };
|
|
1244
|
+
return { ok: false, created: false, tag, state: 'unknown', detail: `gh could not be run: ${err.message}` };
|
|
885
1245
|
}
|
|
886
1246
|
}
|
|
887
1247
|
|
|
@@ -962,8 +1322,10 @@ async function main() {
|
|
|
962
1322
|
if (values.help) {
|
|
963
1323
|
console.log('Usage: node scripts/release.mjs [--set-version X.Y.Z | --check | --publish] [--skip-ci] [--json]');
|
|
964
1324
|
console.log('Release als ein Dispatch: surface sync, preflight checks, token publish, tag AFTER publish.');
|
|
1325
|
+
console.log(' Receipt boundary: before the confirmed npm receipt, failure aborts; after it, never rerun --publish.');
|
|
1326
|
+
console.log(' Tag/push failure after receipt skips GitHub-release and site phases and returns reconciliation guidance.');
|
|
965
1327
|
console.log(' --skip-ci allowed with --check only; REFUSED with --publish (it verifies nothing).');
|
|
966
|
-
console.log('Exit codes: 0 success, 1 check failure, 2 system/usage error.');
|
|
1328
|
+
console.log('Exit codes: 0 success, 1 preflight/check failure or post-publish reconciliation, 2 pre-receipt system/usage error.');
|
|
967
1329
|
return 0;
|
|
968
1330
|
}
|
|
969
1331
|
|
|
@@ -1015,49 +1377,8 @@ async function main() {
|
|
|
1015
1377
|
if (!values.publish) return 0;
|
|
1016
1378
|
|
|
1017
1379
|
console.log(`\nPublishing ${PACKAGE_NAME}@${target} ...`);
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
const { tag, pushed } = tagAndPush(repoRoot, target);
|
|
1021
|
-
console.log(` tagged ${tag} (AFTER publish) and pushed main+tag to: ${pushed.join(', ')}.`);
|
|
1022
|
-
|
|
1023
|
-
// GitHub release — after the tag is on the remote (so --verify-tag can do
|
|
1024
|
-
// its job), before the site poll. Not fatal on its own: npm and both tags
|
|
1025
|
-
// are already published at this point, and reporting a shipped release as a
|
|
1026
|
-
// crash would be a worse lie than reporting the one missing artefact.
|
|
1027
|
-
const release = ensureGithubRelease(repoRoot, target);
|
|
1028
|
-
if (release.ok) {
|
|
1029
|
-
console.log(` ${release.detail}.`);
|
|
1030
|
-
} else {
|
|
1031
|
-
console.error(`\nFAIL: ${release.detail}`);
|
|
1032
|
-
console.error(' npm publish and both tag pushes SUCCEEDED — only the GitHub release is missing.');
|
|
1033
|
-
console.error(` Recover with: gh release create ${tag} --verify-tag --title ${tag} --notes-file <changelog excerpt>`);
|
|
1034
|
-
}
|
|
1035
|
-
|
|
1036
|
-
// The push to `github` above triggers the Vercel git integration, which
|
|
1037
|
-
// deploys site/ (see vercel.json `outputDirectory`). The deploy is async,
|
|
1038
|
-
// so poll rather than assume. This replaces the old manual checklist line
|
|
1039
|
-
// `cd site && vercel --prod` — a checklist line is not a mechanism, and it
|
|
1040
|
-
// was skipped twice in four weeks (#1043), leaving the live site a full
|
|
1041
|
-
// release behind while every other surface said otherwise.
|
|
1042
|
-
const live = await verifyLiveSite(target);
|
|
1043
|
-
if (!live.ok) {
|
|
1044
|
-
console.error(`\nFAIL: live site did not reach ${target}.`);
|
|
1045
|
-
console.error(` ${live.detail}`);
|
|
1046
|
-
console.error(' npm and the tags ARE published — only the site lag remains.');
|
|
1047
|
-
console.error(' Check https://vercel.com/kanevrys-projects/session-orchestrator for the deploy.');
|
|
1048
|
-
} else {
|
|
1049
|
-
console.log(` site live at ${target} (${live.detail}).`);
|
|
1050
|
-
}
|
|
1051
|
-
|
|
1052
|
-
// Both post-publish steps report before either decides the exit code —
|
|
1053
|
-
// an operator who lost the GitHub release should still learn whether the
|
|
1054
|
-
// site deployed, and vice versa.
|
|
1055
|
-
if (!release.ok || !live.ok) return 1;
|
|
1056
|
-
|
|
1057
|
-
console.log('\nPost-release checklist (manual):');
|
|
1058
|
-
console.log(' 1. Rotate/delete the npm token: https://www.npmjs.com/settings/<user>/tokens');
|
|
1059
|
-
console.log(' 2. pi.dev gallery indexes asynchronously — do not block on it.');
|
|
1060
|
-
return 0;
|
|
1380
|
+
const outcome = await runPublishRelease(repoRoot, target, { publishImpl: publish });
|
|
1381
|
+
return printPublishOutcome(outcome, target);
|
|
1061
1382
|
}
|
|
1062
1383
|
|
|
1063
1384
|
console.error('Nothing to do — pass --check, --publish, or --set-version X.Y.Z (see --help).');
|