@adia-ai/adia-ui-forge 0.8.58 → 0.8.60

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.
@@ -71,6 +71,11 @@ import process from 'node:process';
71
71
  import { assertMonorepoRoot } from './assert-monorepo-root.mjs';
72
72
  import { resolvePackageChangelog, PACKAGE_ROSTER } from './package-paths.mjs';
73
73
  import { GATE_ROSTER } from './gate-roster.mjs';
74
+ // gh#3361: Step 5's releaseFiles derives its PINNED_REFS-covered entries
75
+ // from bump.mjs's own tables (below, pinnedRefFiles()) instead of
76
+ // hand-duplicating them — bump.mjs guards its own main()/selftest() behind
77
+ // an entry-point check specifically so this import is side-effect-free.
78
+ import { PINNED_REFS, REPO_PINNED_REFS } from './bump.mjs';
74
79
 
75
80
  const SCRIPT_DIR = path.dirname(new URL(import.meta.url).pathname);
76
81
  const REPO = process.cwd();
@@ -88,6 +93,26 @@ const DEFAULT_NPM_SCOPE = '@adia-ai';
88
93
  const LOCKSTEP_ROSTER = PACKAGE_ROSTER.filter((p) => p.lockstep !== false);
89
94
  const PACKAGES = LOCKSTEP_ROSTER.map((p) => p.name);
90
95
 
