session-orchestrator 3.20.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/030-wave-execution.mdc +10 -8
- package/.cursor/rules/040-discovery.mdc +6 -6
- package/.cursor/rules/050-plan.mdc +8 -8
- package/CHANGELOG.md +515 -0
- package/README.md +16 -11
- package/agents/analyst.md +1 -1
- package/agents/architect-reviewer.md +1 -1
- package/agents/code-implementer.md +4 -2
- package/agents/db-specialist.md +1 -1
- package/agents/dialectic-deriver.md +1 -1
- package/agents/docs-writer.md +1 -1
- package/agents/memory-proposal-collector.md +7 -5
- package/agents/qa-strategist.md +1 -1
- package/agents/security-reviewer.md +1 -1
- package/agents/session-reviewer.md +42 -1
- package/agents/skill-applied-judge.md +1 -1
- package/agents/test-writer.md +1 -1
- package/agents/ui-developer.md +1 -1
- package/agents/ux-evaluator.md +1 -1
- package/commands/eli5.md +33 -0
- package/commands/release.md +62 -0
- package/commands/test.md +2 -2
- package/docs/components.md +6 -5
- package/docs/migration-v3.md +9 -6
- package/docs/persona-panel.md +3 -1
- package/docs/scope-collision-guard.md +167 -0
- package/docs/session-config-reference.md +31 -8
- package/hooks/_lib/lock-bootstrap.mjs +19 -13
- package/hooks/enforce-scope.mjs +103 -3
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks.json +21 -1
- package/hooks/on-session-end.mjs +76 -97
- package/hooks/on-session-start.mjs +195 -104
- package/hooks/on-stop.mjs +127 -12
- package/hooks/post-bash-write-verify.mjs +8 -32
- package/hooks/pre-auq-clarity.mjs +787 -0
- package/hooks/pre-bash-issue-budget.mjs +17 -18
- package/hooks/pre-task-scope-disjoint.mjs +1042 -0
- package/package.json +3 -1
- package/pi/prompts/eli5.md +12 -0
- package/pi/prompts/release.md +12 -0
- package/scripts/auq-audit.mjs +825 -0
- package/scripts/autopilot.mjs +10 -9
- package/scripts/emit-session.mjs +42 -0
- package/scripts/export-hw-learnings.mjs +61 -2
- 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/autopilot/worktree-pipeline.mjs +5 -5
- package/scripts/lib/backlog-scan.mjs +106 -15
- package/scripts/lib/build-live-signals.mjs +7 -3
- package/scripts/lib/ci-status-banner.mjs +267 -77
- 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/git-config-drift.mjs +471 -0
- package/scripts/lib/harness-audit/categories/category6.mjs +65 -12
- package/scripts/lib/io.mjs +432 -7
- package/scripts/lib/issue-budget.mjs +63 -9
- package/scripts/lib/learnings/select.mjs +157 -3
- package/scripts/lib/memory-cleanup-stamp.mjs +132 -8
- package/scripts/lib/mirror-issues-banner.mjs +266 -0
- package/scripts/lib/named-vault-resolver.mjs +105 -16
- package/scripts/lib/owner-interview.mjs +78 -32
- package/scripts/lib/peer-cards/schema.mjs +6 -2
- 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/reconcile/writer.mjs +69 -30
- package/scripts/lib/redact-spans.mjs +89 -0
- 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/scope-baseline.mjs +77 -17
- package/scripts/lib/scope-gate.mjs +658 -0
- package/scripts/lib/secret-masker.mjs +262 -0
- package/scripts/lib/session-lock.mjs +34 -10
- package/scripts/lib/session-registry.mjs +9 -1
- package/scripts/lib/spiral-carryover.mjs +23 -2
- package/scripts/lib/state-md/mission-status.mjs +164 -58
- package/scripts/lib/tmux-layout/vcs-detector.mjs +108 -4
- package/scripts/lib/validate/check-agents.mjs +77 -5
- package/scripts/lib/validate/check-auq-clarity.mjs +274 -0
- package/scripts/lib/validate/check-commands.mjs +2 -20
- package/scripts/lib/validate/check-doc-cli-commands.mjs +514 -0
- package/scripts/lib/validate/check-hooks-symmetry.mjs +48 -0
- package/scripts/lib/validate/check-owner-leakage.mjs +185 -17
- package/scripts/lib/validate/check-rules.mjs +153 -9
- package/scripts/lib/validate/check-skills.mjs +191 -0
- package/scripts/lib/validate/check-test-git-config-target.mjs +665 -0
- package/scripts/lib/validate/check-unicode-safety.mjs +22 -2
- package/scripts/lib/validate/check-untracked-test-deps.mjs +925 -0
- package/scripts/lib/validate/check-unwired-features.mjs +219 -11
- package/scripts/lib/validate/check-vcs-repo-flag.mjs +965 -0
- package/scripts/lib/validate/frontmatter-block.mjs +61 -0
- package/scripts/lib/validate/tier-inference.mjs +46 -8
- package/scripts/lib/vault-backfill/glab.mjs +91 -58
- package/scripts/lib/vault-backfill/manifest.mjs +28 -8
- package/scripts/lib/vault-mirror/namespace.mjs +146 -1
- package/scripts/lib/vault-mirror/process.mjs +264 -31
- package/scripts/lib/vault-mirror/render-sessions.mjs +115 -4
- package/scripts/lib/vault-status/board-writer.mjs +300 -56
- package/scripts/lib/vault-status/narrative-mirror.mjs +119 -5
- package/scripts/lib/vcs-repo-spec.mjs +680 -30
- package/scripts/lib/wave-resource-gate.mjs +67 -73
- package/scripts/materialize-wave-scope.mjs +281 -0
- package/scripts/print-learnings-index.mjs +30 -3
- package/scripts/release.mjs +983 -107
- package/scripts/run-quality-gate.mjs +14 -0
- package/scripts/site-numbers.mjs +1049 -0
- package/scripts/validate-plugin.mjs +64 -0
- package/scripts/validate-wave-scope.mjs +286 -12
- package/scripts/vault-backfill.mjs +32 -5
- package/scripts/vault-mirror.mjs +26 -1
- package/skills/_shared/monitor-patterns.md +24 -4
- package/skills/_shared/parallel-aware-auq.md +30 -24
- package/skills/_shared/parallel-aware-preamble.md +31 -2
- package/skills/_shared/state-ownership.md +49 -6
- package/skills/bootstrap/SKILL.md +2 -1
- package/skills/brainstorm/SKILL.md +18 -18
- package/skills/brainstorm/soul.md +12 -0
- package/skills/claude-md-drift-check/SKILL.md +9 -1
- package/skills/debug/SKILL.md +4 -1
- package/skills/discovery/SKILL.md +28 -24
- package/skills/discovery/issue-templates.md +4 -4
- package/skills/discovery/probes-code.md +2 -2
- package/skills/discovery/probes-feature.md +6 -6
- package/skills/discovery/probes-infra.md +2 -2
- package/skills/discovery/probes-session.md +5 -5
- package/skills/dispatcher/SKILL.md +10 -1
- package/skills/eli5/SKILL.md +43 -0
- package/skills/evolve/SKILL.md +8 -9
- package/skills/frontmatter-guard/SKILL.md +9 -1
- package/skills/gitlab-ops/SKILL.md +73 -59
- package/skills/gitlab-portfolio/SKILL.md +10 -1
- package/skills/grill/SKILL.md +6 -6
- package/skills/grill/soul.md +16 -0
- package/skills/memory-cleanup/SKILL.md +20 -7
- package/skills/npm-publish/SKILL.md +23 -51
- package/skills/peekaboo-driver/SKILL.md +3 -3
- package/skills/persona-panel/SKILL.md +3 -1
- package/skills/plan/SKILL.md +18 -16
- package/skills/plan/mode-feature.md +1 -1
- package/skills/plan/mode-new.md +42 -12
- package/skills/plan/soul.md +12 -0
- package/skills/reconcile/SKILL.md +3 -3
- package/skills/repo-audit/SKILL.md +10 -1
- package/skills/session-end/SKILL.md +97 -22
- package/skills/session-end/metrics-collection.md +1 -1
- package/skills/session-end/phase-3-6-tail.md +37 -2
- package/skills/session-end/session-metrics-write.md +4 -10
- package/skills/session-plan/SKILL.md +2 -2
- package/skills/session-plan/wave-template.md +1 -1
- package/skills/session-start/SKILL.md +82 -36
- 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/spinout/SKILL.md +5 -1
- package/skills/sunset-review/SKILL.md +11 -1
- package/skills/test-runner/SKILL.md +2 -2
- package/skills/tmux-layout/SKILL.md +7 -2
- package/skills/using-orchestrator/SKILL.md +1 -1
- package/skills/vault-mirror/SKILL.md +10 -1
- package/skills/vault-sync/SKILL.md +10 -1
- package/skills/vault-sync/validator.mjs +55 -6
- package/skills/wave-executor/wave-loop.md +64 -12
- package/skills/write-executable-plan/SKILL.md +6 -6
- package/scripts/lib/mission-status-schema.mjs +0 -114
- 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
|
@@ -20,28 +20,52 @@
|
|
|
20
20
|
// entry, README highlights prose) are NOT written —
|
|
21
21
|
// they are enforced by --check instead.
|
|
22
22
|
// --check Preflight: surface parity, CHANGELOG entry present
|
|
23
|
-
// + Unreleased folded, tag collision
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
23
|
+
// + Unreleased folded, drift sweep, tag collision
|
|
24
|
+
// (local, origin, github), github/main mirror parity,
|
|
25
|
+
// npm registry collision, npm token liveness, CI green
|
|
26
|
+
// on HEAD, leakage gate over `npm pack --dry-run`.
|
|
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
|
|
29
32
|
// (never before — eliminates "tagged but unpublished"),
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
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.
|
|
33
38
|
//
|
|
34
39
|
// USAGE:
|
|
35
|
-
// node scripts/release.mjs --check [--json]
|
|
40
|
+
// node scripts/release.mjs --check [--json] [--skip-ci]
|
|
36
41
|
// node scripts/release.mjs --set-version 3.19.0
|
|
37
42
|
// node scripts/release.mjs --publish [--json]
|
|
38
43
|
//
|
|
39
44
|
// EXIT CODES:
|
|
40
45
|
// 0 success
|
|
41
|
-
// 1 check failure (stale surface, missing CHANGELOG entry,
|
|
42
|
-
// collision, CI not green,
|
|
43
|
-
//
|
|
44
|
-
//
|
|
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)
|
|
51
|
+
//
|
|
52
|
+
// FAIL-CLOSED IS THE HOUSE RULE (the defect class this file kept re-growing):
|
|
53
|
+
// A preflight check reports on evidence it GATHERED. When the gathering
|
|
54
|
+
// itself fails — a non-zero exit nobody read, output in an unexpected shape,
|
|
55
|
+
// an empty listing — the honest verdict is "could not tell", and "could not
|
|
56
|
+
// tell" MUST be reported as `ok:false`. Three checks previously did the
|
|
57
|
+
// opposite: an errored `git grep` produced an empty hit list that read as a
|
|
58
|
+
// clean sweep, an unparseable `npm view` produced an empty version list that
|
|
59
|
+
// read as "no collision", and an `npm pack` whose listing did not parse
|
|
60
|
+
// produced zero scanned lines that read as "0 leaks". Each is a green check
|
|
61
|
+
// that verified nothing, on the one code path where being wrong is
|
|
62
|
+
// irreversible. Hence: every check that consumes a subprocess result routes
|
|
63
|
+
// through an exported `evaluate*` function below, which is pure over
|
|
64
|
+
// `{status, stdout, stderr}` and unit-tested against exactly the degraded
|
|
65
|
+
// shapes that used to pass.
|
|
66
|
+
//
|
|
67
|
+
// --skip-ci is the deliberate, operator-visible exception to that rule — and
|
|
68
|
+
// is therefore REFUSED under --publish (see `validateFlags`).
|
|
45
69
|
//
|
|
46
70
|
// SECURITY INVARIANTS (from skills/npm-publish/SKILL.md):
|
|
47
71
|
// - NPM_TOKEN only from gitignored .env.local; never logged, never persisted.
|
|
@@ -63,17 +87,24 @@ import { spawnSync } from 'node:child_process';
|
|
|
63
87
|
import { parseArgs } from 'node:util';
|
|
64
88
|
import { fileURLToPath } from 'node:url';
|
|
65
89
|
|
|
90
|
+
import { resolveRepoSpec } from './lib/vcs-repo-spec.mjs';
|
|
91
|
+
|
|
66
92
|
const PACKAGE_NAME = 'session-orchestrator';
|
|
67
93
|
const SPAWN_OPTS = { encoding: 'utf8', maxBuffer: 32 * 1024 * 1024 };
|
|
68
94
|
|
|
69
95
|
// ---------------------------------------------------------------------------
|
|
70
96
|
// Surfaces table — the SSOT both the scan and the rewrite share.
|
|
71
97
|
//
|
|
72
|
-
// Every entry: { file, patterns: [RegExp] }. Each pattern
|
|
73
|
-
// capture group holding the version. Matching ZERO occurrences
|
|
74
|
-
// failure ("pattern-dead") — that is the guard against a surface
|
|
75
|
-
// falling out of the check after a file refactor. All captured
|
|
76
|
-
// equal the target.
|
|
98
|
+
// Every entry: { file, patterns: [RegExp], checkOnly?: boolean }. Each pattern
|
|
99
|
+
// has exactly one capture group holding the version. Matching ZERO occurrences
|
|
100
|
+
// is a hard failure ("pattern-dead") — that is the guard against a surface
|
|
101
|
+
// silently falling out of the check after a file refactor. All captured
|
|
102
|
+
// versions must equal the target.
|
|
103
|
+
//
|
|
104
|
+
// `checkOnly: true` means: scanned by --check, NOT rewritten by applyVersion,
|
|
105
|
+
// because a different generator owns the write. See site/index.html below for
|
|
106
|
+
// the only current case and for why the ownership split is structural here
|
|
107
|
+
// rather than a comment asking the next editor to be careful.
|
|
77
108
|
//
|
|
78
109
|
// CHANGELOG.md is deliberately NOT here: it carries version HISTORY, so a
|
|
79
110
|
// replace-all would corrupt it. It has its own editorial check below.
|
|
@@ -119,8 +150,36 @@ export const SURFACES = [
|
|
|
119
150
|
],
|
|
120
151
|
},
|
|
121
152
|
{
|
|
153
|
+
// ONE WRITER, ONE CHECKER — and they are not the same program.
|
|
154
|
+
//
|
|
155
|
+
// The page carries its version in three `<span data-metric="version">`
|
|
156
|
+
// cells, and `scripts/site-numbers.mjs --write` owns every `data-metric`
|
|
157
|
+
// cell on the site: it recomputes each one from its declared source (for
|
|
158
|
+
// `version`, that source is package.json). This table only READS them back,
|
|
159
|
+
// hence `checkOnly` — applyVersion deliberately does not touch this file.
|
|
160
|
+
//
|
|
161
|
+
// Why that is not a gap: --set-version runs applyVersion FIRST (package.json
|
|
162
|
+
// gets the target) and `site-numbers --write` SECOND, so the generator
|
|
163
|
+
// derives the same literal from the surface applyVersion just wrote. Adding
|
|
164
|
+
// a second writer here would not "make it safer" — it would make two
|
|
165
|
+
// programs authoritative for one cell, and the next divergence between them
|
|
166
|
+
// would be invisible until a release shipped. If the generator ever stops
|
|
167
|
+
// running, this check goes red rather than quietly self-healing, which is
|
|
168
|
+
// the outcome worth having.
|
|
169
|
+
//
|
|
170
|
+
// HISTORY (do not restore either old pattern): the previous entry was
|
|
171
|
+
// `/"softwareVersion":\s*"(...)"/` plus `/v(\d+\.\d+\.\d+)\b/g`. Commit
|
|
172
|
+
// 8802aa4 removed `softwareVersion` from the JSON-LD (deliberately — see the
|
|
173
|
+
// comment at the top of site/index.html) and replaced the bare `vX.Y.Z`
|
|
174
|
+
// literals with the metric cells, leaving BOTH patterns matching nothing.
|
|
175
|
+
// The pattern-dead guard caught that, which is the entire reason it exists.
|
|
176
|
+
// The `\b`-anchored one was also actively dangerous as a WRITE pattern: it
|
|
177
|
+
// was a replace-all over every `vX.Y.Z` on the page, so a sentence
|
|
178
|
+
// mentioning a historical release would have been silently rewritten to the
|
|
179
|
+
// new version by --set-version. The replacement is anchored to the cell.
|
|
122
180
|
file: 'site/index.html',
|
|
123
|
-
patterns: [/"
|
|
181
|
+
patterns: [/data-metric="version"[^>]*>(\d+\.\d+\.\d+)</g],
|
|
182
|
+
checkOnly: true,
|
|
124
183
|
},
|
|
125
184
|
{
|
|
126
185
|
file: 'site/llms.txt',
|
|
@@ -187,12 +246,15 @@ export function scanSurfaces(repoRoot, target) {
|
|
|
187
246
|
/**
|
|
188
247
|
* Mechanically rewrite every surface to the target version by replacing the
|
|
189
248
|
* captured version in each pattern match. Idempotent. Does NOT touch
|
|
190
|
-
* CHANGELOG.md or package-lock.json (the caller syncs the lock via npm)
|
|
249
|
+
* CHANGELOG.md or package-lock.json (the caller syncs the lock via npm), nor
|
|
250
|
+
* any `checkOnly` surface (another generator owns that file's write — see the
|
|
251
|
+
* site/index.html entry in SURFACES).
|
|
191
252
|
* Returns the list of files actually changed.
|
|
192
253
|
*/
|
|
193
254
|
export function applyVersion(repoRoot, target) {
|
|
194
255
|
const changed = [];
|
|
195
256
|
for (const surface of SURFACES) {
|
|
257
|
+
if (surface.checkOnly) continue;
|
|
196
258
|
const abs = join(repoRoot, surface.file);
|
|
197
259
|
if (!existsSync(abs)) continue;
|
|
198
260
|
const before = readFileSync(abs, 'utf8');
|
|
@@ -236,32 +298,328 @@ export function checkChangelogEntry(text, target) {
|
|
|
236
298
|
return { ok: problems.length === 0, problems };
|
|
237
299
|
}
|
|
238
300
|
|
|
239
|
-
// The
|
|
240
|
-
// `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
|
+
|
|
241
308
|
export const LEAKAGE_PATTERNS = [
|
|
242
|
-
{ name: 'tests/',
|
|
243
|
-
{ name: '.orchestrator/',
|
|
244
|
-
{ name: '.claude/',
|
|
245
|
-
{ name: '.github/',
|
|
246
|
-
{ name: 'node_modules',
|
|
247
|
-
{ name: '.env',
|
|
248
|
-
{ 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) },
|
|
316
|
+
// Claimed as checked by docs/distribution/npm-publish-checklist.md long before
|
|
317
|
+
// any code checked it (measured 2026-08-19: 3 leakage lists, 3 different sets).
|
|
318
|
+
// `files` in package.json overrides .gitignore, so a stray .DS_Store inside a
|
|
319
|
+
// shipped directory reaches the tarball.
|
|
320
|
+
{ name: '.DS_Store', matches: (path) => /\.DS_Store/.test(path) },
|
|
249
321
|
];
|
|
250
322
|
|
|
251
|
-
|
|
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
|
+
|
|
332
|
+
// `commands/release.md` quotes the `npm view` OUTPUT that proves the 3.18.0 gap,
|
|
333
|
+
// dated at the line. Bumping it would destroy the evidence it exists to carry —
|
|
334
|
+
// the registry state on that date is the whole point of the paragraph.
|
|
335
|
+
//
|
|
336
|
+
// `site/guide/index.html` carries ONE dated historical sentence — "re-checked
|
|
337
|
+
// against v<prev> on <date>" — deliberately left as a literal: a release that
|
|
338
|
+
// bumped the version while the date stood still would fabricate a verification
|
|
339
|
+
// nobody ran. The page is not unguarded by this exemption. It loses only the
|
|
340
|
+
// coarse prev-tag sweep and keeps the STRICTER guard in
|
|
341
|
+
// tests/scripts/site-numbers.test.mjs, which forbids ANY vX.Y.Z and the current
|
|
342
|
+
// package version outside a `data-metric` cell on EVERY shipped page, and
|
|
343
|
+
// exempts exactly the lines marked `site-numbers:historical`.
|
|
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)/;
|
|
345
|
+
|
|
346
|
+
/** Pure check over packed-entry lines. Returns violations: {name, line}[]. */
|
|
252
347
|
export function checkLeakage(lines) {
|
|
253
348
|
const violations = [];
|
|
254
349
|
for (const line of lines) {
|
|
255
|
-
|
|
256
|
-
|
|
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() });
|
|
257
355
|
}
|
|
258
356
|
}
|
|
259
357
|
return violations;
|
|
260
358
|
}
|
|
261
359
|
|
|
360
|
+
/**
|
|
361
|
+
* One packed tarball entry in `npm pack --dry-run` output:
|
|
362
|
+
* `npm notice 1.3kB .claude-plugin/marketplace.json`.
|
|
363
|
+
*
|
|
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}
|
|
376
|
+
*/
|
|
377
|
+
export function parsePackedEntry(line) {
|
|
378
|
+
const match = line.match(PACKED_ENTRY_RE);
|
|
379
|
+
return match ? { path: match[2].trim() } : null;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Floor on parsed packed entries, below which the leak scan is presumed BLIND
|
|
384
|
+
* rather than clean.
|
|
385
|
+
*
|
|
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 —
|
|
388
|
+
* two differently-shaped measurements agreeing. Package size 2.9 MB, unpacked
|
|
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.
|
|
396
|
+
*
|
|
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
|
|
405
|
+
* deliberate docs/skills prune would not trip it. What it does catch is the
|
|
406
|
+
* whole failure class in one number: an npm output-format change, an
|
|
407
|
+
* `npm notice` prefix rename, a `files`/`.npmignore` edit that drops entire
|
|
408
|
+
* trees — every state in which the scan sees a handful of lines, finds no
|
|
409
|
+
* leak pattern in them, and reports "0 leaks" with total confidence.
|
|
410
|
+
*/
|
|
411
|
+
export const MIN_PACKED_ENTRIES = 400;
|
|
412
|
+
|
|
413
|
+
// ---------------------------------------------------------------------------
|
|
414
|
+
// Preflight evaluators — pure over a spawn result `{status, stdout, stderr}`.
|
|
415
|
+
//
|
|
416
|
+
// These exist so the DECISION of every preflight check is unit-testable while
|
|
417
|
+
// the subprocess call itself stays in the impure section below. Each returns
|
|
418
|
+
// `{ok, detail}`. The shared contract, and the reason this family exists at
|
|
419
|
+
// all, is the FAIL-CLOSED house rule in the file header: an evaluator may
|
|
420
|
+
// return `ok:true` only when it has positively SEEN the evidence, never merely
|
|
421
|
+
// because it failed to see a counterexample.
|
|
422
|
+
// ---------------------------------------------------------------------------
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Drift sweep verdict over a `git grep -l` result.
|
|
426
|
+
*
|
|
427
|
+
* `git grep` exit codes: 0 = matches found, 1 = no match (the success case
|
|
428
|
+
* here), anything else = it did not run. Measured on git 2.x: a bad regex and
|
|
429
|
+
* a bad pathspec both exit 128; git also documents 2 for usage errors. The old
|
|
430
|
+
* inline code read `.stdout` without ever looking at `.status`, so BOTH the
|
|
431
|
+
* no-match case and the it-crashed case produced an empty hit list and the
|
|
432
|
+
* same reassuring detail line, "no tracked file still carries X". A sweep that
|
|
433
|
+
* never ran is not a clean sweep.
|
|
434
|
+
*
|
|
435
|
+
* @param {{status: number, stdout?: string, stderr?: string}} grep
|
|
436
|
+
* @param {string} prevTag — the previous release literal being swept for
|
|
437
|
+
* @param {RegExp} allowlist — files that legitimately carry version HISTORY
|
|
438
|
+
* @returns {{ok: boolean, detail: string}}
|
|
439
|
+
*/
|
|
440
|
+
export function evaluateDriftSweep(grep, prevTag, allowlist) {
|
|
441
|
+
if (grep.status !== 0 && grep.status !== 1) {
|
|
442
|
+
return {
|
|
443
|
+
ok: false,
|
|
444
|
+
detail: `git grep did not run (exit ${grep.status}): ${(grep.stderr || '').trim().slice(0, 200)} — sweep for ${prevTag} is inconclusive`,
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
const hits = (grep.stdout || '')
|
|
448
|
+
.split('\n')
|
|
449
|
+
.filter(Boolean)
|
|
450
|
+
.filter((f) => !allowlist.test(f));
|
|
451
|
+
return {
|
|
452
|
+
ok: hits.length === 0,
|
|
453
|
+
detail: hits.length
|
|
454
|
+
? `still carry ${prevTag}: ${hits.slice(0, 5).join(', ')}`
|
|
455
|
+
: `no tracked file outside the allowlist still carries ${prevTag}`,
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Registry-collision verdict over `npm view <pkg> versions --json`.
|
|
461
|
+
*
|
|
462
|
+
* The most dangerous of the three fail-opens this file carried: on `status 0`
|
|
463
|
+
* with unparseable stdout, the old code swallowed the parse error, left the
|
|
464
|
+
* version list EMPTY, and concluded from that emptiness that the target was
|
|
465
|
+
* free — reporting `latest: ?` while claiming the collision check had passed.
|
|
466
|
+
* Reproduced verbatim: a `<html>` body (proxy/captive-portal response) with
|
|
467
|
+
* exit 0 yields `ok = true`. Any npm output-format change lands in the same
|
|
468
|
+
* hole. An empty ARRAY is treated identically: a published package always has
|
|
469
|
+
* at least one version, so an empty list is a shape we do not understand, not
|
|
470
|
+
* an all-clear.
|
|
471
|
+
*
|
|
472
|
+
* @param {{status: number, stdout?: string, stderr?: string}} view
|
|
473
|
+
* @param {string} target
|
|
474
|
+
* @returns {{ok: boolean, detail: string}}
|
|
475
|
+
*/
|
|
476
|
+
export function evaluateRegistryCollision(view, target) {
|
|
477
|
+
if (view.status !== 0) {
|
|
478
|
+
const e404 = /E404/.test(view.stderr || '');
|
|
479
|
+
return e404
|
|
480
|
+
? { ok: true, detail: 'package not yet on registry (first publish)' }
|
|
481
|
+
: { ok: false, detail: `npm view failed (exit ${view.status}): ${(view.stderr || '').slice(0, 200)}` };
|
|
482
|
+
}
|
|
483
|
+
const raw = view.stdout || '';
|
|
484
|
+
let parsed;
|
|
485
|
+
try {
|
|
486
|
+
parsed = JSON.parse(raw);
|
|
487
|
+
} catch {
|
|
488
|
+
return {
|
|
489
|
+
ok: false,
|
|
490
|
+
detail: `npm view returned unparseable JSON (${raw.length} bytes, starts "${raw.trim().slice(0, 40)}") — cannot rule out a collision on ${target}`,
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
const published = Array.isArray(parsed) ? parsed : [parsed];
|
|
494
|
+
if (published.length === 0) {
|
|
495
|
+
return { ok: false, detail: `npm view returned an empty version list — cannot rule out a collision on ${target}` };
|
|
496
|
+
}
|
|
497
|
+
return published.includes(target)
|
|
498
|
+
? { ok: false, detail: `${target} already published` }
|
|
499
|
+
: { ok: true, detail: `latest: ${published[published.length - 1]}` };
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* Leakage-gate verdict over an `npm pack --dry-run` result.
|
|
504
|
+
*
|
|
505
|
+
* The SURFACES table has `pattern-dead` for exactly this class — a matcher that
|
|
506
|
+
* stops matching its input must be a hard error, never a silent pass — and the
|
|
507
|
+
* leak scan had no equivalent: an `npm pack` that exits 0 with output the scan
|
|
508
|
+
* cannot parse yields zero scanned lines, zero violations, and the verdict
|
|
509
|
+
* "0 packed entries, 0 leaks". Reproduced verbatim with empty stdout+stderr.
|
|
510
|
+
* {@link MIN_PACKED_ENTRIES} is that missing `pattern-dead`.
|
|
511
|
+
*
|
|
512
|
+
* The floor is asserted on the SAME lines `checkLeakage` scans, not on npm's
|
|
513
|
+
* `total files:` summary line. That is the point: the summary could survive a
|
|
514
|
+
* format change that broke the per-entry lines, and it is the per-entry lines
|
|
515
|
+
* whose absence blinds the scan.
|
|
516
|
+
*
|
|
517
|
+
* @param {{status: number, stdout?: string, stderr?: string}} pack
|
|
518
|
+
* @param {{minEntries?: number}} [opts]
|
|
519
|
+
* @returns {{ok: boolean, detail: string}}
|
|
520
|
+
*/
|
|
521
|
+
export function evaluateLeakageGate(pack, { minEntries = MIN_PACKED_ENTRIES } = {}) {
|
|
522
|
+
if (pack.status !== 0) {
|
|
523
|
+
return { ok: false, detail: `npm pack failed (exit ${pack.status}): ${(pack.stderr || '').trim().slice(-200)}` };
|
|
524
|
+
}
|
|
525
|
+
const lines = `${pack.stdout || ''}\n${pack.stderr || ''}`.split('\n');
|
|
526
|
+
const entries = lines.filter(parsePackedEntry).length;
|
|
527
|
+
if (entries < minEntries) {
|
|
528
|
+
return {
|
|
529
|
+
ok: false,
|
|
530
|
+
detail: `only ${entries} packed entries parsed (floor ${minEntries}) — the pack listing did not parse, so the leak scan read ${entries} line(s) and its "no leaks" verdict means nothing`,
|
|
531
|
+
};
|
|
532
|
+
}
|
|
533
|
+
const violations = checkLeakage(lines);
|
|
534
|
+
return violations.length
|
|
535
|
+
? { ok: false, detail: violations.map((v) => `${v.name}: ${v.line}`).slice(0, 5).join(' | ') }
|
|
536
|
+
: { ok: true, detail: `${entries} packed entries, 0 leaks` };
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* Remote-branch parity verdict over `git ls-remote <remote> refs/heads/<branch>`.
|
|
541
|
+
*
|
|
542
|
+
* Preflight compared HEAD against `origin/main` only. The Vercel deploy hangs
|
|
543
|
+
* off the GITHUB mirror, so a mirror that lags is invisible until
|
|
544
|
+
* `verifyLiveSite` fails — which happens AFTER npm publish and AFTER both tag
|
|
545
|
+
* pushes, i.e. after the two irreversible steps. Same fail-closed shape as
|
|
546
|
+
* `tag-free-github`: a failed `ls-remote` is a failed check, and so is output
|
|
547
|
+
* that carries no sha (an empty answer for `refs/heads/main` means the branch
|
|
548
|
+
* is not there at all, which is not parity either).
|
|
549
|
+
*
|
|
550
|
+
* @param {string} remote
|
|
551
|
+
* @param {{status: number, stdout?: string, stderr?: string}} ls
|
|
552
|
+
* @param {string} head — the local HEAD sha
|
|
553
|
+
* @param {string} [branch]
|
|
554
|
+
* @returns {{ok: boolean, detail: string}}
|
|
555
|
+
*/
|
|
556
|
+
export function evaluateRemoteHeadParity(remote, ls, head, branch = 'main') {
|
|
557
|
+
if (ls.status !== 0) {
|
|
558
|
+
return { ok: false, detail: `ls-remote ${remote} failed (exit ${ls.status}): ${(ls.stderr || '').trim().slice(0, 200)}` };
|
|
559
|
+
}
|
|
560
|
+
const sha = (ls.stdout || '').trim().split(/\s+/)[0] || '';
|
|
561
|
+
if (!/^[0-9a-f]{40}$/i.test(sha)) {
|
|
562
|
+
return { ok: false, detail: `ls-remote ${remote} returned no sha for refs/heads/${branch} — cannot compare` };
|
|
563
|
+
}
|
|
564
|
+
return sha === head
|
|
565
|
+
? { ok: true, detail: sha.slice(0, 8) }
|
|
566
|
+
: { ok: false, detail: `${remote}/${branch} at ${sha.slice(0, 8)}, HEAD at ${head.slice(0, 8)} — the mirror is behind` };
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* npm-auth verdict over `npm whoami --userconfig <tmp>`.
|
|
571
|
+
*
|
|
572
|
+
* A dead or revoked token used to surface only inside `publish()`, i.e. after
|
|
573
|
+
* every other preflight check had passed and the operator had committed to the
|
|
574
|
+
* release. The probe is read-only and costs one request. Fail-closed on the
|
|
575
|
+
* empty-identity case too: `whoami` exiting 0 while printing nothing is not
|
|
576
|
+
* proof of an identity.
|
|
577
|
+
*
|
|
578
|
+
* @param {{status: number, stdout?: string, stderr?: string}|null} whoami
|
|
579
|
+
* @returns {{ok: boolean, detail: string}}
|
|
580
|
+
*/
|
|
581
|
+
export function evaluateNpmAuth(whoami) {
|
|
582
|
+
if (!whoami) return { ok: false, detail: 'npm whoami was not run' };
|
|
583
|
+
if (whoami.status !== 0) {
|
|
584
|
+
return { ok: false, detail: `npm whoami exited ${whoami.status}: ${(whoami.stderr || '').trim().slice(0, 200)}` };
|
|
585
|
+
}
|
|
586
|
+
const who = (whoami.stdout || '').trim();
|
|
587
|
+
return who
|
|
588
|
+
? { ok: true, detail: `authenticated as ${who}` }
|
|
589
|
+
: { ok: false, detail: 'npm whoami exited 0 with an empty identity — the token could not be confirmed' };
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* Flag-combination gate, applied before any work.
|
|
594
|
+
*
|
|
595
|
+
* `--skip-ci` turns the CI check into `ok:true` with the detail
|
|
596
|
+
* "SKIPPED via --skip-ci". That is a legitimate affordance for `--check` (an
|
|
597
|
+
* operator inspecting surface parity while a pipeline is still running) and an
|
|
598
|
+
* illegitimate one for `--publish`: it would let a green summary that verified
|
|
599
|
+
* nothing about CI authorise npm publish + two tag pushes, none of which can be
|
|
600
|
+
* taken back. The refusal is a usage error (exit 2), not a check failure —
|
|
601
|
+
* nothing was checked.
|
|
602
|
+
*
|
|
603
|
+
* @param {{publish?: boolean, 'skip-ci'?: boolean}} values
|
|
604
|
+
* @returns {{ok: boolean, code?: number, message?: string}}
|
|
605
|
+
*/
|
|
606
|
+
export function validateFlags(values) {
|
|
607
|
+
if (values.publish && values['skip-ci']) {
|
|
608
|
+
return {
|
|
609
|
+
ok: false,
|
|
610
|
+
code: 2,
|
|
611
|
+
message:
|
|
612
|
+
'--skip-ci is refused under --publish: it makes ci-green-on-head pass without checking anything, and publish is irreversible.\n' +
|
|
613
|
+
'Run `--check --skip-ci` to inspect the other surfaces, then `--publish` once CI is actually green on HEAD.',
|
|
614
|
+
};
|
|
615
|
+
}
|
|
616
|
+
return { ok: true };
|
|
617
|
+
}
|
|
618
|
+
|
|
262
619
|
// ---------------------------------------------------------------------------
|
|
263
|
-
// Impure orchestration below — git/npm/network.
|
|
264
|
-
//
|
|
620
|
+
// Impure orchestration below — git/npm/network. The DECISIONS live in the
|
|
621
|
+
// evaluators above and are unit-tested; what remains here is the plumbing that
|
|
622
|
+
// feeds them.
|
|
265
623
|
// ---------------------------------------------------------------------------
|
|
266
624
|
|
|
267
625
|
function run(cmd, args, opts = {}) {
|
|
@@ -286,15 +644,48 @@ async function preflight(repoRoot, target, { skipCi = false } = {}) {
|
|
|
286
644
|
const checks = [];
|
|
287
645
|
const add = (name, ok, detail = '') => checks.push({ name, ok, detail });
|
|
288
646
|
|
|
289
|
-
// 1. Git state: on main, clean tree, HEAD
|
|
647
|
+
// 1. Git state: on main, clean tree, HEAD present on BOTH publish remotes.
|
|
648
|
+
//
|
|
649
|
+
// Both remotes, symmetrically, and both read LIVE via ls-remote rather than
|
|
650
|
+
// from a local tracking ref. origin (GitLab) is where the code lives; github
|
|
651
|
+
// is where the Vercel git integration watches, so a lagging mirror means the
|
|
652
|
+
// site cannot deploy — and that was previously discovered only by
|
|
653
|
+
// verifyLiveSite, i.e. after npm publish and both tag pushes had already
|
|
654
|
+
// happened. The old origin check read `origin/main` after a `git fetch` whose
|
|
655
|
+
// exit status nobody inspected: a failed fetch left a stale tracking ref that
|
|
656
|
+
// could still equal HEAD, so the comparison was against remembered state
|
|
657
|
+
// rather than remote state. ls-remote has no such intermediate.
|
|
290
658
|
const branch = run('git', ['branch', '--show-current'], { cwd: repoRoot }).stdout.trim();
|
|
291
659
|
add('branch-is-main', branch === 'main', branch);
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
660
|
+
// `git status` exit status is read, not assumed: an empty stdout from a
|
|
661
|
+
// FAILED status call is indistinguishable from a genuinely clean tree, and
|
|
662
|
+
// the empty-reads-as-all-clear shape is exactly the fail-open this file was
|
|
663
|
+
// hardened against elsewhere. Same reasoning for the two `git tag -l` reads
|
|
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.)
|
|
672
|
+
const status = run('git', ['status', '--porcelain'], { cwd: repoRoot });
|
|
673
|
+
const dirty = (status.stdout || '').trim();
|
|
674
|
+
add(
|
|
675
|
+
'working-tree-clean',
|
|
676
|
+
status.status === 0 && dirty === '',
|
|
677
|
+
status.status !== 0
|
|
678
|
+
? `git status failed (exit ${status.status}) — cleanliness unknown`
|
|
679
|
+
: dirty
|
|
680
|
+
? `${dirty.split('\n').length} dirty path(s)`
|
|
681
|
+
: '',
|
|
682
|
+
);
|
|
295
683
|
const head = run('git', ['rev-parse', 'HEAD'], { cwd: repoRoot }).stdout.trim();
|
|
296
|
-
const
|
|
297
|
-
|
|
684
|
+
for (const remote of ['origin', 'github']) {
|
|
685
|
+
const ls = run('git', ['ls-remote', remote, 'refs/heads/main'], { cwd: repoRoot });
|
|
686
|
+
const parity = evaluateRemoteHeadParity(remote, ls, head);
|
|
687
|
+
add(`head-pushed-${remote}`, parity.ok, parity.detail);
|
|
688
|
+
}
|
|
298
689
|
|
|
299
690
|
// 2. Surface parity.
|
|
300
691
|
const surfaceRows = scanSurfaces(repoRoot, target);
|
|
@@ -312,22 +703,33 @@ async function preflight(repoRoot, target, { skipCi = false } = {}) {
|
|
|
312
703
|
// directories, which is exactly how the forgotten .codex-plugin manifest
|
|
313
704
|
// was invisible to a plain rg census. Allowlisted: files that legitimately
|
|
314
705
|
// carry version HISTORY.
|
|
315
|
-
const
|
|
316
|
-
|
|
706
|
+
const tagList = run('git', ['tag', '-l', 'v*', '--sort=-v:refname'], { cwd: repoRoot });
|
|
707
|
+
const prevTag = (tagList.stdout || '')
|
|
708
|
+
.split('\n').map((t) => t.trim().replace(/^v/, ''))
|
|
317
709
|
.filter((t) => /^\d+\.\d+\.\d+$/.test(t) && t !== target)[0];
|
|
318
|
-
if (
|
|
319
|
-
|
|
710
|
+
if (tagList.status !== 0) {
|
|
711
|
+
// "No previous tag" and "could not list tags" are different facts, and only
|
|
712
|
+
// one of them means the sweep is unnecessary.
|
|
713
|
+
add('drift-sweep', false, `git tag -l failed (exit ${tagList.status}) — cannot determine the previous release to sweep for`);
|
|
714
|
+
} else if (prevTag) {
|
|
320
715
|
const grep = run('git', ['grep', '-l', '--fixed-strings', prevTag, '--', '.'], { cwd: repoRoot });
|
|
321
|
-
const
|
|
322
|
-
add('drift-sweep',
|
|
716
|
+
const sweep = evaluateDriftSweep(grep, prevTag, HISTORY_ALLOWLIST);
|
|
717
|
+
add('drift-sweep', sweep.ok, sweep.detail);
|
|
323
718
|
} else {
|
|
324
719
|
add('drift-sweep', true, 'no previous tag to sweep against');
|
|
325
720
|
}
|
|
326
721
|
|
|
327
722
|
// 4. Tag collision — local, origin, github mirror.
|
|
328
723
|
const tag = `v${target}`;
|
|
329
|
-
const
|
|
330
|
-
|
|
724
|
+
const localTagRes = run('git', ['tag', '-l', tag], { cwd: repoRoot });
|
|
725
|
+
const localTag = (localTagRes.stdout || '').trim();
|
|
726
|
+
add(
|
|
727
|
+
'tag-free-local',
|
|
728
|
+
localTagRes.status === 0 && localTag === '',
|
|
729
|
+
localTagRes.status !== 0
|
|
730
|
+
? `git tag -l failed (exit ${localTagRes.status}) — local tag collision unknown`
|
|
731
|
+
: localTag && `${tag} already exists locally`,
|
|
732
|
+
);
|
|
331
733
|
for (const remote of ['origin', 'github']) {
|
|
332
734
|
const ls = run('git', ['ls-remote', '--tags', remote, `refs/tags/${tag}`], { cwd: repoRoot });
|
|
333
735
|
const collision = ls.status === 0 && ls.stdout.trim() !== '';
|
|
@@ -336,21 +738,32 @@ async function preflight(repoRoot, target, { skipCi = false } = {}) {
|
|
|
336
738
|
|
|
337
739
|
// 5. npm registry collision (E404 = name free = fine for a first publish).
|
|
338
740
|
const view = run('npm', ['view', PACKAGE_NAME, 'versions', '--json'], { cwd: repoRoot });
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
741
|
+
const registry = evaluateRegistryCollision(view, target);
|
|
742
|
+
add('registry-version-free', registry.ok, registry.detail);
|
|
743
|
+
|
|
744
|
+
// 5b. npm token liveness. Read-only, one request, and it answers the one
|
|
745
|
+
// question the rest of the preflight cannot: is the credential we are about
|
|
746
|
+
// to publish with actually alive? Without it, a revoked or expired token
|
|
747
|
+
// surfaces inside publish() — after every other check has gone green and the
|
|
748
|
+
// operator has committed to the release. Same token discipline as publish():
|
|
749
|
+
// .env.local only, temp userconfig at 0600, removed in a finally.
|
|
750
|
+
let auth;
|
|
751
|
+
try {
|
|
752
|
+
auth = withTempUserconfig(loadNpmToken(repoRoot), (rc) =>
|
|
753
|
+
run('npm', ['whoami', '--userconfig', rc], { cwd: repoRoot }),
|
|
754
|
+
);
|
|
755
|
+
const verdict = evaluateNpmAuth(auth);
|
|
756
|
+
add('npm-token-live', verdict.ok, verdict.detail);
|
|
757
|
+
} catch (err) {
|
|
758
|
+
// A missing/ungitignored .env.local is a legitimate red preflight, not a
|
|
759
|
+
// crash: "cannot publish from here" is exactly what the operator needs.
|
|
760
|
+
add('npm-token-live', false, err.message);
|
|
350
761
|
}
|
|
351
762
|
|
|
352
763
|
// 6. CI green on HEAD (the repo's iron session-start rule applies to
|
|
353
764
|
// releases doubly: local green is not evidence — see .claude/rules).
|
|
765
|
+
// --skip-ci is refused under --publish upstream in validateFlags(); it can
|
|
766
|
+
// only reach this branch from --check.
|
|
354
767
|
if (skipCi) {
|
|
355
768
|
add('ci-green-on-head', true, 'SKIPPED via --skip-ci');
|
|
356
769
|
} else {
|
|
@@ -361,10 +774,20 @@ async function preflight(repoRoot, target, { skipCi = false } = {}) {
|
|
|
361
774
|
}
|
|
362
775
|
|
|
363
776
|
// 7. Leakage gate over the actual pack file list.
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
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
|
+
});
|
|
789
|
+
const leakage = evaluateLeakageGate(pack);
|
|
790
|
+
add('leakage-gate', leakage.ok, leakage.detail);
|
|
368
791
|
|
|
369
792
|
return checks;
|
|
370
793
|
}
|
|
@@ -376,58 +799,498 @@ function changelogExcerpt(repoRoot, target) {
|
|
|
376
799
|
return m ? m[1].trim().split('\n').slice(0, 40).join('\n') : '';
|
|
377
800
|
}
|
|
378
801
|
|
|
379
|
-
|
|
380
|
-
|
|
802
|
+
/**
|
|
803
|
+
* Read NPM_TOKEN from the gitignored .env.local, refusing if the ignore is not
|
|
804
|
+
* actually in force. Throws with an operator-actionable message; the token
|
|
805
|
+
* itself is never part of any message.
|
|
806
|
+
*/
|
|
807
|
+
function loadNpmToken(repoRoot) {
|
|
381
808
|
const ignored = run('git', ['check-ignore', '.env.local'], { cwd: repoRoot });
|
|
382
809
|
if (ignored.status !== 0) throw new Error('.env.local is NOT gitignored — refusing to read a token from it');
|
|
383
|
-
|
|
384
|
-
const tokenMatch =
|
|
810
|
+
if (!existsSync(join(repoRoot, '.env.local'))) throw new Error('.env.local not found — no NPM_TOKEN to publish with');
|
|
811
|
+
const tokenMatch = readFileSync(join(repoRoot, '.env.local'), 'utf8').match(/^NPM_TOKEN=(.+)$/m);
|
|
385
812
|
if (!tokenMatch) throw new Error('NPM_TOKEN not found in .env.local');
|
|
386
|
-
|
|
813
|
+
return tokenMatch[1].trim();
|
|
814
|
+
}
|
|
387
815
|
|
|
816
|
+
/**
|
|
817
|
+
* Run `fn(userconfigPath)` against a throwaway npm userconfig carrying the
|
|
818
|
+
* token. Extracted so the preflight liveness probe and the publish itself share
|
|
819
|
+
* ONE implementation of the security invariants from
|
|
820
|
+
* skills/npm-publish/SKILL.md — 0600, and removed in a finally even when the
|
|
821
|
+
* callback throws. Two hand-copied versions of this dance would be two places
|
|
822
|
+
* for a token file to be left behind.
|
|
823
|
+
*/
|
|
824
|
+
function withTempUserconfig(token, fn) {
|
|
388
825
|
const tmpDir = mkdtempSync(join(tmpdir(), 'release-npmrc-'));
|
|
389
826
|
const tmpRc = join(tmpDir, 'npmrc');
|
|
390
827
|
try {
|
|
391
|
-
writeFileSync(tmpRc, `//registry.npmjs.org/:_authToken=${token}\n
|
|
828
|
+
writeFileSync(tmpRc, `//registry.npmjs.org/:_authToken=${token}\n`, { mode: 0o600 });
|
|
392
829
|
chmodSync(tmpRc, 0o600);
|
|
393
|
-
|
|
394
|
-
const out = `${res.stdout}\n${res.stderr}`;
|
|
395
|
-
if (res.status !== 0 || !out.includes(`+ ${PACKAGE_NAME}@${target}`)) {
|
|
396
|
-
// Never echo the raw output wholesale into logs beyond the error slice —
|
|
397
|
-
// it cannot contain the token (npm masks userconfig), but stay frugal.
|
|
398
|
-
throw new Error(`npm publish failed (exit ${res.status}): ${out.slice(0, 800)}`);
|
|
399
|
-
}
|
|
830
|
+
return fn(tmpRc);
|
|
400
831
|
} finally {
|
|
401
|
-
|
|
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
|
+
}
|
|
402
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 }));
|
|
403
887
|
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
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
|
+
}
|
|
409
937
|
}
|
|
410
|
-
|
|
938
|
+
return {
|
|
939
|
+
ok: false,
|
|
940
|
+
kind: 'timeout',
|
|
941
|
+
attempts,
|
|
942
|
+
detail: `registry did not report ${target} after ${attempts} attempts`,
|
|
943
|
+
};
|
|
944
|
+
}
|
|
945
|
+
|
|
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 = {}) {
|
|
960
|
+
const token = loadNpmToken(repoRoot);
|
|
961
|
+
const runImpl = deps.runImpl ?? run;
|
|
962
|
+
const res = withTempUserconfig(token, (tmpRc) =>
|
|
963
|
+
runImpl('npm', ['publish', '--access', 'public', '--userconfig', tmpRc], { cwd: repoRoot }),
|
|
964
|
+
);
|
|
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 };
|
|
411
975
|
}
|
|
412
976
|
|
|
413
977
|
function tagAndPush(repoRoot, target) {
|
|
414
978
|
const tag = `v${target}`;
|
|
415
|
-
const
|
|
416
|
-
const msgDir = mkdtempSync(join(tmpdir(), 'release-tagmsg-'));
|
|
417
|
-
const msgFile = join(msgDir, 'msg');
|
|
979
|
+
const progress = { tag, localTagCreated: false, pushed: [], remotes: [] };
|
|
418
980
|
try {
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
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;
|
|
423
1005
|
}
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
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}`);
|
|
1035
|
+
}
|
|
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;
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
/**
|
|
1165
|
+
* Create the GitHub release for `v<target>`, or confirm the existing one.
|
|
1166
|
+
*
|
|
1167
|
+
* WHY THIS IS CODE AND NOT A CHECKLIST LINE: it was a checklist line, and the
|
|
1168
|
+
* evidence that a checklist line is not a mechanism is in the release history.
|
|
1169
|
+
* The GitHub releases for v3.15, v3.18, v3.19 and v3.20 were all created within
|
|
1170
|
+
* a THREE-SECOND window on 2026-08-19 — hand-backfilled in one sitting, 5 to 31
|
|
1171
|
+
* days after their tags, where the releases that were not forgotten were made 19
|
|
1172
|
+
* seconds to 2.5 minutes after theirs. The same class of gap left 3.18.0 with a
|
|
1173
|
+
* tag, a GitHub release and a CHANGELOG entry that the npm registry has still
|
|
1174
|
+
* never seen.
|
|
1175
|
+
*
|
|
1176
|
+
* Three properties make this safe to run unconditionally after a push:
|
|
1177
|
+
* - `--verify-tag` makes gh refuse when the tag is not on the remote, so
|
|
1178
|
+
* "release without a tag" is structurally impossible rather than merely
|
|
1179
|
+
* discouraged.
|
|
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.
|
|
1183
|
+
* - The `-R` spec comes from `resolveRepoSpec({vcs:'github'})` (#1039), not a
|
|
1184
|
+
* hardcoded owner/repo, so a fork or a renamed remote targets its own repo.
|
|
1185
|
+
*
|
|
1186
|
+
* Never throws: the caller has already published to npm and pushed both tags by
|
|
1187
|
+
* the time this runs, so an exception here would report a successful release as
|
|
1188
|
+
* a crash. Failure comes back as `{ok:false}` with the recovery command.
|
|
1189
|
+
*
|
|
1190
|
+
* @param {string} repoRoot
|
|
1191
|
+
* @param {string} target
|
|
1192
|
+
* @param {{runImpl?: Function, repoSpec?: string}} [deps] — injection seam for tests
|
|
1193
|
+
* @returns {{ok: boolean, created: boolean, tag: string, state: 'exists'|'created'|'unknown'|'create-failed', detail: string, argv?: string[]}}
|
|
1194
|
+
*/
|
|
1195
|
+
export function ensureGithubRelease(repoRoot, target, deps = {}) {
|
|
1196
|
+
const runImpl = deps.runImpl ?? run;
|
|
1197
|
+
const tag = `v${target}`;
|
|
1198
|
+
const spec = deps.repoSpec ?? resolveRepoSpec({ repoRoot, vcs: 'github' });
|
|
1199
|
+
// resolveRepoSpec returns undefined when it cannot auto-detect; its contract
|
|
1200
|
+
// is that callers OMIT the flag rather than pass `-R undefined`.
|
|
1201
|
+
const repoFlag = spec ? ['--repo', spec] : [];
|
|
1202
|
+
|
|
1203
|
+
try {
|
|
1204
|
+
const existing = runImpl('gh', ['release', 'view', tag, ...repoFlag], { cwd: repoRoot });
|
|
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
|
+
};
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
const notesDir = mkdtempSync(join(tmpdir(), 'release-ghnotes-'));
|
|
1223
|
+
const notesFile = join(notesDir, 'notes.md');
|
|
1224
|
+
let argv;
|
|
1225
|
+
try {
|
|
1226
|
+
writeFileSync(notesFile, `${changelogExcerpt(repoRoot, target)}\n`);
|
|
1227
|
+
argv = ['release', 'create', tag, ...repoFlag, '--verify-tag', '--title', tag, '--notes-file', notesFile];
|
|
1228
|
+
const created = runImpl('gh', argv, { cwd: repoRoot });
|
|
1229
|
+
if (created.status !== 0) {
|
|
1230
|
+
return {
|
|
1231
|
+
ok: false,
|
|
1232
|
+
created: false,
|
|
1233
|
+
tag,
|
|
1234
|
+
state: 'create-failed',
|
|
1235
|
+
argv,
|
|
1236
|
+
detail: `gh release create exited ${created.status}: ${(created.stderr || created.stdout || '').trim().slice(0, 300)}`,
|
|
1237
|
+
};
|
|
1238
|
+
}
|
|
1239
|
+
return { ok: true, created: true, tag, state: 'created', argv, detail: `GitHub release ${tag} created (--verify-tag)` };
|
|
1240
|
+
} finally {
|
|
1241
|
+
rmSync(notesDir, { recursive: true, force: true });
|
|
1242
|
+
}
|
|
1243
|
+
} catch (err) {
|
|
1244
|
+
return { ok: false, created: false, tag, state: 'unknown', detail: `gh could not be run: ${err.message}` };
|
|
429
1245
|
}
|
|
430
|
-
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
/**
|
|
1249
|
+
* Poll the live site until it serves `expected`, or give up.
|
|
1250
|
+
*
|
|
1251
|
+
* WHY POLLING: the Vercel git integration builds asynchronously after the push
|
|
1252
|
+
* to `github`, so a single immediate check would report a false negative on
|
|
1253
|
+
* every release. WHY AT ALL: the live site silently fell a release behind twice
|
|
1254
|
+
* in four weeks (#1043) — a deploy that reports success at the push and is
|
|
1255
|
+
* never re-read afterwards cannot tell "deployed" from "did not deploy".
|
|
1256
|
+
*
|
|
1257
|
+
* Fail-closed by design: a network error, a non-200, an unparseable body and a
|
|
1258
|
+
* genuine version mismatch are four DISTINCT reported outcomes, never collapsed
|
|
1259
|
+
* onto one "not ok" — collapsing them is the defect class this replaces.
|
|
1260
|
+
*
|
|
1261
|
+
* @param {string} expected — the version literal the site must serve
|
|
1262
|
+
* @param {{url?: string, attempts?: number, delayMs?: number, fetchImpl?: Function}} [opts]
|
|
1263
|
+
* @returns {Promise<{ok: boolean, detail: string}>}
|
|
1264
|
+
*/
|
|
1265
|
+
export async function verifyLiveSite(expected, opts = {}) {
|
|
1266
|
+
const url = opts.url ?? 'https://session-orchestrator.com/llms.txt';
|
|
1267
|
+
const attempts = opts.attempts ?? 12;
|
|
1268
|
+
const delayMs = opts.delayMs ?? 10_000;
|
|
1269
|
+
const doFetch = opts.fetchImpl ?? globalThis.fetch;
|
|
1270
|
+
let last = 'no attempt made';
|
|
1271
|
+
|
|
1272
|
+
for (let i = 1; i <= attempts; i++) {
|
|
1273
|
+
try {
|
|
1274
|
+
const res = await doFetch(url, { headers: { 'Cache-Control': 'no-cache' } });
|
|
1275
|
+
if (!res.ok) {
|
|
1276
|
+
last = `HTTP ${res.status} from ${url}`;
|
|
1277
|
+
} else {
|
|
1278
|
+
const body = await res.text();
|
|
1279
|
+
const m = body.match(/^Version:\s*([0-9]+\.[0-9]+\.[0-9]+)/m);
|
|
1280
|
+
if (!m) {
|
|
1281
|
+
last = `no "Version: X.Y.Z" line in ${url} (${body.length} bytes) — the surface moved, fix the check`;
|
|
1282
|
+
} else if (m[1] === expected) {
|
|
1283
|
+
return { ok: true, detail: `attempt ${i}/${attempts}, ${url}` };
|
|
1284
|
+
} else {
|
|
1285
|
+
last = `live serves ${m[1]}, expected ${expected}`;
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
} catch (err) {
|
|
1289
|
+
last = `fetch failed: ${err.message}`;
|
|
1290
|
+
}
|
|
1291
|
+
if (i < attempts) await new Promise((r) => setTimeout(r, delayMs));
|
|
1292
|
+
}
|
|
1293
|
+
return { ok: false, detail: `${last} (gave up after ${attempts} attempts)` };
|
|
431
1294
|
}
|
|
432
1295
|
|
|
433
1296
|
function printChecks(checks, asJson, version) {
|
|
@@ -459,9 +1322,18 @@ async function main() {
|
|
|
459
1322
|
if (values.help) {
|
|
460
1323
|
console.log('Usage: node scripts/release.mjs [--set-version X.Y.Z | --check | --publish] [--skip-ci] [--json]');
|
|
461
1324
|
console.log('Release als ein Dispatch: surface sync, preflight checks, token publish, tag AFTER publish.');
|
|
462
|
-
console.log('
|
|
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.');
|
|
1327
|
+
console.log(' --skip-ci allowed with --check only; REFUSED with --publish (it verifies nothing).');
|
|
1328
|
+
console.log('Exit codes: 0 success, 1 preflight/check failure or post-publish reconciliation, 2 pre-receipt system/usage error.');
|
|
463
1329
|
return 0;
|
|
464
1330
|
}
|
|
1331
|
+
|
|
1332
|
+
const flags = validateFlags(values);
|
|
1333
|
+
if (!flags.ok) {
|
|
1334
|
+
console.error(flags.message);
|
|
1335
|
+
return flags.code;
|
|
1336
|
+
}
|
|
465
1337
|
if (values.version) {
|
|
466
1338
|
console.log(readPackageVersion(repoRootOf()));
|
|
467
1339
|
return 0;
|
|
@@ -477,9 +1349,20 @@ async function main() {
|
|
|
477
1349
|
}
|
|
478
1350
|
const changed = applyVersion(repoRoot, target);
|
|
479
1351
|
mustRun('npm', ['install', '--package-lock-only', '--ignore-scripts', '--no-audit', '--no-fund'], { cwd: repoRoot });
|
|
1352
|
+
|
|
1353
|
+
// Re-stamp the site's measured census (#1043, second drift level). The
|
|
1354
|
+
// version literals above are only half the problem: the "Measured in this
|
|
1355
|
+
// repository" block was typed once on 2026-08-03 and 5 of its 8 figures
|
|
1356
|
+
// were wrong twelve days later. Release time is the RIGHT moment and CI is
|
|
1357
|
+
// the wrong one — `sessions` and `learnings` grow on every session, so a
|
|
1358
|
+
// pipeline gate on them would be permanently red. The page discloses that
|
|
1359
|
+
// by stamping the date and SHA it was counted at, which this refreshes too.
|
|
1360
|
+
mustRun('node', ['scripts/site-numbers.mjs', '--write'], { cwd: repoRoot });
|
|
1361
|
+
|
|
480
1362
|
console.log(`Rewrote ${changed.length} surface file(s) to ${target}:`);
|
|
481
1363
|
for (const f of changed) console.log(` ${f}`);
|
|
482
1364
|
console.log(' package-lock.json (via npm install --package-lock-only)');
|
|
1365
|
+
console.log(' site/index.html cells + site/_census.json re-stamped (scripts/site-numbers.mjs --write) — commit BOTH');
|
|
483
1366
|
console.log('\nEditorial TODOs (enforced by --check):');
|
|
484
1367
|
console.log(` 1. CHANGELOG.md — write the "## [${target}] - YYYY-MM-DD" entry, fold [Unreleased].`);
|
|
485
1368
|
console.log(' 2. README.md — rewrite the "Recent highlights" section content.');
|
|
@@ -494,15 +1377,8 @@ async function main() {
|
|
|
494
1377
|
if (!values.publish) return 0;
|
|
495
1378
|
|
|
496
1379
|
console.log(`\nPublishing ${PACKAGE_NAME}@${target} ...`);
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
const { tag, pushed } = tagAndPush(repoRoot, target);
|
|
500
|
-
console.log(` tagged ${tag} (AFTER publish) and pushed main+tag to: ${pushed.join(', ')}.`);
|
|
501
|
-
console.log('\nPost-release checklist (manual):');
|
|
502
|
-
console.log(' 1. Site deploy: cd site && vercel --prod');
|
|
503
|
-
console.log(' 2. Rotate/delete the npm token: https://www.npmjs.com/settings/<user>/tokens');
|
|
504
|
-
console.log(' 3. pi.dev gallery indexes asynchronously — do not block on it.');
|
|
505
|
-
return 0;
|
|
1380
|
+
const outcome = await runPublishRelease(repoRoot, target, { publishImpl: publish });
|
|
1381
|
+
return printPublishOutcome(outcome, target);
|
|
506
1382
|
}
|
|
507
1383
|
|
|
508
1384
|
console.error('Nothing to do — pass --check, --publish, or --set-version X.Y.Z (see --help).');
|