session-orchestrator 3.19.0 → 3.21.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/030-wave-execution.mdc +10 -8
- package/CHANGELOG.md +494 -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 +1 -1
- 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/release.md +60 -0
- package/commands/session.md +6 -2
- package/docs/USER-GUIDE.md +1 -1
- package/docs/instruction-delivery.md +350 -0
- 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 +1 -41
- package/docs/session-config-template.md +0 -23
- package/hooks/_lib/guard-source-loader.mjs +304 -91
- package/hooks/enforce-commands.mjs +216 -17
- package/hooks/enforce-scope.mjs +236 -12
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks.json +11 -1
- package/hooks/on-session-end.mjs +52 -5
- package/hooks/on-session-start.mjs +7 -4
- package/hooks/on-stop.mjs +127 -12
- package/hooks/post-bash-write-verify.mjs +8 -32
- package/hooks/pre-bash-destructive-guard.mjs +146 -59
- package/hooks/pre-bash-sessions-ledger-guard.mjs +493 -66
- package/hooks/pre-task-scope-disjoint.mjs +1042 -0
- package/package.json +2 -2
- package/pi/prompts/release.md +12 -0
- package/scripts/autopilot.mjs +3 -1
- package/scripts/backfill-learnings-from-vault.mjs +967 -0
- package/scripts/emit-session.mjs +45 -40
- package/scripts/export-hw-learnings.mjs +61 -2
- 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 +207 -23
- package/scripts/lib/command-blocker.mjs +322 -62
- package/scripts/lib/git-config-drift.mjs +471 -0
- package/scripts/lib/hardening.mjs +9 -9
- package/scripts/lib/harness-audit/categories/category6.mjs +65 -12
- package/scripts/lib/io.mjs +193 -7
- package/scripts/lib/learnings/affinity.mjs +434 -0
- package/scripts/lib/learnings/candidates.mjs +736 -0
- package/scripts/lib/learnings/expiry-sweep.mjs +408 -53
- package/scripts/lib/learnings/judgment.mjs +782 -0
- package/scripts/lib/learnings/kebab.mjs +128 -0
- package/scripts/lib/learnings/select.mjs +704 -0
- 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/peer-cards/schema.mjs +6 -2
- package/scripts/lib/reconcile/emitter.mjs +107 -22
- package/scripts/lib/reconcile/engine.mjs +9 -15
- package/scripts/lib/reconcile/renderer.mjs +141 -25
- package/scripts/lib/reconcile/sanitize.mjs +518 -0
- package/scripts/lib/reconcile/writer.mjs +134 -1
- package/scripts/lib/redact-spans.mjs +89 -0
- package/scripts/lib/scope-baseline.mjs +77 -17
- package/scripts/lib/scope-gate.mjs +852 -72
- package/scripts/lib/secret-masker.mjs +262 -0
- package/scripts/lib/session-close-backfill.mjs +2 -2
- package/scripts/lib/session-lock.mjs +34 -10
- package/scripts/lib/session-record-repair.mjs +551 -0
- package/scripts/lib/session-registry.mjs +9 -1
- package/scripts/lib/session-schema/serializer.mjs +54 -0
- package/scripts/lib/session-schema.mjs +1 -0
- package/scripts/lib/session-token-rollup.mjs +68 -6
- package/scripts/lib/soul-resolve.mjs +12 -0
- package/scripts/lib/state-md/mission-status.mjs +21 -12
- package/scripts/lib/tmux-layout/telemetry.mjs +43 -10
- package/scripts/lib/tmux-layout/vcs-detector.mjs +108 -4
- package/scripts/lib/validate/check-agents.mjs +77 -5
- package/scripts/lib/validate/check-banner-parity.mjs +376 -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-guard-requires-parity.mjs +1148 -0
- package/scripts/lib/validate/check-hooks-symmetry.mjs +18 -0
- package/scripts/lib/validate/check-learning-provenance.mjs +511 -0
- package/scripts/lib/validate/check-owner-leakage.mjs +188 -20
- package/scripts/lib/validate/check-rules.mjs +31 -5
- 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 +757 -0
- 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-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 +500 -19
- package/scripts/print-applicable-rules.mjs +170 -7
- package/scripts/print-learnings-index.mjs +501 -0
- package/scripts/release.mjs +616 -61
- package/scripts/repair-invalid-sessions.mjs +209 -0
- package/scripts/site-numbers.mjs +1049 -0
- package/scripts/sweep-expired-learnings.mjs +192 -32
- package/scripts/validate-plugin.mjs +82 -0
- package/scripts/validate-wave-scope.mjs +281 -12
- package/scripts/vault-mirror.mjs +26 -1
- package/skills/_shared/monitor-patterns.md +24 -4
- package/skills/_shared/state-ownership.md +17 -0
- package/skills/brainstorm/soul.md +47 -1
- package/skills/claude-md-drift-check/SKILL.md +9 -1
- package/skills/debug/SKILL.md +4 -1
- 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/evolve/SKILL.md +116 -18
- package/skills/frontmatter-guard/SKILL.md +9 -1
- package/skills/gitlab-ops/SKILL.md +54 -39
- package/skills/gitlab-portfolio/SKILL.md +10 -1
- package/skills/grill/soul.md +44 -1
- package/skills/memory-cleanup/SKILL.md +18 -5
- package/skills/npm-publish/SKILL.md +22 -50
- package/skills/persona-panel/SKILL.md +3 -1
- package/skills/plan/mode-new.md +23 -5
- package/skills/plan/soul.md +46 -3
- package/skills/repo-audit/SKILL.md +10 -1
- package/skills/session-end/SKILL.md +45 -26
- package/skills/session-end/metrics-collection.md +1 -1
- package/skills/session-end/phase-3-6-tail.md +30 -1
- package/skills/session-end/plan-verification.md +1 -5
- package/skills/session-end/session-metrics-write.md +6 -10
- package/skills/session-plan/SKILL.md +2 -2
- package/skills/session-plan/wave-template.md +1 -1
- package/skills/session-start/SKILL.md +15 -1
- package/skills/session-start/soul.md +41 -1
- package/skills/spinout/SKILL.md +5 -1
- package/skills/sunset-review/SKILL.md +11 -1
- package/skills/tmux-layout/SKILL.md +7 -2
- 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/SKILL.md +1 -5
- package/skills/wave-executor/wave-loop.md +77 -82
- package/scripts/lib/mission-status-schema.mjs +0 -114
package/scripts/release.mjs
CHANGED
|
@@ -20,28 +20,47 @@
|
|
|
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
|
-
//
|
|
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`.
|
|
26
27
|
// --publish Runs --check first, then: token publish via temp
|
|
27
28
|
// userconfig (NPM_TOKEN from .env.local), registry
|
|
28
29
|
// verify, annotated tag AFTER successful publish
|
|
29
30
|
// (never before — eliminates "tagged but unpublished"),
|
|
30
31
|
// push main + tag to origin AND the github mirror,
|
|
31
|
-
//
|
|
32
|
-
//
|
|
32
|
+
// the GitHub release (idempotent, `--verify-tag`), the
|
|
33
|
+
// live-site poll, then the post-release checklist.
|
|
33
34
|
//
|
|
34
35
|
// USAGE:
|
|
35
|
-
// node scripts/release.mjs --check [--json]
|
|
36
|
+
// node scripts/release.mjs --check [--json] [--skip-ci]
|
|
36
37
|
// node scripts/release.mjs --set-version 3.19.0
|
|
37
38
|
// node scripts/release.mjs --publish [--json]
|
|
38
39
|
//
|
|
39
40
|
// EXIT CODES:
|
|
40
41
|
// 0 success
|
|
41
42
|
// 1 check failure (stale surface, missing CHANGELOG entry, tag/registry
|
|
42
|
-
// collision, CI not green, leakage-gate hit)
|
|
43
|
+
// collision, mirror behind, dead token, CI not green, leakage-gate hit)
|
|
43
44
|
// 2 system/usage error (git/npm spawn failure, missing NPM_TOKEN,
|
|
44
|
-
// unknown flag)
|
|
45
|
+
// unknown flag, --skip-ci combined with --publish)
|
|
46
|
+
//
|
|
47
|
+
// FAIL-CLOSED IS THE HOUSE RULE (the defect class this file kept re-growing):
|
|
48
|
+
// A preflight check reports on evidence it GATHERED. When the gathering
|
|
49
|
+
// itself fails — a non-zero exit nobody read, output in an unexpected shape,
|
|
50
|
+
// an empty listing — the honest verdict is "could not tell", and "could not
|
|
51
|
+
// tell" MUST be reported as `ok:false`. Three checks previously did the
|
|
52
|
+
// opposite: an errored `git grep` produced an empty hit list that read as a
|
|
53
|
+
// clean sweep, an unparseable `npm view` produced an empty version list that
|
|
54
|
+
// read as "no collision", and an `npm pack` whose listing did not parse
|
|
55
|
+
// produced zero scanned lines that read as "0 leaks". Each is a green check
|
|
56
|
+
// that verified nothing, on the one code path where being wrong is
|
|
57
|
+
// irreversible. Hence: every check that consumes a subprocess result routes
|
|
58
|
+
// through an exported `evaluate*` function below, which is pure over
|
|
59
|
+
// `{status, stdout, stderr}` and unit-tested against exactly the degraded
|
|
60
|
+
// shapes that used to pass.
|
|
61
|
+
//
|
|
62
|
+
// --skip-ci is the deliberate, operator-visible exception to that rule — and
|
|
63
|
+
// is therefore REFUSED under --publish (see `validateFlags`).
|
|
45
64
|
//
|
|
46
65
|
// SECURITY INVARIANTS (from skills/npm-publish/SKILL.md):
|
|
47
66
|
// - NPM_TOKEN only from gitignored .env.local; never logged, never persisted.
|
|
@@ -63,17 +82,24 @@ import { spawnSync } from 'node:child_process';
|
|
|
63
82
|
import { parseArgs } from 'node:util';
|
|
64
83
|
import { fileURLToPath } from 'node:url';
|
|
65
84
|
|
|
85
|
+
import { resolveRepoSpec } from './lib/vcs-repo-spec.mjs';
|
|
86
|
+
|
|
66
87
|
const PACKAGE_NAME = 'session-orchestrator';
|
|
67
88
|
const SPAWN_OPTS = { encoding: 'utf8', maxBuffer: 32 * 1024 * 1024 };
|
|
68
89
|
|
|
69
90
|
// ---------------------------------------------------------------------------
|
|
70
91
|
// Surfaces table — the SSOT both the scan and the rewrite share.
|
|
71
92
|
//
|
|
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.
|
|
93
|
+
// Every entry: { file, patterns: [RegExp], checkOnly?: boolean }. Each pattern
|
|
94
|
+
// has exactly one capture group holding the version. Matching ZERO occurrences
|
|
95
|
+
// is a hard failure ("pattern-dead") — that is the guard against a surface
|
|
96
|
+
// silently falling out of the check after a file refactor. All captured
|
|
97
|
+
// versions must equal the target.
|
|
98
|
+
//
|
|
99
|
+
// `checkOnly: true` means: scanned by --check, NOT rewritten by applyVersion,
|
|
100
|
+
// because a different generator owns the write. See site/index.html below for
|
|
101
|
+
// the only current case and for why the ownership split is structural here
|
|
102
|
+
// rather than a comment asking the next editor to be careful.
|
|
77
103
|
//
|
|
78
104
|
// CHANGELOG.md is deliberately NOT here: it carries version HISTORY, so a
|
|
79
105
|
// replace-all would corrupt it. It has its own editorial check below.
|
|
@@ -119,8 +145,36 @@ export const SURFACES = [
|
|
|
119
145
|
],
|
|
120
146
|
},
|
|
121
147
|
{
|
|
148
|
+
// ONE WRITER, ONE CHECKER — and they are not the same program.
|
|
149
|
+
//
|
|
150
|
+
// The page carries its version in three `<span data-metric="version">`
|
|
151
|
+
// cells, and `scripts/site-numbers.mjs --write` owns every `data-metric`
|
|
152
|
+
// cell on the site: it recomputes each one from its declared source (for
|
|
153
|
+
// `version`, that source is package.json). This table only READS them back,
|
|
154
|
+
// hence `checkOnly` — applyVersion deliberately does not touch this file.
|
|
155
|
+
//
|
|
156
|
+
// Why that is not a gap: --set-version runs applyVersion FIRST (package.json
|
|
157
|
+
// gets the target) and `site-numbers --write` SECOND, so the generator
|
|
158
|
+
// derives the same literal from the surface applyVersion just wrote. Adding
|
|
159
|
+
// a second writer here would not "make it safer" — it would make two
|
|
160
|
+
// programs authoritative for one cell, and the next divergence between them
|
|
161
|
+
// would be invisible until a release shipped. If the generator ever stops
|
|
162
|
+
// running, this check goes red rather than quietly self-healing, which is
|
|
163
|
+
// the outcome worth having.
|
|
164
|
+
//
|
|
165
|
+
// HISTORY (do not restore either old pattern): the previous entry was
|
|
166
|
+
// `/"softwareVersion":\s*"(...)"/` plus `/v(\d+\.\d+\.\d+)\b/g`. Commit
|
|
167
|
+
// 8802aa4 removed `softwareVersion` from the JSON-LD (deliberately — see the
|
|
168
|
+
// comment at the top of site/index.html) and replaced the bare `vX.Y.Z`
|
|
169
|
+
// literals with the metric cells, leaving BOTH patterns matching nothing.
|
|
170
|
+
// The pattern-dead guard caught that, which is the entire reason it exists.
|
|
171
|
+
// The `\b`-anchored one was also actively dangerous as a WRITE pattern: it
|
|
172
|
+
// was a replace-all over every `vX.Y.Z` on the page, so a sentence
|
|
173
|
+
// mentioning a historical release would have been silently rewritten to the
|
|
174
|
+
// new version by --set-version. The replacement is anchored to the cell.
|
|
122
175
|
file: 'site/index.html',
|
|
123
|
-
patterns: [/"
|
|
176
|
+
patterns: [/data-metric="version"[^>]*>(\d+\.\d+\.\d+)</g],
|
|
177
|
+
checkOnly: true,
|
|
124
178
|
},
|
|
125
179
|
{
|
|
126
180
|
file: 'site/llms.txt',
|
|
@@ -187,12 +241,15 @@ export function scanSurfaces(repoRoot, target) {
|
|
|
187
241
|
/**
|
|
188
242
|
* Mechanically rewrite every surface to the target version by replacing the
|
|
189
243
|
* captured version in each pattern match. Idempotent. Does NOT touch
|
|
190
|
-
* CHANGELOG.md or package-lock.json (the caller syncs the lock via npm)
|
|
244
|
+
* CHANGELOG.md or package-lock.json (the caller syncs the lock via npm), nor
|
|
245
|
+
* any `checkOnly` surface (another generator owns that file's write — see the
|
|
246
|
+
* site/index.html entry in SURFACES).
|
|
191
247
|
* Returns the list of files actually changed.
|
|
192
248
|
*/
|
|
193
249
|
export function applyVersion(repoRoot, target) {
|
|
194
250
|
const changed = [];
|
|
195
251
|
for (const surface of SURFACES) {
|
|
252
|
+
if (surface.checkOnly) continue;
|
|
196
253
|
const abs = join(repoRoot, surface.file);
|
|
197
254
|
if (!existsSync(abs)) continue;
|
|
198
255
|
const before = readFileSync(abs, 'utf8');
|
|
@@ -246,8 +303,27 @@ export const LEAKAGE_PATTERNS = [
|
|
|
246
303
|
{ name: 'node_modules', re: /node_modules/ },
|
|
247
304
|
{ name: '.env', re: /npm notice.*\.env/i },
|
|
248
305
|
{ name: 'owner.yaml', re: /owner\.yaml/i },
|
|
306
|
+
// Claimed as checked by docs/distribution/npm-publish-checklist.md long before
|
|
307
|
+
// any code checked it (measured 2026-08-19: 3 leakage lists, 3 different sets).
|
|
308
|
+
// `files` in package.json overrides .gitignore, so a stray .DS_Store inside a
|
|
309
|
+
// shipped directory reaches the tarball.
|
|
310
|
+
{ name: '.DS_Store', re: /\.DS_Store/ },
|
|
249
311
|
];
|
|
250
312
|
|
|
313
|
+
// `commands/release.md` quotes the `npm view` OUTPUT that proves the 3.18.0 gap,
|
|
314
|
+
// dated at the line. Bumping it would destroy the evidence it exists to carry —
|
|
315
|
+
// the registry state on that date is the whole point of the paragraph.
|
|
316
|
+
//
|
|
317
|
+
// `site/guide/index.html` carries ONE dated historical sentence — "re-checked
|
|
318
|
+
// against v<prev> on <date>" — deliberately left as a literal: a release that
|
|
319
|
+
// bumped the version while the date stood still would fabricate a verification
|
|
320
|
+
// nobody ran. The page is not unguarded by this exemption. It loses only the
|
|
321
|
+
// coarse prev-tag sweep and keeps the STRICTER guard in
|
|
322
|
+
// tests/scripts/site-numbers.test.mjs, which forbids ANY vX.Y.Z and the current
|
|
323
|
+
// package version outside a `data-metric` cell on EVERY shipped page, and
|
|
324
|
+
// exempts exactly the lines marked `site-numbers:historical`.
|
|
325
|
+
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
|
+
|
|
251
327
|
/** Pure check over pack-output lines. Returns violations: {name, line}[]. */
|
|
252
328
|
export function checkLeakage(lines) {
|
|
253
329
|
const violations = [];
|
|
@@ -259,9 +335,249 @@ export function checkLeakage(lines) {
|
|
|
259
335
|
return violations;
|
|
260
336
|
}
|
|
261
337
|
|
|
338
|
+
/**
|
|
339
|
+
* One packed tarball entry in `npm pack --dry-run` output:
|
|
340
|
+
* `npm notice 1.3kB .claude-plugin/marketplace.json`.
|
|
341
|
+
*
|
|
342
|
+
* The previous inline counter was `/npm notice.*[0-9]+B /`, which requires a
|
|
343
|
+
* DIGIT immediately before the `B` and therefore matched only entries sized in
|
|
344
|
+
* plain bytes — 108 of the 830 real entries at 8984224. It was never load-
|
|
345
|
+
* bearing (it only decorated a detail string), but it becomes load-bearing the
|
|
346
|
+
* moment a floor is asserted on it, so it is fixed here rather than floored at
|
|
347
|
+
* a number that means nothing.
|
|
348
|
+
*/
|
|
349
|
+
export const PACKED_ENTRY_RE = /^npm notice\s+[\d.]+\s*(?:B|kB|MB|GB)\s+\S/;
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Floor on parsed packed entries, below which the leak scan is presumed BLIND
|
|
353
|
+
* rather than clean.
|
|
354
|
+
*
|
|
355
|
+
* Measured at 8984224 with `npm pack --dry-run`: npm's own summary reports
|
|
356
|
+
* `total files: 830` and {@link PACKED_ENTRY_RE} independently counts 830 —
|
|
357
|
+
* two differently-shaped measurements agreeing. Package size 2.9 MB, unpacked
|
|
358
|
+
* 8.9 MB. (docs/distribution/npm-publish-checklist.md still records the older
|
|
359
|
+
* "~750 files, ~6.5 MB unpacked" baseline; that file has a different owner.)
|
|
360
|
+
*
|
|
361
|
+
* 400 is a FLOOR, not a pin — deliberately ~48% of today's count. It cannot
|
|
362
|
+
* break on growth (the pack only grows), and it is far enough below 830 that a
|
|
363
|
+
* deliberate docs/skills prune would not trip it. What it does catch is the
|
|
364
|
+
* whole failure class in one number: an npm output-format change, an
|
|
365
|
+
* `npm notice` prefix rename, a `files`/`.npmignore` edit that drops entire
|
|
366
|
+
* trees — every state in which the scan sees a handful of lines, finds no
|
|
367
|
+
* leak pattern in them, and reports "0 leaks" with total confidence.
|
|
368
|
+
*/
|
|
369
|
+
export const MIN_PACKED_ENTRIES = 400;
|
|
370
|
+
|
|
371
|
+
// ---------------------------------------------------------------------------
|
|
372
|
+
// Preflight evaluators — pure over a spawn result `{status, stdout, stderr}`.
|
|
373
|
+
//
|
|
374
|
+
// These exist so the DECISION of every preflight check is unit-testable while
|
|
375
|
+
// the subprocess call itself stays in the impure section below. Each returns
|
|
376
|
+
// `{ok, detail}`. The shared contract, and the reason this family exists at
|
|
377
|
+
// all, is the FAIL-CLOSED house rule in the file header: an evaluator may
|
|
378
|
+
// return `ok:true` only when it has positively SEEN the evidence, never merely
|
|
379
|
+
// because it failed to see a counterexample.
|
|
380
|
+
// ---------------------------------------------------------------------------
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Drift sweep verdict over a `git grep -l` result.
|
|
384
|
+
*
|
|
385
|
+
* `git grep` exit codes: 0 = matches found, 1 = no match (the success case
|
|
386
|
+
* here), anything else = it did not run. Measured on git 2.x: a bad regex and
|
|
387
|
+
* a bad pathspec both exit 128; git also documents 2 for usage errors. The old
|
|
388
|
+
* inline code read `.stdout` without ever looking at `.status`, so BOTH the
|
|
389
|
+
* no-match case and the it-crashed case produced an empty hit list and the
|
|
390
|
+
* same reassuring detail line, "no tracked file still carries X". A sweep that
|
|
391
|
+
* never ran is not a clean sweep.
|
|
392
|
+
*
|
|
393
|
+
* @param {{status: number, stdout?: string, stderr?: string}} grep
|
|
394
|
+
* @param {string} prevTag — the previous release literal being swept for
|
|
395
|
+
* @param {RegExp} allowlist — files that legitimately carry version HISTORY
|
|
396
|
+
* @returns {{ok: boolean, detail: string}}
|
|
397
|
+
*/
|
|
398
|
+
export function evaluateDriftSweep(grep, prevTag, allowlist) {
|
|
399
|
+
if (grep.status !== 0 && grep.status !== 1) {
|
|
400
|
+
return {
|
|
401
|
+
ok: false,
|
|
402
|
+
detail: `git grep did not run (exit ${grep.status}): ${(grep.stderr || '').trim().slice(0, 200)} — sweep for ${prevTag} is inconclusive`,
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
const hits = (grep.stdout || '')
|
|
406
|
+
.split('\n')
|
|
407
|
+
.filter(Boolean)
|
|
408
|
+
.filter((f) => !allowlist.test(f));
|
|
409
|
+
return {
|
|
410
|
+
ok: hits.length === 0,
|
|
411
|
+
detail: hits.length
|
|
412
|
+
? `still carry ${prevTag}: ${hits.slice(0, 5).join(', ')}`
|
|
413
|
+
: `no tracked file outside the allowlist still carries ${prevTag}`,
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Registry-collision verdict over `npm view <pkg> versions --json`.
|
|
419
|
+
*
|
|
420
|
+
* The most dangerous of the three fail-opens this file carried: on `status 0`
|
|
421
|
+
* with unparseable stdout, the old code swallowed the parse error, left the
|
|
422
|
+
* version list EMPTY, and concluded from that emptiness that the target was
|
|
423
|
+
* free — reporting `latest: ?` while claiming the collision check had passed.
|
|
424
|
+
* Reproduced verbatim: a `<html>` body (proxy/captive-portal response) with
|
|
425
|
+
* exit 0 yields `ok = true`. Any npm output-format change lands in the same
|
|
426
|
+
* hole. An empty ARRAY is treated identically: a published package always has
|
|
427
|
+
* at least one version, so an empty list is a shape we do not understand, not
|
|
428
|
+
* an all-clear.
|
|
429
|
+
*
|
|
430
|
+
* @param {{status: number, stdout?: string, stderr?: string}} view
|
|
431
|
+
* @param {string} target
|
|
432
|
+
* @returns {{ok: boolean, detail: string}}
|
|
433
|
+
*/
|
|
434
|
+
export function evaluateRegistryCollision(view, target) {
|
|
435
|
+
if (view.status !== 0) {
|
|
436
|
+
const e404 = /E404/.test(view.stderr || '');
|
|
437
|
+
return e404
|
|
438
|
+
? { ok: true, detail: 'package not yet on registry (first publish)' }
|
|
439
|
+
: { ok: false, detail: `npm view failed (exit ${view.status}): ${(view.stderr || '').slice(0, 200)}` };
|
|
440
|
+
}
|
|
441
|
+
const raw = view.stdout || '';
|
|
442
|
+
let parsed;
|
|
443
|
+
try {
|
|
444
|
+
parsed = JSON.parse(raw);
|
|
445
|
+
} catch {
|
|
446
|
+
return {
|
|
447
|
+
ok: false,
|
|
448
|
+
detail: `npm view returned unparseable JSON (${raw.length} bytes, starts "${raw.trim().slice(0, 40)}") — cannot rule out a collision on ${target}`,
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
const published = Array.isArray(parsed) ? parsed : [parsed];
|
|
452
|
+
if (published.length === 0) {
|
|
453
|
+
return { ok: false, detail: `npm view returned an empty version list — cannot rule out a collision on ${target}` };
|
|
454
|
+
}
|
|
455
|
+
return published.includes(target)
|
|
456
|
+
? { ok: false, detail: `${target} already published` }
|
|
457
|
+
: { ok: true, detail: `latest: ${published[published.length - 1]}` };
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* Leakage-gate verdict over an `npm pack --dry-run` result.
|
|
462
|
+
*
|
|
463
|
+
* The SURFACES table has `pattern-dead` for exactly this class — a matcher that
|
|
464
|
+
* stops matching its input must be a hard error, never a silent pass — and the
|
|
465
|
+
* leak scan had no equivalent: an `npm pack` that exits 0 with output the scan
|
|
466
|
+
* cannot parse yields zero scanned lines, zero violations, and the verdict
|
|
467
|
+
* "0 packed entries, 0 leaks". Reproduced verbatim with empty stdout+stderr.
|
|
468
|
+
* {@link MIN_PACKED_ENTRIES} is that missing `pattern-dead`.
|
|
469
|
+
*
|
|
470
|
+
* The floor is asserted on the SAME lines `checkLeakage` scans, not on npm's
|
|
471
|
+
* `total files:` summary line. That is the point: the summary could survive a
|
|
472
|
+
* format change that broke the per-entry lines, and it is the per-entry lines
|
|
473
|
+
* whose absence blinds the scan.
|
|
474
|
+
*
|
|
475
|
+
* @param {{status: number, stdout?: string, stderr?: string}} pack
|
|
476
|
+
* @param {{minEntries?: number}} [opts]
|
|
477
|
+
* @returns {{ok: boolean, detail: string}}
|
|
478
|
+
*/
|
|
479
|
+
export function evaluateLeakageGate(pack, { minEntries = MIN_PACKED_ENTRIES } = {}) {
|
|
480
|
+
if (pack.status !== 0) {
|
|
481
|
+
return { ok: false, detail: `npm pack failed (exit ${pack.status}): ${(pack.stderr || '').trim().slice(-200)}` };
|
|
482
|
+
}
|
|
483
|
+
const lines = `${pack.stdout || ''}\n${pack.stderr || ''}`.split('\n');
|
|
484
|
+
const entries = lines.filter((l) => PACKED_ENTRY_RE.test(l)).length;
|
|
485
|
+
if (entries < minEntries) {
|
|
486
|
+
return {
|
|
487
|
+
ok: false,
|
|
488
|
+
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`,
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
const violations = checkLeakage(lines);
|
|
492
|
+
return violations.length
|
|
493
|
+
? { ok: false, detail: violations.map((v) => `${v.name}: ${v.line}`).slice(0, 5).join(' | ') }
|
|
494
|
+
: { ok: true, detail: `${entries} packed entries, 0 leaks` };
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* Remote-branch parity verdict over `git ls-remote <remote> refs/heads/<branch>`.
|
|
499
|
+
*
|
|
500
|
+
* Preflight compared HEAD against `origin/main` only. The Vercel deploy hangs
|
|
501
|
+
* off the GITHUB mirror, so a mirror that lags is invisible until
|
|
502
|
+
* `verifyLiveSite` fails — which happens AFTER npm publish and AFTER both tag
|
|
503
|
+
* pushes, i.e. after the two irreversible steps. Same fail-closed shape as
|
|
504
|
+
* `tag-free-github`: a failed `ls-remote` is a failed check, and so is output
|
|
505
|
+
* that carries no sha (an empty answer for `refs/heads/main` means the branch
|
|
506
|
+
* is not there at all, which is not parity either).
|
|
507
|
+
*
|
|
508
|
+
* @param {string} remote
|
|
509
|
+
* @param {{status: number, stdout?: string, stderr?: string}} ls
|
|
510
|
+
* @param {string} head — the local HEAD sha
|
|
511
|
+
* @param {string} [branch]
|
|
512
|
+
* @returns {{ok: boolean, detail: string}}
|
|
513
|
+
*/
|
|
514
|
+
export function evaluateRemoteHeadParity(remote, ls, head, branch = 'main') {
|
|
515
|
+
if (ls.status !== 0) {
|
|
516
|
+
return { ok: false, detail: `ls-remote ${remote} failed (exit ${ls.status}): ${(ls.stderr || '').trim().slice(0, 200)}` };
|
|
517
|
+
}
|
|
518
|
+
const sha = (ls.stdout || '').trim().split(/\s+/)[0] || '';
|
|
519
|
+
if (!/^[0-9a-f]{40}$/i.test(sha)) {
|
|
520
|
+
return { ok: false, detail: `ls-remote ${remote} returned no sha for refs/heads/${branch} — cannot compare` };
|
|
521
|
+
}
|
|
522
|
+
return sha === head
|
|
523
|
+
? { ok: true, detail: sha.slice(0, 8) }
|
|
524
|
+
: { ok: false, detail: `${remote}/${branch} at ${sha.slice(0, 8)}, HEAD at ${head.slice(0, 8)} — the mirror is behind` };
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* npm-auth verdict over `npm whoami --userconfig <tmp>`.
|
|
529
|
+
*
|
|
530
|
+
* A dead or revoked token used to surface only inside `publish()`, i.e. after
|
|
531
|
+
* every other preflight check had passed and the operator had committed to the
|
|
532
|
+
* release. The probe is read-only and costs one request. Fail-closed on the
|
|
533
|
+
* empty-identity case too: `whoami` exiting 0 while printing nothing is not
|
|
534
|
+
* proof of an identity.
|
|
535
|
+
*
|
|
536
|
+
* @param {{status: number, stdout?: string, stderr?: string}|null} whoami
|
|
537
|
+
* @returns {{ok: boolean, detail: string}}
|
|
538
|
+
*/
|
|
539
|
+
export function evaluateNpmAuth(whoami) {
|
|
540
|
+
if (!whoami) return { ok: false, detail: 'npm whoami was not run' };
|
|
541
|
+
if (whoami.status !== 0) {
|
|
542
|
+
return { ok: false, detail: `npm whoami exited ${whoami.status}: ${(whoami.stderr || '').trim().slice(0, 200)}` };
|
|
543
|
+
}
|
|
544
|
+
const who = (whoami.stdout || '').trim();
|
|
545
|
+
return who
|
|
546
|
+
? { ok: true, detail: `authenticated as ${who}` }
|
|
547
|
+
: { ok: false, detail: 'npm whoami exited 0 with an empty identity — the token could not be confirmed' };
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* Flag-combination gate, applied before any work.
|
|
552
|
+
*
|
|
553
|
+
* `--skip-ci` turns the CI check into `ok:true` with the detail
|
|
554
|
+
* "SKIPPED via --skip-ci". That is a legitimate affordance for `--check` (an
|
|
555
|
+
* operator inspecting surface parity while a pipeline is still running) and an
|
|
556
|
+
* illegitimate one for `--publish`: it would let a green summary that verified
|
|
557
|
+
* nothing about CI authorise npm publish + two tag pushes, none of which can be
|
|
558
|
+
* taken back. The refusal is a usage error (exit 2), not a check failure —
|
|
559
|
+
* nothing was checked.
|
|
560
|
+
*
|
|
561
|
+
* @param {{publish?: boolean, 'skip-ci'?: boolean}} values
|
|
562
|
+
* @returns {{ok: boolean, code?: number, message?: string}}
|
|
563
|
+
*/
|
|
564
|
+
export function validateFlags(values) {
|
|
565
|
+
if (values.publish && values['skip-ci']) {
|
|
566
|
+
return {
|
|
567
|
+
ok: false,
|
|
568
|
+
code: 2,
|
|
569
|
+
message:
|
|
570
|
+
'--skip-ci is refused under --publish: it makes ci-green-on-head pass without checking anything, and publish is irreversible.\n' +
|
|
571
|
+
'Run `--check --skip-ci` to inspect the other surfaces, then `--publish` once CI is actually green on HEAD.',
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
return { ok: true };
|
|
575
|
+
}
|
|
576
|
+
|
|
262
577
|
// ---------------------------------------------------------------------------
|
|
263
|
-
// Impure orchestration below — git/npm/network.
|
|
264
|
-
//
|
|
578
|
+
// Impure orchestration below — git/npm/network. The DECISIONS live in the
|
|
579
|
+
// evaluators above and are unit-tested; what remains here is the plumbing that
|
|
580
|
+
// feeds them.
|
|
265
581
|
// ---------------------------------------------------------------------------
|
|
266
582
|
|
|
267
583
|
function run(cmd, args, opts = {}) {
|
|
@@ -286,15 +602,42 @@ async function preflight(repoRoot, target, { skipCi = false } = {}) {
|
|
|
286
602
|
const checks = [];
|
|
287
603
|
const add = (name, ok, detail = '') => checks.push({ name, ok, detail });
|
|
288
604
|
|
|
289
|
-
// 1. Git state: on main, clean tree, HEAD
|
|
605
|
+
// 1. Git state: on main, clean tree, HEAD present on BOTH publish remotes.
|
|
606
|
+
//
|
|
607
|
+
// Both remotes, symmetrically, and both read LIVE via ls-remote rather than
|
|
608
|
+
// from a local tracking ref. origin (GitLab) is where the code lives; github
|
|
609
|
+
// is where the Vercel git integration watches, so a lagging mirror means the
|
|
610
|
+
// site cannot deploy — and that was previously discovered only by
|
|
611
|
+
// verifyLiveSite, i.e. after npm publish and both tag pushes had already
|
|
612
|
+
// happened. The old origin check read `origin/main` after a `git fetch` whose
|
|
613
|
+
// exit status nobody inspected: a failed fetch left a stale tracking ref that
|
|
614
|
+
// could still equal HEAD, so the comparison was against remembered state
|
|
615
|
+
// rather than remote state. ls-remote has no such intermediate.
|
|
290
616
|
const branch = run('git', ['branch', '--show-current'], { cwd: repoRoot }).stdout.trim();
|
|
291
617
|
add('branch-is-main', branch === 'main', branch);
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
618
|
+
// `git status` exit status is read, not assumed: an empty stdout from a
|
|
619
|
+
// FAILED status call is indistinguishable from a genuinely clean tree, and
|
|
620
|
+
// the empty-reads-as-all-clear shape is exactly the fail-open this file was
|
|
621
|
+
// hardened against elsewhere. Same reasoning for the two `git tag -l` reads
|
|
622
|
+
// below — those are the only other preflight subprocesses whose emptiness
|
|
623
|
+
// means "all clear" (census: all 14 `run(` call sites in this file).
|
|
624
|
+
const status = run('git', ['status', '--porcelain'], { cwd: repoRoot });
|
|
625
|
+
const dirty = (status.stdout || '').trim();
|
|
626
|
+
add(
|
|
627
|
+
'working-tree-clean',
|
|
628
|
+
status.status === 0 && dirty === '',
|
|
629
|
+
status.status !== 0
|
|
630
|
+
? `git status failed (exit ${status.status}) — cleanliness unknown`
|
|
631
|
+
: dirty
|
|
632
|
+
? `${dirty.split('\n').length} dirty path(s)`
|
|
633
|
+
: '',
|
|
634
|
+
);
|
|
295
635
|
const head = run('git', ['rev-parse', 'HEAD'], { cwd: repoRoot }).stdout.trim();
|
|
296
|
-
const
|
|
297
|
-
|
|
636
|
+
for (const remote of ['origin', 'github']) {
|
|
637
|
+
const ls = run('git', ['ls-remote', remote, 'refs/heads/main'], { cwd: repoRoot });
|
|
638
|
+
const parity = evaluateRemoteHeadParity(remote, ls, head);
|
|
639
|
+
add(`head-pushed-${remote}`, parity.ok, parity.detail);
|
|
640
|
+
}
|
|
298
641
|
|
|
299
642
|
// 2. Surface parity.
|
|
300
643
|
const surfaceRows = scanSurfaces(repoRoot, target);
|
|
@@ -312,22 +655,33 @@ async function preflight(repoRoot, target, { skipCi = false } = {}) {
|
|
|
312
655
|
// directories, which is exactly how the forgotten .codex-plugin manifest
|
|
313
656
|
// was invisible to a plain rg census. Allowlisted: files that legitimately
|
|
314
657
|
// carry version HISTORY.
|
|
315
|
-
const
|
|
316
|
-
|
|
658
|
+
const tagList = run('git', ['tag', '-l', 'v*', '--sort=-v:refname'], { cwd: repoRoot });
|
|
659
|
+
const prevTag = (tagList.stdout || '')
|
|
660
|
+
.split('\n').map((t) => t.trim().replace(/^v/, ''))
|
|
317
661
|
.filter((t) => /^\d+\.\d+\.\d+$/.test(t) && t !== target)[0];
|
|
318
|
-
if (
|
|
319
|
-
|
|
662
|
+
if (tagList.status !== 0) {
|
|
663
|
+
// "No previous tag" and "could not list tags" are different facts, and only
|
|
664
|
+
// one of them means the sweep is unnecessary.
|
|
665
|
+
add('drift-sweep', false, `git tag -l failed (exit ${tagList.status}) — cannot determine the previous release to sweep for`);
|
|
666
|
+
} else if (prevTag) {
|
|
320
667
|
const grep = run('git', ['grep', '-l', '--fixed-strings', prevTag, '--', '.'], { cwd: repoRoot });
|
|
321
|
-
const
|
|
322
|
-
add('drift-sweep',
|
|
668
|
+
const sweep = evaluateDriftSweep(grep, prevTag, HISTORY_ALLOWLIST);
|
|
669
|
+
add('drift-sweep', sweep.ok, sweep.detail);
|
|
323
670
|
} else {
|
|
324
671
|
add('drift-sweep', true, 'no previous tag to sweep against');
|
|
325
672
|
}
|
|
326
673
|
|
|
327
674
|
// 4. Tag collision — local, origin, github mirror.
|
|
328
675
|
const tag = `v${target}`;
|
|
329
|
-
const
|
|
330
|
-
|
|
676
|
+
const localTagRes = run('git', ['tag', '-l', tag], { cwd: repoRoot });
|
|
677
|
+
const localTag = (localTagRes.stdout || '').trim();
|
|
678
|
+
add(
|
|
679
|
+
'tag-free-local',
|
|
680
|
+
localTagRes.status === 0 && localTag === '',
|
|
681
|
+
localTagRes.status !== 0
|
|
682
|
+
? `git tag -l failed (exit ${localTagRes.status}) — local tag collision unknown`
|
|
683
|
+
: localTag && `${tag} already exists locally`,
|
|
684
|
+
);
|
|
331
685
|
for (const remote of ['origin', 'github']) {
|
|
332
686
|
const ls = run('git', ['ls-remote', '--tags', remote, `refs/tags/${tag}`], { cwd: repoRoot });
|
|
333
687
|
const collision = ls.status === 0 && ls.stdout.trim() !== '';
|
|
@@ -336,21 +690,32 @@ async function preflight(repoRoot, target, { skipCi = false } = {}) {
|
|
|
336
690
|
|
|
337
691
|
// 5. npm registry collision (E404 = name free = fine for a first publish).
|
|
338
692
|
const view = run('npm', ['view', PACKAGE_NAME, 'versions', '--json'], { cwd: repoRoot });
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
693
|
+
const registry = evaluateRegistryCollision(view, target);
|
|
694
|
+
add('registry-version-free', registry.ok, registry.detail);
|
|
695
|
+
|
|
696
|
+
// 5b. npm token liveness. Read-only, one request, and it answers the one
|
|
697
|
+
// question the rest of the preflight cannot: is the credential we are about
|
|
698
|
+
// to publish with actually alive? Without it, a revoked or expired token
|
|
699
|
+
// surfaces inside publish() — after every other check has gone green and the
|
|
700
|
+
// operator has committed to the release. Same token discipline as publish():
|
|
701
|
+
// .env.local only, temp userconfig at 0600, removed in a finally.
|
|
702
|
+
let auth;
|
|
703
|
+
try {
|
|
704
|
+
auth = withTempUserconfig(loadNpmToken(repoRoot), (rc) =>
|
|
705
|
+
run('npm', ['whoami', '--userconfig', rc], { cwd: repoRoot }),
|
|
706
|
+
);
|
|
707
|
+
const verdict = evaluateNpmAuth(auth);
|
|
708
|
+
add('npm-token-live', verdict.ok, verdict.detail);
|
|
709
|
+
} catch (err) {
|
|
710
|
+
// A missing/ungitignored .env.local is a legitimate red preflight, not a
|
|
711
|
+
// crash: "cannot publish from here" is exactly what the operator needs.
|
|
712
|
+
add('npm-token-live', false, err.message);
|
|
350
713
|
}
|
|
351
714
|
|
|
352
715
|
// 6. CI green on HEAD (the repo's iron session-start rule applies to
|
|
353
716
|
// releases doubly: local green is not evidence — see .claude/rules).
|
|
717
|
+
// --skip-ci is refused under --publish upstream in validateFlags(); it can
|
|
718
|
+
// only reach this branch from --check.
|
|
354
719
|
if (skipCi) {
|
|
355
720
|
add('ci-green-on-head', true, 'SKIPPED via --skip-ci');
|
|
356
721
|
} else {
|
|
@@ -362,9 +727,8 @@ async function preflight(repoRoot, target, { skipCi = false } = {}) {
|
|
|
362
727
|
|
|
363
728
|
// 7. Leakage gate over the actual pack file list.
|
|
364
729
|
const pack = run('npm', ['pack', '--dry-run'], { cwd: repoRoot });
|
|
365
|
-
const
|
|
366
|
-
|
|
367
|
-
add('leakage-gate', pack.status === 0 && violations.length === 0, violations.length ? violations.map((v) => `${v.name}: ${v.line}`).slice(0, 5).join(' | ') : pack.status !== 0 ? 'npm pack failed' : `${lines.filter((l) => /npm notice.*[0-9]+B /.test(l)).length} packed entries, 0 leaks`);
|
|
730
|
+
const leakage = evaluateLeakageGate(pack);
|
|
731
|
+
add('leakage-gate', leakage.ok, leakage.detail);
|
|
368
732
|
|
|
369
733
|
return checks;
|
|
370
734
|
}
|
|
@@ -376,30 +740,51 @@ function changelogExcerpt(repoRoot, target) {
|
|
|
376
740
|
return m ? m[1].trim().split('\n').slice(0, 40).join('\n') : '';
|
|
377
741
|
}
|
|
378
742
|
|
|
379
|
-
|
|
380
|
-
|
|
743
|
+
/**
|
|
744
|
+
* Read NPM_TOKEN from the gitignored .env.local, refusing if the ignore is not
|
|
745
|
+
* actually in force. Throws with an operator-actionable message; the token
|
|
746
|
+
* itself is never part of any message.
|
|
747
|
+
*/
|
|
748
|
+
function loadNpmToken(repoRoot) {
|
|
381
749
|
const ignored = run('git', ['check-ignore', '.env.local'], { cwd: repoRoot });
|
|
382
750
|
if (ignored.status !== 0) throw new Error('.env.local is NOT gitignored — refusing to read a token from it');
|
|
383
|
-
|
|
384
|
-
const tokenMatch =
|
|
751
|
+
if (!existsSync(join(repoRoot, '.env.local'))) throw new Error('.env.local not found — no NPM_TOKEN to publish with');
|
|
752
|
+
const tokenMatch = readFileSync(join(repoRoot, '.env.local'), 'utf8').match(/^NPM_TOKEN=(.+)$/m);
|
|
385
753
|
if (!tokenMatch) throw new Error('NPM_TOKEN not found in .env.local');
|
|
386
|
-
|
|
754
|
+
return tokenMatch[1].trim();
|
|
755
|
+
}
|
|
387
756
|
|
|
757
|
+
/**
|
|
758
|
+
* Run `fn(userconfigPath)` against a throwaway npm userconfig carrying the
|
|
759
|
+
* token. Extracted so the preflight liveness probe and the publish itself share
|
|
760
|
+
* ONE implementation of the security invariants from
|
|
761
|
+
* skills/npm-publish/SKILL.md — 0600, and removed in a finally even when the
|
|
762
|
+
* callback throws. Two hand-copied versions of this dance would be two places
|
|
763
|
+
* for a token file to be left behind.
|
|
764
|
+
*/
|
|
765
|
+
function withTempUserconfig(token, fn) {
|
|
388
766
|
const tmpDir = mkdtempSync(join(tmpdir(), 'release-npmrc-'));
|
|
389
767
|
const tmpRc = join(tmpDir, 'npmrc');
|
|
390
768
|
try {
|
|
391
|
-
writeFileSync(tmpRc, `//registry.npmjs.org/:_authToken=${token}\n
|
|
769
|
+
writeFileSync(tmpRc, `//registry.npmjs.org/:_authToken=${token}\n`, { mode: 0o600 });
|
|
392
770
|
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
|
-
}
|
|
771
|
+
return fn(tmpRc);
|
|
400
772
|
} finally {
|
|
401
773
|
rmSync(tmpDir, { recursive: true, force: true });
|
|
402
774
|
}
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
function publish(repoRoot, target) {
|
|
778
|
+
const token = loadNpmToken(repoRoot);
|
|
779
|
+
const res = withTempUserconfig(token, (tmpRc) =>
|
|
780
|
+
run('npm', ['publish', '--access', 'public', '--userconfig', tmpRc], { cwd: repoRoot }),
|
|
781
|
+
);
|
|
782
|
+
const out = `${res.stdout}\n${res.stderr}`;
|
|
783
|
+
if (res.status !== 0 || !out.includes(`+ ${PACKAGE_NAME}@${target}`)) {
|
|
784
|
+
// Never echo the raw output wholesale into logs beyond the error slice —
|
|
785
|
+
// it cannot contain the token (npm masks userconfig), but stay frugal.
|
|
786
|
+
throw new Error(`npm publish failed (exit ${res.status}): ${out.slice(0, 800)}`);
|
|
787
|
+
}
|
|
403
788
|
|
|
404
789
|
// Registry verify with propagation retries.
|
|
405
790
|
for (let attempt = 1; attempt <= 5; attempt++) {
|
|
@@ -430,6 +815,124 @@ function tagAndPush(repoRoot, target) {
|
|
|
430
815
|
return { tag, pushed };
|
|
431
816
|
}
|
|
432
817
|
|
|
818
|
+
/**
|
|
819
|
+
* Create the GitHub release for `v<target>`, or confirm the existing one.
|
|
820
|
+
*
|
|
821
|
+
* WHY THIS IS CODE AND NOT A CHECKLIST LINE: it was a checklist line, and the
|
|
822
|
+
* evidence that a checklist line is not a mechanism is in the release history.
|
|
823
|
+
* The GitHub releases for v3.15, v3.18, v3.19 and v3.20 were all created within
|
|
824
|
+
* a THREE-SECOND window on 2026-08-19 — hand-backfilled in one sitting, 5 to 31
|
|
825
|
+
* days after their tags, where the releases that were not forgotten were made 19
|
|
826
|
+
* seconds to 2.5 minutes after theirs. The same class of gap left 3.18.0 with a
|
|
827
|
+
* tag, a GitHub release and a CHANGELOG entry that the npm registry has still
|
|
828
|
+
* never seen.
|
|
829
|
+
*
|
|
830
|
+
* Three properties make this safe to run unconditionally after a push:
|
|
831
|
+
* - `--verify-tag` makes gh refuse when the tag is not on the remote, so
|
|
832
|
+
* "release without a tag" is structurally impossible rather than merely
|
|
833
|
+
* discouraged.
|
|
834
|
+
* - The `gh release view` probe first makes a re-run a no-op instead of an
|
|
835
|
+
* error, so a second `--publish` pass after a partial failure is not blocked
|
|
836
|
+
* by the step that already succeeded.
|
|
837
|
+
* - The `-R` spec comes from `resolveRepoSpec({vcs:'github'})` (#1039), not a
|
|
838
|
+
* hardcoded owner/repo, so a fork or a renamed remote targets its own repo.
|
|
839
|
+
*
|
|
840
|
+
* Never throws: the caller has already published to npm and pushed both tags by
|
|
841
|
+
* the time this runs, so an exception here would report a successful release as
|
|
842
|
+
* a crash. Failure comes back as `{ok:false}` with the recovery command.
|
|
843
|
+
*
|
|
844
|
+
* @param {string} repoRoot
|
|
845
|
+
* @param {string} target
|
|
846
|
+
* @param {{runImpl?: Function, repoSpec?: string}} [deps] — injection seam for tests
|
|
847
|
+
* @returns {{ok: boolean, created: boolean, tag: string, detail: string, argv?: string[]}}
|
|
848
|
+
*/
|
|
849
|
+
export function ensureGithubRelease(repoRoot, target, deps = {}) {
|
|
850
|
+
const runImpl = deps.runImpl ?? run;
|
|
851
|
+
const tag = `v${target}`;
|
|
852
|
+
const spec = deps.repoSpec ?? resolveRepoSpec({ repoRoot, vcs: 'github' });
|
|
853
|
+
// resolveRepoSpec returns undefined when it cannot auto-detect; its contract
|
|
854
|
+
// is that callers OMIT the flag rather than pass `-R undefined`.
|
|
855
|
+
const repoFlag = spec ? ['--repo', spec] : [];
|
|
856
|
+
|
|
857
|
+
try {
|
|
858
|
+
const existing = runImpl('gh', ['release', 'view', tag, ...repoFlag], { cwd: repoRoot });
|
|
859
|
+
if (existing.status === 0) {
|
|
860
|
+
return { ok: true, created: false, tag, detail: `GitHub release ${tag} already exists — no-op` };
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
const notesDir = mkdtempSync(join(tmpdir(), 'release-ghnotes-'));
|
|
864
|
+
const notesFile = join(notesDir, 'notes.md');
|
|
865
|
+
let argv;
|
|
866
|
+
try {
|
|
867
|
+
writeFileSync(notesFile, `${changelogExcerpt(repoRoot, target)}\n`);
|
|
868
|
+
argv = ['release', 'create', tag, ...repoFlag, '--verify-tag', '--title', tag, '--notes-file', notesFile];
|
|
869
|
+
const created = runImpl('gh', argv, { cwd: repoRoot });
|
|
870
|
+
if (created.status !== 0) {
|
|
871
|
+
return {
|
|
872
|
+
ok: false,
|
|
873
|
+
created: false,
|
|
874
|
+
tag,
|
|
875
|
+
argv,
|
|
876
|
+
detail: `gh release create exited ${created.status}: ${(created.stderr || created.stdout || '').trim().slice(0, 300)}`,
|
|
877
|
+
};
|
|
878
|
+
}
|
|
879
|
+
return { ok: true, created: true, tag, argv, detail: `GitHub release ${tag} created (--verify-tag)` };
|
|
880
|
+
} finally {
|
|
881
|
+
rmSync(notesDir, { recursive: true, force: true });
|
|
882
|
+
}
|
|
883
|
+
} catch (err) {
|
|
884
|
+
return { ok: false, created: false, tag, detail: `gh could not be run: ${err.message}` };
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
/**
|
|
889
|
+
* Poll the live site until it serves `expected`, or give up.
|
|
890
|
+
*
|
|
891
|
+
* WHY POLLING: the Vercel git integration builds asynchronously after the push
|
|
892
|
+
* to `github`, so a single immediate check would report a false negative on
|
|
893
|
+
* every release. WHY AT ALL: the live site silently fell a release behind twice
|
|
894
|
+
* in four weeks (#1043) — a deploy that reports success at the push and is
|
|
895
|
+
* never re-read afterwards cannot tell "deployed" from "did not deploy".
|
|
896
|
+
*
|
|
897
|
+
* Fail-closed by design: a network error, a non-200, an unparseable body and a
|
|
898
|
+
* genuine version mismatch are four DISTINCT reported outcomes, never collapsed
|
|
899
|
+
* onto one "not ok" — collapsing them is the defect class this replaces.
|
|
900
|
+
*
|
|
901
|
+
* @param {string} expected — the version literal the site must serve
|
|
902
|
+
* @param {{url?: string, attempts?: number, delayMs?: number, fetchImpl?: Function}} [opts]
|
|
903
|
+
* @returns {Promise<{ok: boolean, detail: string}>}
|
|
904
|
+
*/
|
|
905
|
+
export async function verifyLiveSite(expected, opts = {}) {
|
|
906
|
+
const url = opts.url ?? 'https://session-orchestrator.com/llms.txt';
|
|
907
|
+
const attempts = opts.attempts ?? 12;
|
|
908
|
+
const delayMs = opts.delayMs ?? 10_000;
|
|
909
|
+
const doFetch = opts.fetchImpl ?? globalThis.fetch;
|
|
910
|
+
let last = 'no attempt made';
|
|
911
|
+
|
|
912
|
+
for (let i = 1; i <= attempts; i++) {
|
|
913
|
+
try {
|
|
914
|
+
const res = await doFetch(url, { headers: { 'Cache-Control': 'no-cache' } });
|
|
915
|
+
if (!res.ok) {
|
|
916
|
+
last = `HTTP ${res.status} from ${url}`;
|
|
917
|
+
} else {
|
|
918
|
+
const body = await res.text();
|
|
919
|
+
const m = body.match(/^Version:\s*([0-9]+\.[0-9]+\.[0-9]+)/m);
|
|
920
|
+
if (!m) {
|
|
921
|
+
last = `no "Version: X.Y.Z" line in ${url} (${body.length} bytes) — the surface moved, fix the check`;
|
|
922
|
+
} else if (m[1] === expected) {
|
|
923
|
+
return { ok: true, detail: `attempt ${i}/${attempts}, ${url}` };
|
|
924
|
+
} else {
|
|
925
|
+
last = `live serves ${m[1]}, expected ${expected}`;
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
} catch (err) {
|
|
929
|
+
last = `fetch failed: ${err.message}`;
|
|
930
|
+
}
|
|
931
|
+
if (i < attempts) await new Promise((r) => setTimeout(r, delayMs));
|
|
932
|
+
}
|
|
933
|
+
return { ok: false, detail: `${last} (gave up after ${attempts} attempts)` };
|
|
934
|
+
}
|
|
935
|
+
|
|
433
936
|
function printChecks(checks, asJson, version) {
|
|
434
937
|
const ok = checks.every((c) => c.ok);
|
|
435
938
|
if (asJson) {
|
|
@@ -459,9 +962,16 @@ async function main() {
|
|
|
459
962
|
if (values.help) {
|
|
460
963
|
console.log('Usage: node scripts/release.mjs [--set-version X.Y.Z | --check | --publish] [--skip-ci] [--json]');
|
|
461
964
|
console.log('Release als ein Dispatch: surface sync, preflight checks, token publish, tag AFTER publish.');
|
|
965
|
+
console.log(' --skip-ci allowed with --check only; REFUSED with --publish (it verifies nothing).');
|
|
462
966
|
console.log('Exit codes: 0 success, 1 check failure, 2 system/usage error.');
|
|
463
967
|
return 0;
|
|
464
968
|
}
|
|
969
|
+
|
|
970
|
+
const flags = validateFlags(values);
|
|
971
|
+
if (!flags.ok) {
|
|
972
|
+
console.error(flags.message);
|
|
973
|
+
return flags.code;
|
|
974
|
+
}
|
|
465
975
|
if (values.version) {
|
|
466
976
|
console.log(readPackageVersion(repoRootOf()));
|
|
467
977
|
return 0;
|
|
@@ -477,9 +987,20 @@ async function main() {
|
|
|
477
987
|
}
|
|
478
988
|
const changed = applyVersion(repoRoot, target);
|
|
479
989
|
mustRun('npm', ['install', '--package-lock-only', '--ignore-scripts', '--no-audit', '--no-fund'], { cwd: repoRoot });
|
|
990
|
+
|
|
991
|
+
// Re-stamp the site's measured census (#1043, second drift level). The
|
|
992
|
+
// version literals above are only half the problem: the "Measured in this
|
|
993
|
+
// repository" block was typed once on 2026-08-03 and 5 of its 8 figures
|
|
994
|
+
// were wrong twelve days later. Release time is the RIGHT moment and CI is
|
|
995
|
+
// the wrong one — `sessions` and `learnings` grow on every session, so a
|
|
996
|
+
// pipeline gate on them would be permanently red. The page discloses that
|
|
997
|
+
// by stamping the date and SHA it was counted at, which this refreshes too.
|
|
998
|
+
mustRun('node', ['scripts/site-numbers.mjs', '--write'], { cwd: repoRoot });
|
|
999
|
+
|
|
480
1000
|
console.log(`Rewrote ${changed.length} surface file(s) to ${target}:`);
|
|
481
1001
|
for (const f of changed) console.log(` ${f}`);
|
|
482
1002
|
console.log(' package-lock.json (via npm install --package-lock-only)');
|
|
1003
|
+
console.log(' site/index.html cells + site/_census.json re-stamped (scripts/site-numbers.mjs --write) — commit BOTH');
|
|
483
1004
|
console.log('\nEditorial TODOs (enforced by --check):');
|
|
484
1005
|
console.log(` 1. CHANGELOG.md — write the "## [${target}] - YYYY-MM-DD" entry, fold [Unreleased].`);
|
|
485
1006
|
console.log(' 2. README.md — rewrite the "Recent highlights" section content.');
|
|
@@ -498,10 +1019,44 @@ async function main() {
|
|
|
498
1019
|
console.log(` + ${PACKAGE_NAME}@${target} — registry verified.`);
|
|
499
1020
|
const { tag, pushed } = tagAndPush(repoRoot, target);
|
|
500
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
|
+
|
|
501
1057
|
console.log('\nPost-release checklist (manual):');
|
|
502
|
-
console.log(' 1.
|
|
503
|
-
console.log(' 2.
|
|
504
|
-
console.log(' 3. pi.dev gallery indexes asynchronously — do not block on it.');
|
|
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.');
|
|
505
1060
|
return 0;
|
|
506
1061
|
}
|
|
507
1062
|
|