96
+ // Reject a --substantive-packages/--stub-packages name whose roster entry is
97
+ // unknown or lockstep:false, BEFORE Step 1 runs (gh#2894). The 0.8.58 cut
98
+ // passed `adia-plugins` (lockstep:false, gh#1133) in --substantive-packages;
99
+ // promote-unreleased.mjs happily rewrote its CHANGELOG's [Unreleased] header
100
+ // to a version that package doesn't ship, and the cut only caught it at Step
101
+ // 5.6's unstaged-tracked-files guard — AFTER the 34-gate pre-flight had
102
+ // already run. Matches PACKAGE_ROSTER by name form (`adia-plugins`) or dir
103
+ // form minus the `packages/` prefix (`plugins/adia-plugins`).
104
+ function assertLockstepPackages(names) {
105
+ for (const pkg of names) {
106
+ const entry = PACKAGE_ROSTER.find(
107
+ (p) => p.name === pkg || p.dir.replace(/^packages\//, '') === pkg,
108
+ );
109
+ if (!entry || entry.lockstep === false) {
110
+ console.error(`error: --substantive-packages/--stub-packages package "${pkg}" is not lockstep:true in scripts/package-paths.mjs PACKAGE_ROSTER (unknown or class-B) — rejected before Step 1.`);
111
+ process.exit(2);
112
+ }
113
+ }
114
+ }
115
+
91
116
  function parseArgs(argv) {
92
117
  const args = {
93
118
  version: null, date: null, previous: null, mode: null,
@@ -98,6 +123,7 @@ function parseArgs(argv) {
98
123
  host: process.env.ADIA_DEPLOY_HOST || DEFAULT_DEPLOY_HOST,
99
124
  scope: process.env.ADIA_NPM_SCOPE || DEFAULT_NPM_SCOPE,
100
125
  dry: false, yes: false, push: false, publish: false, skipGates: false, skipSite: false,
126
+ fromStep10: false,
101
127
  };
102
128
  for (let i = 0; i < argv.length; i++) {
103
129
  const k = argv[i];
@@ -128,6 +154,15 @@ function parseArgs(argv) {
128
154
  else if (k === '--go') { args.yes = true; args.push = true; args.publish = true; }
129
155
  else if (k === '--skip-gates') args.skipGates = true;
130
156
  else if (k === '--skip-site') args.skipSite = true;
157
+ // gh#3342: resume a handoff that already tagged/pushed/published — a
158
+ // straight re-run of `--mode handoff` re-gates (Step 3 never runs in
159
+ // handoff mode, but Step 6 would re-tag at HEAD, which may no longer be
160
+ // the tagged commit if anything merged since) and Step 9 would
161
+ // re-dispatch publishes that already succeeded (idempotent, but a
162
+ // needless wait). `--from-step10` skips straight to Step 10 (GH
163
+ // releases + site deploy) — the exact "resume from Step 10 only" shape
164
+ // the v0.8.59 cut needed by hand (a one-off script, never checked in).
165
+ else if (k === '--from-step10') args.fromStep10 = true;
131
166
  else if (k === '-h' || k === '--help') { help(); process.exit(0); }
132
167
  }
133
168
  if (!args.version || !args.date || !args.previous || !args.mode) {
@@ -148,6 +183,12 @@ function parseArgs(argv) {
148
183
  console.error(`error: --mode must be cut|from-scratch|handoff (got: ${args.mode})`);
149
184
  process.exit(2);
150
185
  }
186
+ // Roster validation (gh#2894), before any mode-specific check below —
187
+ // applies to BOTH cut and from-scratch (the script's own --help text shows
188
+ // both flags on a `cut` invocation).
189
+ if (args.substantivePackages || args.stubPackages) {
190
+ assertLockstepPackages([...(args.substantivePackages ?? []), ...(args.stubPackages ?? [])]);
191
+ }
151
192
  // Handoff needs the GH-notes body at Step 10 — validate at PARSE time, not
152
193
  // there: on the v0.8.10 cut the missing flag surfaced only after tags and
153
194
  // npm publish were already irreversible (Step 10 is the LAST step). An arg
@@ -209,6 +250,10 @@ function parseArgs(argv) {
209
250
  console.error(`error: GH notes file not found or not a regular file: ${args.ghNotesFile}`);
210
251
  process.exit(2);
211
252
  }
253
+ } else if (args.fromStep10) {
254
+ console.error('error: --from-step10 only applies to --mode handoff (it resumes handoff\'s own');
255
+ console.error(' tag→publish→deploy sequence at its last step) — got --mode ' + args.mode);
256
+ process.exit(2);
212
257
  }
213
258
  return args;
214
259
  }
@@ -233,8 +278,13 @@ Flags: --go (single-authorization: the operator's cut-start go covers the
233
278
  --push (auto-confirm checkpoint 2 — push tags)
234
279
  --publish (auto-confirm checkpoint 3 — dispatch publishes)
235
280
  --skip-site (skip EXE deploy)
281
+ --from-step10 (--mode handoff only: resume at Step 10 — GH releases +
282
+ site deploy — skipping the pre-flight gates and re-tagging;
283
+ use once Steps 1-9 are independently confirmed done. See
284
+ recovery-paths.md §Scenario 9.)
236
285
  Instance: --host <deploy-host> (default ${DEFAULT_DEPLOY_HOST}; or $ADIA_DEPLOY_HOST)
237
- --scope <@org> (npm scope; default ${DEFAULT_NPM_SCOPE}; or $ADIA_NPM_SCOPE)`);
286
+ --scope <@org> (npm scope; default ${DEFAULT_NPM_SCOPE}; or $ADIA_NPM_SCOPE)
287
+ $REGISTRY_POLL_MINUTES (Step 9 registry-poll window; default 30)`);
238
288
  }
239
289
 
240
290
  const rl = createInterface({ input: stdin, output: stdout });
@@ -714,6 +764,27 @@ async function step3PreFlight(args) {
714
764
  return;
715
765
  }
716
766
  console.log(`\n=== Step 3 — Pre-flight gates (${GATE_ROSTER.length}-gate roster — gate-roster.mjs) ===`);
767
+
768
+ // gh#3342: gate 4 (test:unit:serial) runs package-root `*.test.js` files
769
+ // for llm/agent/persona against their BUILT tsc output — agent.test.js's
770
+ // own docstring: "run against the BUILT output ... npm run build
771
+ // -w @adia-ai/agent first"; persona.test.js imports './index.js' directly;
772
+ // llm/core carries a dedicated dist-check.test.js that asserts the emitted
773
+ // artifacts exist and explicitly does NOT build them itself ("callers
774
+ // should run npm run build first"). All three packages' emitted .js/.d.ts
775
+ // are gitignored (packages/llm/agent/.gitignore: "Generated by npm run
776
+ // build ... not tracked in git"), so a fresh cut clone has none of them.
777
+ // cut-procedure.md §3.0's harvest preamble names `npm run build
778
+ // -w @adia-ai/llm` but that whole block is CONDITIONAL on source content
779
+ // having changed in the window, and never named agent/persona at all — a
780
+ // cut with no §3.0 trigger skipped the build outright and gate 4 failed
781
+ // on missing dist (the v0.8.59 cut). Unlike §3.0's regen outputs, these
782
+ // are plain build artifacts, not content-conditional — build them
783
+ // unconditionally, every cut, before gate 4 ever runs. Runs before the
784
+ // `--dry` early-return below so a dry preview shows it too.
785
+ console.log('\n-- Precondition: tsc build (llm, agent, persona — gate 4 needs their built output) --');
786
+ sh('npm run build -w @adia-ai/llm -w @adia-ai/agent -w @adia-ai/persona', args);
787
+
717
788
  if (args.dry) {
718
789
  step3PreFlightDry(args);
719
790
  return;
@@ -947,6 +1018,31 @@ function step4PromoteAndBump(args) {
947
1018
  console.log(' ✓ Hermes/Pi plugin manifests fresh after regeneration');
948
1019
  }
949
1020
 
1021
+ // Step 4d.8 — rebuild the dist CSS+JS bundles (gh#3342): bump.mjs's
1022
+ // web-components PINNED_REFS entry moves icons-cdn.js's hand-typed
1023
+ // PACKAGE_VERSION literal every cut (gh#3240/invariant 9), and that
1024
+ // literal is embedded verbatim in three committed dist bundles
1025
+ // (web-components.min.js, everything.min.js, chat-shell.min.js) —
1026
+ // same "derived artifact embeds the lockstep version" class as 4d.5-4d.7
1027
+ // above. Gate 9 (check:js-bundles-fresh) runs pre-bump in Step 3 and only
1028
+ // proves freshness against the PREVIOUS version; relying on the
1029
+ // derived-resync bot to rebuild post-merge left gate 9 red in the
1030
+ // handoff pre-flight (AGENTS.md: committed dist/ bundles are PR-blocking,
1031
+ // Class C — never a derived-resync-only artifact). Rebuild here, under
1032
+ // the same npm-ci layout Step 1 already asserted for gate 9, and verify:
1033
+ console.log('\n Step 4d.8 — rebuild dist CSS+JS bundles (icons-cdn.js pin embeds the lockstep bump):');
1034
+ sh('npm run build:bundles', args);
1035
+ if (args.dry) {
1036
+ console.log(' [dry] npm run check:css-bundles-fresh && npm run check:js-bundles-fresh (post-rebuild proof)');
1037
+ } else if (!shOk('npm run check:css-bundles-fresh') || !shOk('npm run check:js-bundles-fresh')) {
1038
+ console.error('\nERROR: dist bundles still stale after rebuild — build:bundles did not produce');
1039
+ console.error(' output matching check:css-bundles-fresh/check:js-bundles-fresh\'s expectations.');
1040
+ console.error(' Run both by hand to diagnose before re-running the cut.');
1041
+ process.exit(1);
1042
+ } else {
1043
+ console.log(' ✓ dist CSS+JS bundles fresh after rebuild');
1044
+ }
1045
+
950
1046
  // Step 4e — generate docs/ops/releases/vX.Y.Z.md (gh#399: neither this step nor
951
1047
  // its staging into the release commit existed before — check-cut-hygiene's
952
1048
  // "notes present" claim (Step 4g, below) went unverified until
@@ -976,6 +1072,27 @@ function step4PromoteAndBump(args) {
976
1072
  }
977
1073
  }
978
1074
 
1075
+ // gh#3361: derive Step 5's PINNED_REFS-covered allowlist entries directly
1076
+ // from bump.mjs's own tables, instead of hand-listing the same paths a
1077
+ // second time — the drift class this closes structurally (gh#1198, gh#1899,
1078
+ // gh#1954, gh#2473, gh#3342): every one of those was bump.mjs gaining a new
1079
+ // pinned file that release-pack.mjs's Step 5 didn't independently know
1080
+ // about. A package-keyed entry becomes `<pkgDir>/<file>`; a repo-level entry
1081
+ // (REPO_PINNED_REFS, e.g. the root README's "Current version" claim) is
1082
+ // already repo-root-relative. Pure — the selftest below asserts the
1083
+ // derivation against a fixture table, and separately that it agrees with
1084
+ // the REAL imported PINNED_REFS/REPO_PINNED_REFS for every entry currently
1085
+ // known (so an entry added to bump.mjs without a corresponding
1086
+ // release-pack.mjs edit still shows up here for free).
1087
+ function pinnedRefFiles(pinnedRefs, repoPinnedRefs) {
1088
+ const files = [];
1089
+ for (const [pkgDir, cfgs] of Object.entries(pinnedRefs)) {
1090
+ for (const cfg of cfgs) files.push(`${pkgDir}/${cfg.file}`);
1091
+ }
1092
+ for (const cfg of repoPinnedRefs) files.push(cfg.file);
1093
+ return files;
1094
+ }
1095
+
979
1096
  // Pure so the selftest can lock the parse without a real git tree (gh#2473,
980
1097
  // same "pure so selftest can lock the transform" shape as bump.mjs's
981
1098
  // bumpPinnedRef). Takes `git status --porcelain` output, returns the tracked
@@ -1043,10 +1160,10 @@ function step5Commit(args) {
1043
1160
  sh('git reset HEAD >/dev/null 2>&1 || true', args);
1044
1161
  const releaseFiles = [
1045
1162
  'package-lock.json',
1046
- // Root README's "Current version" claim bump.mjs moves it per-cut
1047
- // (REPO_PINNED_REFS); gate 18 (check-cut-hygiene doc-currency) exact-matches
1048
- // it against the cut version, so it must ride the release commit.
1049
- 'README.md',
1163
+ // Root README's "Current version" claim (gate 18, check-cut-hygiene
1164
+ // doc-currency, exact-matches it against the cut version) comes from
1165
+ // pinnedRefFiles(REPO_PINNED_REFS) below bump.mjs's own REPO_PINNED_REFS
1166
+ // table names this exact file (gh#3361).
1050
1167
  // Root CHANGELOG — the gh#453 promotion machinery renames its
1051
1168
  // [Unreleased] heading at Step 4, but this roster predated it, so the
1052
1169
  // v0.8.20 cut promoted the heading and then shipped WITHOUT it (the
@@ -1071,6 +1188,17 @@ function step5Commit(args) {
1071
1188
  'packages/genui/adia-catalog/adia.agent.json',
1072
1189
  'packages/genui/adia-catalog/adia.shells.json',
1073
1190
  'packages/genui/adia-catalog/catalog-data.js',
1191
+ // gh#3342/gh#3361: packages/web-components/core/icons-cdn.js (bump.mjs's
1192
+ // PACKAGE_VERSION pin, fifth instance of this drift class — gh#1198,
1193
+ // gh#1899, gh#1954, gh#2473) comes from pinnedRefFiles(PINNED_REFS)
1194
+ // below, not a hand-typed entry — bump.mjs's own PINNED_REFS table names
1195
+ // it, so it can't drift out of this list again.
1196
+ // gh#3342: Step 4d.8 rebuilds the dist CSS+JS bundles post-bump (the
1197
+ // icons-cdn.js PACKAGE_VERSION pin above is embedded in three of them) —
1198
+ // directory pathspecs, `git add` stages whatever the rebuild actually
1199
+ // changed, same shape as the chunk-corpus/skills-glob entries below.
1200
+ 'packages/web-components/dist',
1201
+ 'packages/web-modules/dist',
1074
1202
  // gh#1361: Step 4d.5b regenerates the catalog tier index post-bump (it
1075
1203
  // derives from the catalog derive-genui-catalog.mjs just refreshed) and,
1076
1204
  // when that regen goes stale against the chunk harvester's recorded
@@ -1121,19 +1249,25 @@ function step5Commit(args) {
1121
1249
  // Codex `agents/openai.yaml` glob above is.
1122
1250
  files.push(`${dir}/plugin.yaml`, `${dir}/__init__.py`, `${dir}/prompts/*.md`);
1123
1251
  }
1124
- // gh#2473: bump.mjs's PINNED_REFS also rewrites the factory README's
1125
- // OWN prose echo of the generation-MCP pin (`server pinned:
1126
- // @adia-ai/mcp@X` see bump.mjs's A2UI_MCP_PIN comment,
1127
- // check:plugin-count-claims' "factory README generation-MCP pin"
1128
- // equality check) — same invariant-8 scope as .mcp.json, but this
1129
- // list only ever staged .mcp.json + hermes-mcp.yaml, so the README
1130
- // edit rode along unstaged/uncommitted every cut until Step 5.6
1131
- // (below) started catching it.
1132
- if (name === 'adia-ui-factory') files.push(`${dir}/.mcp.json`, `${dir}/hermes-mcp.yaml`, `${dir}/README.md`);
1252
+ // gh#1954: Step 4d.7 also regenerates the factory's Hermes/Pi
1253
+ // hermes-mcp.yaml fragment post-bump not PINNED_REFS-covered (it's
1254
+ // a manifest-generator output, not a version-pin literal), so it
1255
+ // stays hand-listed here. factory's `.mcp.json` + `README.md`
1256
+ // generation-MCP pins (gh#1198, gh#2473 — same invariant-8 scope) come
1257
+ // from pinnedRefFiles(PINNED_REFS) below instead (gh#3361).
1258
+ if (name === 'adia-ui-factory') files.push(`${dir}/hermes-mcp.yaml`);
1133
1259
  return files;
1134
1260
  }),
1261
+ // gh#3361: every PINNED_REFS/REPO_PINNED_REFS-covered file, derived
1262
+ // straight from bump.mjs's own tables — see pinnedRefFiles()'s header
1263
+ // comment for what this closes. Deduped below against entries this list
1264
+ // already carries for OTHER reasons (e.g. every roster package's own
1265
+ // package.json is already staged above; web-modules' peer-pin entry
1266
+ // targets that same package.json).
1267
+ ...pinnedRefFiles(PINNED_REFS, REPO_PINNED_REFS),
1135
1268
  ];
1136
- sh(`git add ${releaseFiles.join(' ')}`, args);
1269
+ const dedupedReleaseFiles = [...new Set(releaseFiles)];
1270
+ sh(`git add ${dedupedReleaseFiles.join(' ')}`, args);
1137
1271
  sh('git diff --cached --stat | tail -3', args);
1138
1272
 
1139
1273
  // Step 5.6 — unstaged-tracked-files guard (gh#2473). Numbered 5.6, not
@@ -1291,6 +1425,77 @@ function registrySnapshot(args) {
1291
1425
  return `Registry state BEFORE publish (target = ${args.version}):\n${lines.join('\n')}`;
1292
1426
  }
1293
1427
 
1428
+ async function sleepMs(ms) {
1429
+ return new Promise((res) => setTimeout(res, ms));
1430
+ }
1431
+
1432
+ // Registry truth for the Step 9 poll — which packages (plus, as the sentinel
1433
+ // entry 'dist-tags.latest', the tag itself) have NOT yet converged on the
1434
+ // target version. [] means fully converged.
1435
+ function registryStalePackages(args, scope, version) {
1436
+ const stale = [];
1437
+ for (const pkg of PACKAGES) {
1438
+ let v;
1439
+ try { v = execSync(`npm view "${scope}/${pkg}" version`, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim(); }
1440
+ catch { v = null; }
1441
+ if (v !== version) stale.push(pkg);
1442
+ }
1443
+ let latest;
1444
+ try { latest = execSync(`npm view ${scope}/web-components dist-tags.latest`, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim(); }
1445
+ catch { latest = null; }
1446
+ if (latest !== version) stale.push('dist-tags.latest');
1447
+ return stale;
1448
+ }
1449
+
1450
+ // gh#3342: npm now stages a large publish ASYNCHRONOUSLY — the tarball can
1451
+ // take up to ~25 minutes to actually become visible on `npm view`, well past
1452
+ // this poll's former 10-minute bound (the v0.8.59 cut hit exactly this). A
1453
+ // re-dispatch of the publish workflow while a package is in that state fails
1454
+ // its own `npm publish` step with npm error E409 ("Cannot publish over
1455
+ // previously staged version") — that error IS the staged-not-lost signal
1456
+ // (npm rejected the duplicate BECAUSE the real publish already landed
1457
+ // server-side), never a real failure. Check the package's latest
1458
+ // publish-<pkg>.yml run: a FAILED conclusion whose log matches the E409
1459
+ // signature confirms staged-not-lost; anything else (no failed run, or a
1460
+ // failure that isn't E409) is a genuine miss. Never throws — a gh query
1461
+ // failure just means "not confirmed E409", the conservative default so a
1462
+ // genuine miss is never masked.
1463
+ function publishRunFailedWithE409(pkg) {
1464
+ try {
1465
+ const runId = execSync(
1466
+ `gh run list --workflow=publish-${pkg}.yml --limit 1 --json databaseId,conclusion -q '.[0] | select(.conclusion=="failure") | .databaseId'`,
1467
+ { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] },
1468
+ ).trim();
1469
+ if (!runId) return false;
1470
+ const log = execSync(`gh run view ${runId} --log-failed`, { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'] });
1471
+ return /E409|previously staged/i.test(log);
1472
+ } catch {
1473
+ return false;
1474
+ }
1475
+ }
1476
+
1477
+ // Poll the registry every 15s up to `minutes`, logging progress the same way
1478
+ // the original bash loop did. Returns the still-stale list (empty = fully
1479
+ // converged) — never throws; the caller decides what a non-empty result means.
1480
+ async function pollRegistry(args, scope, version, minutes, label) {
1481
+ if (args.dry) {
1482
+ console.log(` [dry] ${label} poll skipped (up to ${minutes}min, every 15s)`);
1483
+ return [];
1484
+ }
1485
+ const checks = Math.max(1, Math.round((minutes * 60) / 15));
1486
+ let stale = PACKAGES.length ? [...PACKAGES, 'dist-tags.latest'] : [];
1487
+ for (let i = 1; i <= checks; i++) {
1488
+ stale = registryStalePackages(args, scope, version);
1489
+ if (stale.length === 0) {
1490
+ console.log(` ✓ ${label} confirms all ${PACKAGES.length} + latest, after ${i} check(s)`);
1491
+ return [];
1492
+ }
1493
+ if (i < checks) await sleepMs(15000);
1494
+ }
1495
+ console.log(` ${label} did not converge after ${minutes}min — still stale: ${stale.join(', ')}`);
1496
+ return stale;
1497
+ }
1498
+
1294
1499
  async function step9Publish(args) {
1295
1500
  console.log('\n=== Step 9 — Verify-triggered + re-dispatch misses ===');
1296
1501
  const evidence = registrySnapshot(args);
@@ -1308,39 +1513,105 @@ async function step9Publish(args) {
1308
1513
  // Poll the REGISTRY itself instead — the skill's own doctrine (SKILL.md:
1309
1514
  // "the registry is the verify target, never a workflow's green check")
1310
1515
  // applies here as much as it does to the final check.
1311
- console.log(`\n Waiting for the registry to confirm all ${PACKAGES.length} packages at ${args.version} (polling every 15s, up to 10min)...`);
1312
- sh(
1313
- `for i in $(seq 1 40); do ` +
1314
- `fail=0; ` +
1315
- `for pkg in ${PACKAGES.join(' ')}; do ` +
1316
- `v="$(npm view "${args.scope}/$pkg" version 2>/dev/null || true)"; ` +
1317
- `[ "$v" = "${args.version}" ] || fail=1; ` +
1318
- `done; ` +
1319
- `latest="$(npm view ${args.scope}/web-components dist-tags.latest 2>/dev/null || true)"; ` +
1320
- `[ "$latest" = "${args.version}" ] || fail=1; ` +
1321
- `[ "$fail" = 0 ] && { echo " ✓ registry confirms all ${PACKAGES.length} + latest, after $i check(s)"; exit 0; }; ` +
1322
- `sleep 15; ` +
1323
- `done; ` +
1324
- `echo " registry did not converge after 10min — the fail-closed check below will report exactly which package(s) are still stale"`,
1325
- args,
1326
- );
1516
+ //
1517
+ // gh#3342: the poll window moved from a flat 10 minutes to
1518
+ // REGISTRY_POLL_MINUTES (default 30) npm stages a large publish
1519
+ // ASYNCHRONOUSLY and the tarball can take up to ~25 minutes to become
1520
+ // visible on `npm view`, past the old bound (the v0.8.59 cut hit this and
1521
+ // exited 1 with every publish run actually green).
1522
+ const pollMinutes = Number(process.env.REGISTRY_POLL_MINUTES) || 30;
1523
+ console.log(`\n Waiting for the registry to confirm all ${PACKAGES.length} packages at ${args.version} (polling every 15s, up to ${pollMinutes}min)...`);
1524
+ let stale = await pollRegistry(args, args.scope, args.version, pollMinutes, 'registry');
1525
+
1526
+ // gh#3342: a still-stale package here may be genuinely missing, OR it may
1527
+ // be staged-but-not-yet-visible — distinguish via its latest publish run.
1528
+ // A run that failed specifically on npm's E409 ("Cannot publish over
1529
+ // previously staged version") proves the package IS staged: npm rejected
1530
+ // the (redundant) publish attempt BECAUSE the real one already landed
1531
+ // server-side. That is the staged-not-lost signal this ticket exists to
1532
+ // handle — never a hard failure, and never itself grounds to re-dispatch
1533
+ // again (a THIRD publish attempt would only 409 again). Give
1534
+ // E409-confirmed packages one more extended, isolated poll; a package
1535
+ // with no E409 signal is a genuine miss and skips straight to the
1536
+ // fail-closed report below.
1537
+ if (stale.length > 0 && !args.dry) {
1538
+ const staleePkgs = stale.filter((p) => p !== 'dist-tags.latest');
1539
+ const e409 = staleePkgs.filter((pkg) => publishRunFailedWithE409(pkg));
1540
+ if (e409.length > 0) {
1541
+ console.log(`\n ${e409.length} stale package(s) confirmed E409 (staged, not lost) — extending the poll another ${pollMinutes}min: ${e409.join(', ')}`);
1542
+ console.log(' (a genuinely missing package would show no failed run, or a failure that is not E409 — those are NOT extended)');
1543
+ // The fail-closed block below re-queries the registry fresh regardless
1544
+ // of this poll's own result — this extended wait exists to let a
1545
+ // genuinely staged package converge before that final read, not to
1546
+ // compute the verdict itself.
1547
+ await pollRegistry(args, args.scope, args.version, pollMinutes, 'extended registry poll');
1548
+ }
1549
+ }
1550
+
1327
1551
  // Fail-closed: the registry is the verify target (SKILL.md) — a missing or
1328
1552
  // stale package aborts the cut here, before GH releases + site deploy. This
1329
- // is now a final confirmation of the poll above, not the first real read.
1553
+ // is now a final confirmation of the poll(s) above, not the first real read.
1330
1554
  console.log(`\n Post-publish registry verification, all ${PACKAGES.length} at ${args.version} + npm latest (fail-closed):`);
1331
- sh(
1332
- `fail=0; ` +
1333
- `for pkg in ${PACKAGES.join(' ')}; do ` +
1334
- `v="$(npm view "${args.scope}/$pkg" version 2>/dev/null || true)"; ` +
1335
- `echo " $pkg: \${v:-MISSING}"; ` +
1336
- `[ "$v" = "${args.version}" ] || fail=1; ` +
1337
- `done; ` +
1338
- `latest="$(npm view ${args.scope}/web-components dist-tags.latest 2>/dev/null || true)"; ` +
1339
- `echo " latest: \${latest:-MISSING}"; ` +
1340
- `[ "$latest" = "${args.version}" ] || fail=1; ` +
1341
- `[ "$fail" = 0 ] || { echo "ERROR: registry does not confirm ${args.version} across all ${PACKAGES.length} packages — fix before GH releases/deploy"; exit 1; }`,
1342
- args,
1343
- );
1555
+ if (args.dry) {
1556
+ console.log(' [dry] registry re-verify skipped');
1557
+ return;
1558
+ }
1559
+ const final = registryStalePackages(args, args.scope, args.version);
1560
+ for (const pkg of PACKAGES) {
1561
+ console.log(` ${pkg}: ${final.includes(pkg) ? 'MISSING' : args.version}`);
1562
+ }
1563
+ console.log(` latest: ${final.includes('dist-tags.latest') ? 'MISSING' : args.version}`);
1564
+ if (final.length > 0) {
1565
+ console.error(`ERROR: registry does not confirm ${args.version} across all ${PACKAGES.length} packages — fix before GH releases/deploy`);
1566
+ console.error(` still stale: ${final.join(', ')}`);
1567
+ process.exit(1);
1568
+ }
1569
+ }
1570
+
1571
+ // Precondition for `--from-step10` (critic finding on PR #3364): jumping
1572
+ // straight to Step 10 without checking the release tags exist is a silent
1573
+ // trap — `gh release create <tag>` on a tag that doesn't exist yet mints a
1574
+ // NEW lightweight tag at whatever HEAD happens to be, not the real tagged
1575
+ // release commit. Refuse the resume outright unless every tag Step 6 would
1576
+ // have created (umbrella + all per-package) already exists on origin and
1577
+ // points at HEAD.
1578
+ function assertReleaseTagsAtHead(args) {
1579
+ console.log('\n Precondition — verifying release tags already exist and point at HEAD before resuming at Step 10:');
1580
+ const headSha = shQuiet('git rev-parse HEAD', args).trim();
1581
+ const tags = [`v${args.version}`, ...PACKAGES.map((p) => `${p}-v${args.version}`)];
1582
+ if (args.dry) {
1583
+ console.log(` [dry] git fetch origin ${tags.map((t) => `tag ${t}`).join(' ')}`);
1584
+ console.log(` [dry] verify each of ${tags.length} tags resolves to HEAD (${headSha || '<dry HEAD>'})`);
1585
+ return;
1586
+ }
1587
+ sh(`git fetch origin ${tags.map((t) => `tag ${t}`).join(' ')}`, args);
1588
+ const missing = [];
1589
+ const mismatched = [];
1590
+ for (const tag of tags) {
1591
+ let tagSha;
1592
+ try {
1593
+ tagSha = execSync(`git rev-parse "${tag}^{commit}"`, { cwd: REPO, encoding: 'utf8' }).trim();
1594
+ } catch {
1595
+ missing.push(tag);
1596
+ continue;
1597
+ }
1598
+ if (tagSha !== headSha) mismatched.push(`${tag} -> ${tagSha}`);
1599
+ }
1600
+ if (missing.length > 0 || mismatched.length > 0) {
1601
+ console.error('\nERROR: --from-step10 refuses to resume — the release tags this handoff needs are not all present at HEAD.');
1602
+ if (missing.length > 0) {
1603
+ console.error(` Missing entirely (Step 6 creates these, Step 8 pushes them to origin): ${missing.join(', ')}`);
1604
+ }
1605
+ if (mismatched.length > 0) {
1606
+ console.error(` Point somewhere other than HEAD (${headSha}): ${mismatched.join(', ')}`);
1607
+ }
1608
+ console.error(' Resuming Step 10 here would let `gh release create` mint NEW lightweight tags at the');
1609
+ console.error(' current HEAD instead of using the real tagged release commit — silently wrong.');
1610
+ console.error(' Fix: confirm the original handoff run actually reached Step 8 (tags pushed to origin)');
1611
+ console.error(' and that HEAD has not moved since, or re-run the full handoff instead of --from-step10.');
1612
+ process.exit(1);
1613
+ }
1614
+ console.log(` ✓ all ${tags.length} release tags exist on origin and point at HEAD (${headSha})`);
1344
1615
  }
1345
1616
 
1346
1617
  // ── Step 10 — GH releases + site deploy ──────────────────────────
@@ -1493,6 +1764,22 @@ async function main() {
1493
1764
  console.error(` node ${repoCopy} <same args>\n`);
1494
1765
  }
1495
1766
  }
1767
+ // gh#3342: --from-step10 resumes a handoff that already tagged, pushed,
1768
+ // and published — skip straight to Step 10 (GH releases + site deploy)
1769
+ // rather than a plain re-run, which would re-run the full pre-flight gate
1770
+ // roster (Step 3, ~15min) AND re-tag at HEAD (Step 6, wrong if anything
1771
+ // merged since the original tag). Every step this skips is either
1772
+ // idempotent-but-wasteful (3, 9) or actively wrong to repeat (6, if HEAD
1773
+ // moved) — a real operational need the v0.8.59 cut hit and worked around
1774
+ // with a one-off, never-checked-in script.
1775
+ if (args.mode === 'handoff' && args.fromStep10) {
1776
+ console.log('\n[resume --from-step10] skipping Steps 1/3/4/5/6/7/8/9 (already tagged/pushed/published) — jumping straight to Step 10.');
1777
+ assertReleaseTagsAtHead(args);
1778
+ await step10GhAndSite(args);
1779
+ rl.close();
1780
+ console.log('\n✓ release-pack complete (resumed at Step 10). Verify against the npm registry + GH releases, then author the release notes.');
1781
+ return;
1782
+ }
1496
1783
  // gh#765: an aborted Step-4 run leaves bumped versions + promoted
1497
1784
  // CHANGELOGs; detect BEFORE pre-flight so gate 24 validates at the right
1498
1785
  // version and Step 4 skips the already-done promote/stub/bump.
@@ -1626,6 +1913,50 @@ async function selftest() {
1626
1913
  }
1627
1914
  }
1628
1915
 
1916
+ // gh#3361 — pinnedRefFiles() must derive `<pkgDir>/<file>` for a
1917
+ // package-keyed entry and the bare `file` for a repo-level entry, from a
1918
+ // FIXTURE table (independent of bump.mjs's real, evolving content).
1919
+ {
1920
+ const fixturePinned = {
1921
+ 'packages/plugins/fake-plugin': [{ file: '.mcp.json' }, { file: 'README.md' }],
1922
+ 'packages/fake-pkg': [{ file: 'core/pinned.js' }],
1923
+ };
1924
+ const fixtureRepoPinned = [{ file: 'README.md' }];
1925
+ const got = pinnedRefFiles(fixturePinned, fixtureRepoPinned);
1926
+ const want = [
1927
+ 'packages/plugins/fake-plugin/.mcp.json',
1928
+ 'packages/plugins/fake-plugin/README.md',
1929
+ 'packages/fake-pkg/core/pinned.js',
1930
+ 'README.md',
1931
+ ];
1932
+ if (JSON.stringify(got) !== JSON.stringify(want)) {
1933
+ console.error(`selftest FAIL: pinnedRefFiles() expected ${JSON.stringify(want)}, got ${JSON.stringify(got)}`);
1934
+ process.exit(1);
1935
+ }
1936
+ }
1937
+ // gh#3361 — the structural guarantee this ticket exists for: every path
1938
+ // bump.mjs's REAL, imported PINNED_REFS/REPO_PINNED_REFS tables name is
1939
+ // present in Step 5's assembled releaseFiles (the `--mode cut --dry`
1940
+ // subprocess run below), proven by literally re-deriving the expected
1941
+ // set from the SAME imported tables the subprocess itself reads — a
1942
+ // future PINNED_REFS entry with no matching releaseFiles line (the
1943
+ // gh#1198/1899/1954/2473/3342 drift shape) fails HERE, not three cuts
1944
+ // later at Step 5.6.
1945
+ {
1946
+ const expectedPinned = pinnedRefFiles(PINNED_REFS, REPO_PINNED_REFS);
1947
+ if (expectedPinned.length === 0) {
1948
+ console.error('selftest FAIL: bump.mjs\'s real PINNED_REFS/REPO_PINNED_REFS produced zero derived paths — the import is broken');
1949
+ process.exit(1);
1950
+ }
1951
+ // The fixture set itself must never silently reduce to nothing new —
1952
+ // proves gh#3342's icons-cdn.js entry specifically survived the
1953
+ // gh#3361 refactor (it's the exact file that motivated this ticket).
1954
+ if (!expectedPinned.includes('packages/web-components/core/icons-cdn.js')) {
1955
+ console.error(`selftest FAIL: pinnedRefFiles(PINNED_REFS, REPO_PINNED_REFS) is missing packages/web-components/core/icons-cdn.js — got ${JSON.stringify(expectedPinned)}`);
1956
+ process.exit(1);
1957
+ }
1958
+ }
1959
+
1629
1960
  // gh#2473 — Step 5.6's guard: unstagedTrackedLeftovers() must find a
1630
1961
  // plain modified/deleted tracked file, unquote a C-quoted path
1631
1962
  // (spaces), and report the NEW side of a rename/copy — all from a
@@ -1689,6 +2020,11 @@ async function selftest() {
1689
2020
  `${GATE_ROSTER.length}-gate roster`,
1690
2021
  `✓ all ${GATE_ROSTER.length}/${GATE_ROSTER.length} gates passed`,
1691
2022
  'STOPPING per invariant 3',
2023
+ // gh#3342 — gate 4 needs llm/agent/persona's BUILT (tsc) output, not
2024
+ // just conditionally regenerated content; the precondition must run
2025
+ // even in a --dry preview, before the gate roster itself.
2026
+ 'Precondition: tsc build (llm, agent, persona',
2027
+ '[dry] npm run build -w @adia-ai/llm -w @adia-ai/agent -w @adia-ai/persona',
1692
2028
  // gh#358 — the release branch must actually be created, not just documented.
1693
2029
  'git checkout -b release/v9.9.9',
1694
2030
  // gh#1197 — Step 5's staging pathspec must carry gate 28's committed
@@ -1726,6 +2062,19 @@ async function selftest() {
1726
2062
  // README target), or it rides unstaged/uncommitted past the release
1727
2063
  // commit the same way .mcp.json itself did before gh#1198.
1728
2064
  'packages/plugins/adia-ui-factory/README.md',
2065
+ // gh#3342 — fifth instance of the same allowlist-drift class
2066
+ // (gh#1198, gh#1899, gh#1954, gh#2473): bump.mjs's web-components
2067
+ // PINNED_REFS entry moves icons-cdn.js's PACKAGE_VERSION literal
2068
+ // every cut; the v0.8.59 cut hit Step 5.6's guard on this exact file.
2069
+ 'packages/web-components/core/icons-cdn.js',
2070
+ // gh#3342 — Step 4d.8 rebuilds the dist bundles post-bump (the
2071
+ // icons-cdn.js pin above is embedded in three of them) and Step 5
2072
+ // must stage the result, or gate 9 (check:js-bundles-fresh) fails in
2073
+ // the handoff pre-flight the way the v0.8.59 cut's did.
2074
+ 'Step 4d.8 — rebuild dist CSS+JS bundles',
2075
+ '[dry] npm run build:bundles',
2076
+ 'packages/web-components/dist',
2077
+ 'packages/web-modules/dist',
1729
2078
  // gh#2473 — Step 5.6's unstaged-tracked-files guard must actually run
1730
2079
  // (as a no-op) on a clean --dry plan. Its real leftover-detection
1731
2080
  // logic is unit-tested directly above via unstagedTrackedLeftovers();
@@ -1753,6 +2102,37 @@ async function selftest() {
1753
2102
  process.exit(1);
1754
2103
  }
1755
2104
 
2105
+ // gh#2894 — a --substantive-packages/--stub-packages name whose roster
2106
+ // entry is lockstep:false (or unknown) must die at PARSE time, naming
2107
+ // the roster file, BEFORE Step 1 runs. The 0.8.58 cut passed
2108
+ // `adia-plugins` (lockstep:false) in --substantive-packages and only
2109
+ // caught it at Step 5.6, after the 34-gate pre-flight had already run.
2110
+ const rosterRejectShapes = [
2111
+ { flags: '--substantive-packages adia-plugins', name: 'adia-plugins' },
2112
+ { flags: '--stub-packages totally-unknown-package', name: 'totally-unknown-package' },
2113
+ ];
2114
+ for (const { flags, name } of rosterRejectShapes) {
2115
+ let failed = false;
2116
+ let out = '';
2117
+ try {
2118
+ execSync(
2119
+ `node "${scriptPath}" --mode cut --version 9.9.9 --date 2026-01-01 --previous-version 9.9.8 ${flags} --dry`,
2120
+ { cwd: REPO, encoding: 'utf8' },
2121
+ );
2122
+ } catch (e) {
2123
+ failed = true;
2124
+ out = (e.stdout || '') + (e.stderr || '');
2125
+ }
2126
+ if (!failed || !out.includes(name) || !out.includes('package-paths.mjs PACKAGE_ROSTER')) {
2127
+ console.error(`selftest FAIL: --substantive-packages/--stub-packages "${name}" must hard-reject naming scripts/package-paths.mjs PACKAGE_ROSTER`);
2128
+ process.exit(1);
2129
+ }
2130
+ if (out.includes('=== Step 1') || out.includes('gate roster')) {
2131
+ console.error(`selftest FAIL: the roster guard for "${name}" must fire BEFORE Step 1, not after`);
2132
+ process.exit(1);
2133
+ }
2134
+ }
2135
+
1756
2136
  // gh#765 — from-scratch with substantive-but-no-stub packages must die at
1757
2137
  // PARSE time, naming the ride-alongs, BEFORE any pre-flight gate runs
1758
2138
  // (v0.8.29 burned three ~15-min pre-flight re-runs discovering it at
@@ -1851,6 +2231,62 @@ async function selftest() {
1851
2231
  process.exit(1);
1852
2232
  }
1853
2233
 
2234
+ // gh#3364 critic finding — `--from-step10` must (a) only apply to
2235
+ // --mode handoff, at parse time, and (b) refuse to resume without first
2236
+ // verifying the release tags already exist and point at HEAD (otherwise
2237
+ // `gh release create` on a tag that was never made mints a NEW
2238
+ // lightweight tag at whatever HEAD happens to be — silently wrong).
2239
+ let fromStep10ModeFailed = false;
2240
+ let fromStep10ModeOut = '';
2241
+ try {
2242
+ execSync(
2243
+ `node "${scriptPath}" --mode cut --version 9.9.9 --date 2026-01-01 --previous-version 9.9.8 --from-step10 --dry`,
2244
+ { cwd: REPO, encoding: 'utf8' },
2245
+ );
2246
+ } catch (e) {
2247
+ fromStep10ModeFailed = true;
2248
+ fromStep10ModeOut = (e.stdout || '') + (e.stderr || '');
2249
+ }
2250
+ if (!fromStep10ModeFailed || !fromStep10ModeOut.includes('--from-step10 only applies to --mode handoff')) {
2251
+ console.error('selftest FAIL: --from-step10 with a non-handoff mode must hard-reject at parse time');
2252
+ process.exit(1);
2253
+ }
2254
+
2255
+ let resumeOut;
2256
+ try {
2257
+ resumeOut = execSync(
2258
+ `node "${scriptPath}" --mode handoff --version 9.9.9 --date 2026-01-01 --previous-version 9.9.8 ` +
2259
+ `--gh-notes-file "${notesFile}" --from-step10 --skip-site --dry`,
2260
+ { cwd: REPO, encoding: 'utf8' },
2261
+ );
2262
+ } catch (e) {
2263
+ console.error('selftest FAIL: --mode handoff --from-step10 --dry plan assembly exited non-zero');
2264
+ console.error((e.stdout || '') + (e.stderr || ''));
2265
+ process.exit(1);
2266
+ }
2267
+ const resumeNeed = [
2268
+ '[resume --from-step10] skipping Steps 1/3/4/5/6/7/8/9',
2269
+ 'Precondition — verifying release tags already exist and point at HEAD',
2270
+ '[dry] git fetch origin',
2271
+ '=== Step 10 — GH releases + Site deploy',
2272
+ ];
2273
+ for (const marker of resumeNeed) {
2274
+ if (!resumeOut.includes(marker)) {
2275
+ console.error(`selftest FAIL: expected marker not found in --from-step10 dry plan: "${marker}"`);
2276
+ process.exit(1);
2277
+ }
2278
+ }
2279
+ // "=== Step 1" is a prefix of "=== Step 10" — every marker below must
2280
+ // carry enough of its own header to avoid a false positive against the
2281
+ // Step 10 banner this resume path is SUPPOSED to reach.
2282
+ const resumeMustSkip = ['=== Step 1 — Re-baseline', '=== Step 3 — Pre-flight', '=== Step 6 — Tag at HEAD', '=== Step 7 — F-N1', '=== Step 8 — Push', '=== Step 9 — Verify-triggered'];
2283
+ for (const marker of resumeMustSkip) {
2284
+ if (resumeOut.includes(marker)) {
2285
+ console.error(`selftest FAIL: --from-step10 must skip Steps 1-9 — found "${marker}" in the dry plan`);
2286
+ process.exit(1);
2287
+ }
2288
+ }
2289
+
1854
2290
  console.log('selftest OK');
1855
2291
  } finally {
1856
2292
  fs.rmSync(tmp, { recursive: true, force: true });