@bongos/core 1.19.1032 → 1.19.1034

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.
Files changed (151) hide show
  1. package/.bongos-core.json +322 -117
  2. package/.claude/skills/builder-start/SKILL.md +1 -1
  3. package/.claude/skills/grade-audit/SKILL.md +1 -1
  4. package/.claude/skills/grade-recover/SKILL.md +2 -1
  5. package/.claude/skills/grader-health/SKILL.md +5 -1
  6. package/.claude/skills/merge-mode/SKILL.md +1 -1
  7. package/docs/adr/0089-modular-multi-model-adversarial-grader.md +15 -0
  8. package/docs/adr/0175-the-board-room-full-idea-ratification.md +3 -2
  9. package/docs/adr/0178-the-copy-desk-flag-and-queue.md +3 -3
  10. package/docs/adr/0188-patrol-runs-on-ci-not-the-server.md +2 -2
  11. package/docs/adr/0189-baseline-floors-and-the-freeze-detector.md +1 -1
  12. package/docs/adr/0233-a-copy-proposal-is-a-task-carrying-a-patch.md +1 -1
  13. package/docs/adr/0241-the-artist-gate-is-a-per-project-deploy-gate-that-reads-a-state.md +11 -3
  14. package/docs/adr/README.md +4 -4
  15. package/docs/api/openapi.json +1 -1
  16. package/docs/architecture.md +4 -3
  17. package/docs/canonical-permissions.md +2 -2
  18. package/docs/copy-inventory.md +34 -30
  19. package/docs/copy-registry.json +69 -33
  20. package/docs/file-map.md +4 -3
  21. package/docs/module-api-changelog.md +4 -0
  22. package/docs/recipes/autobongos-windows-host.md +3 -1
  23. package/docs/recipes/local-dev.md +45 -3
  24. package/docs/recipes/packaging-the-core.md +3 -1
  25. package/docs/recipes/redteam-patrol.md +2 -2
  26. package/docs/recipes/upgrading-the-core.md +1 -1
  27. package/modules/autonomy/cadence.js +52 -0
  28. package/modules/autonomy/gauge.js +57 -9
  29. package/modules/government/protected-surfaces.json +1 -0
  30. package/modules/grading/grade-engine.js +267 -0
  31. package/modules/grading/grader-subagent.js +4 -3
  32. package/modules/grading/grader-workers/index.js +67 -11
  33. package/modules/grading/grader-workers/worker-verdict.js +32 -2
  34. package/modules/grading/grader.js +109 -88
  35. package/modules/hall-ui/public/deploy.js +35 -0
  36. package/modules/hall-ui/public/project-settings.css +7 -0
  37. package/modules/hall-ui/records/builders-roster.md +10 -0
  38. package/modules/hall-ui/records/catalog.md +10 -0
  39. package/modules/hall-ui/records/collaboration.md +14 -0
  40. package/modules/hall-ui/records/goal-dependency-forest.md +11 -0
  41. package/modules/hall-ui/records/oversight-family.md +19 -0
  42. package/modules/hall-ui/records/panel-family.md +19 -0
  43. package/modules/hall-ui/records/reading-rooms.md +12 -0
  44. package/modules/hall-ui/records/record-family.md +13 -0
  45. package/modules/hall-ui/records/reference-room.md +9 -0
  46. package/modules/hall-ui/records/roster-and-ledger.md +9 -0
  47. package/modules/hall-ui/records/sky.md +23 -0
  48. package/modules/lifecycle/dependency-advisory.js +2 -1
  49. package/modules/lifecycle/routes/tasks.js +19 -4
  50. package/modules/lifecycle/ship-preflight.js +91 -0
  51. package/modules/provisioning/control-plane-guard.js +14 -13
  52. package/modules/provisioning/core-upgrade.js +30 -17
  53. package/modules/provisioning/public-refusal.js +44 -0
  54. package/modules/provisioning/routes/disconnect.js +2 -1
  55. package/modules/provisioning/routes/env-manifest.js +5 -4
  56. package/modules/provisioning/routes/provisioning.js +16 -15
  57. package/modules/provisioning/routes/repo-private.js +2 -1
  58. package/package-lock.json +2 -2
  59. package/package.json +1 -1
  60. package/release-notes.json +7481 -0
  61. package/scripts/gds/address-park-sweep.sh +3 -1
  62. package/scripts/gds/autobongos-run.js +91 -3
  63. package/scripts/gds/backfill-cost.js +20 -9
  64. package/scripts/gds/baseline-staleness.js +1 -1
  65. package/scripts/gds/claim.js +2 -2
  66. package/scripts/gds/credential-presence.js +281 -0
  67. package/scripts/gds/db-backup-nightly.sh +9 -4
  68. package/scripts/gds/db-backup-offsite.sh +4 -2
  69. package/scripts/gds/deploy-main-audit-gate.sh +11 -4
  70. package/scripts/gds/dev-lib.js +212 -5
  71. package/scripts/gds/dev.js +102 -50
  72. package/scripts/gds/doctor.js +3 -2
  73. package/scripts/gds/grade-correlation-audit.js +280 -0
  74. package/scripts/gds/grade-replay.js +237 -0
  75. package/scripts/gds/hooks/pre-push.js +7 -3
  76. package/scripts/gds/main-audit.js +19 -10
  77. package/scripts/gds/memory-map.js +3 -1
  78. package/scripts/gds/onboarding-config.js +3 -2
  79. package/scripts/gds/package-core.js +72 -1
  80. package/scripts/gds/plain-cards.js +4 -0
  81. package/scripts/gds/preflight.js +12 -4
  82. package/scripts/gds/provision-core-upgrade.js +282 -80
  83. package/scripts/gds/provision-repo-auth.js +15 -0
  84. package/scripts/gds/provision.js +1 -1
  85. package/scripts/gds/publish-credential-check.js +3 -0
  86. package/scripts/gds/push-main.js +14 -3
  87. package/scripts/gds/push-path-brief.js +3 -2
  88. package/scripts/gds/redteam-patrol.js +4 -4
  89. package/scripts/gds/release-notes.js +271 -0
  90. package/scripts/gds/run-routine.js +18 -5
  91. package/scripts/gds/run-unit-tests.js +14 -3
  92. package/scripts/gds/seed-board-room-tasks.js +1 -1
  93. package/scripts/gds/session-cost.js +12 -3
  94. package/scripts/gds/ship-deploy-target.js +11 -6
  95. package/scripts/gds/ship-flow.js +5 -3
  96. package/scripts/gds/ship-grade-steps.js +33 -0
  97. package/scripts/gds/ship-land.js +241 -16
  98. package/scripts/gds/ship-merge.js +3 -2
  99. package/scripts/gds/ship-preflight-steps.js +86 -11
  100. package/scripts/gds/ship.js +1 -1
  101. package/scripts/gds/stray-unit-sweep.sh +3 -1
  102. package/scripts/gds/upgrade-migrate.js +67 -0
  103. package/scripts/gds/upgrade.js +9 -7
  104. package/scripts/hall-preview/README.md +20 -3
  105. package/scripts/hall-preview/live-identity.js +1 -1
  106. package/scripts/hall-preview/refresh-fixtures.js +71 -11
  107. package/scripts/hall-preview/scrub-identity.js +296 -0
  108. package/src/bongos/routes/security.js +3 -3
  109. package/src/build-info.js +4 -2
  110. package/src/module-api.js +1 -1
  111. package/tests/adr_0089_scope.mjs +70 -0
  112. package/tests/adr_0162_citation.mjs +184 -0
  113. package/tests/autobongos_cadence.mjs +74 -0
  114. package/tests/autobongos_loop.mjs +114 -0
  115. package/tests/autonomy_gauge.mjs +56 -0
  116. package/tests/consumer_layout_boot.mjs +363 -31
  117. package/tests/core_upgrade_door.mjs +40 -6
  118. package/tests/core_upgrade_runner.mjs +329 -5
  119. package/tests/credential_presence.mjs +153 -0
  120. package/tests/db_backup_nightly.mjs +21 -3
  121. package/tests/db_backup_offsite.mjs +81 -0
  122. package/tests/deploy_audit_gate.mjs +5 -2
  123. package/tests/deploy_page_projects.mjs +63 -2
  124. package/tests/dev_lib.mjs +2 -1
  125. package/tests/dev_windows.mjs +324 -0
  126. package/tests/env_knobs_neutral_prefix.mjs +203 -0
  127. package/tests/fixtures/grade-replay/recorded-panel-grades.json +27 -0
  128. package/tests/government_protected_surfaces.mjs +10 -0
  129. package/tests/grade_correlation_audit.mjs +159 -0
  130. package/tests/grade_engine.mjs +249 -0
  131. package/tests/grade_engine_replay.mjs +99 -0
  132. package/tests/grader_prompt_window_bound.mjs +132 -0
  133. package/tests/grader_unbacked_score.mjs +194 -0
  134. package/tests/hall_preview_scrub.mjs +214 -0
  135. package/tests/hall_records.mjs +85 -0
  136. package/tests/helpers.mjs +25 -0
  137. package/tests/instance_db_migrated_on_code_move.mjs +8 -1
  138. package/tests/integration_parked_ratchet.mjs +24 -1
  139. package/tests/plain_cards.mjs +1 -1
  140. package/tests/provisioning_public_refusal.mjs +195 -0
  141. package/tests/publish_status_branch.mjs +128 -0
  142. package/tests/redteam_patrol.mjs +1 -1
  143. package/tests/release_notes.mjs +327 -0
  144. package/tests/shell_stat_portability.mjs +49 -0
  145. package/tests/ship_land_conflict.mjs +181 -0
  146. package/tests/ship_land_red_pr.mjs +236 -0
  147. package/tests/ship_preflight.mjs +226 -3
  148. package/tests/start_env_guard.mjs +16 -0
  149. package/tests/sweep_app_account.mjs +15 -2
  150. package/tests/upgrade.mjs +20 -1
  151. package/tests/upgrade_migrate.mjs +195 -0
@@ -34,6 +34,10 @@
34
34
  // 3. Per file, IN MEMORY (processCoreFile): DOCS (.md) are genericized + secret-scrubbed
35
35
  // like the mirror; FUNCTIONAL files are kept VERBATIM and scanned only for an exact
36
36
  // live-credential leak (brand/IP hits are non-fatal ADVISORIES, surfaced for a human).
37
+ // 3b. RELEASE NOTES (task 1004218): release-notes.json — what each release brought in,
38
+ // generated from the ref's HISTORY (scripts/gds/release-notes.js), its prose redacted
39
+ // exactly like a doc, and pinned like every other file. Skipped, with a line saying
40
+ // so, where there is no history to read (--from-worktree, a shallow clone).
37
41
  // 4. NO-LEAK GATE (fail-closed): if any DOC leaks post-redaction, or any FUNCTIONAL file
38
42
  // carries a live credential verbatim, PRODUCE NOTHING — dist/ is never touched. The
39
43
  // in-memory pass makes the abort atomic (same discipline as the mirror export).
@@ -105,6 +109,12 @@ const {
105
109
  const { collectEnvSecrets } = require(path.join(REPO_ROOT, 'modules/security/secret-scrub.js'));
106
110
  const { redactBlob, parseLsTree } = require(path.join(REPO_ROOT, 'scripts/public-mirror-export.js'));
107
111
  const { partitionHits, unusedEntries } = require(path.join(REPO_ROOT, 'scripts/gds/leak-scan-allowlist.js'));
112
+ // The release notes shipped beside package.json (task 1004218). Required by a literal path:
113
+ // the module is pure and requires nothing, so it adds no computed require to this file's
114
+ // executed closure (tests/executed_closure.mjs counts those).
115
+ const {
116
+ RELEASE_NOTES_FILE, RELEASE_NOTES_MAX_BYTES, generateReleaseNotes, boundReleaseNotes, serializeReleaseNotes,
117
+ } = require('./release-notes.js');
108
118
 
109
119
  const MANIFEST_SCHEMA = 1;
110
120
  const ARTIFACT_NAME = 'bongos-core';
@@ -734,6 +744,42 @@ function scanArtifactForLeaks(files, { literals = PRIOR_INSTANCE_LITERALS } = {}
734
744
  return { literalHits, secretWarnings };
735
745
  }
736
746
 
747
+ // release-notes.json — the one file the core carries that is derived from HISTORY rather
748
+ // than from a committed file (task 1004218). It only exists where the history does, which
749
+ // is the CI pack (publish.yml checks out at fetch-depth 0). main() stages it into the SAME
750
+ // staged/filesMeta arrays as package.json, so the pin manifest covers it and --self-verify
751
+ // reproduces it; a file written into the stage dir behind the manifest's back would fail
752
+ // self-verify at publish time.
753
+ //
754
+ // ITS PROSE IS TREATED AS A DOC. These are merge summaries — builder-authored sentences — so
755
+ // the neutral floor applies exactly as it does to a shipped .md: redacted with the same rules
756
+ // (redactBlob), and any residue after redaction is a fatal no-leak failure. Not optional:
757
+ // ten of the 1,399 summaries in the history (measured 2026-09-25) name a prior instance or
758
+ // its owner, and history cannot be edited — shipped verbatim, they would fail the fail-closed
759
+ // post-build scan on EVERY pack from now on, and the lane would never publish again. That
760
+ // scan still reads the finished file, as it reads every staged one.
761
+ //
762
+ // Pure: notes + the rules main() already built in, `{ buf, notes, dropped, summaries,
763
+ // redacted, leaks }` out.
764
+ function releaseNotesArtifact(notes, { rules, sensitiveLiterals, envSecrets, knownDocNames, maxBytes = RELEASE_NOTES_MAX_BYTES }) {
765
+ const leaks = [];
766
+ let redacted = 0;
767
+ const clean = {};
768
+ for (const [version, entries] of Object.entries(notes || {})) {
769
+ clean[version] = entries.map((e) => {
770
+ const r = redactBlob({ buf: Buffer.from(e.text, 'utf8'), rules, sensitiveLiterals, envSecrets, knownDocNames });
771
+ for (const l of r.leaks) leaks.push({ label: `doc:${l.label}`, sample: l.sample });
772
+ if (r.secret) leaks.push({ label: 'doc:secret-shape' });
773
+ const text = r.buf.toString('utf8');
774
+ if (text !== e.text) redacted++;
775
+ return e.id ? { id: e.id, text } : { text };
776
+ });
777
+ }
778
+ const bounded = boundReleaseNotes(clean, maxBytes);
779
+ const summaries = Object.values(bounded.notes).reduce((n, list) => n + list.length, 0);
780
+ return { buf: serializeReleaseNotes(bounded.notes), notes: bounded.notes, dropped: bounded.dropped, summaries, redacted, leaks };
781
+ }
782
+
737
783
  // Self-verify the built artifact (task 1002218 / audit H3, findings F1+F5). The packager
738
784
  // claims a consumer "verifies an install by recomputing [the pin] from the unpacked tree" —
739
785
  // this proves the packager's OWN output actually does, at build time, on the produce side:
@@ -964,6 +1010,29 @@ function main() {
964
1010
  staged.push({ name: TAR_ROOT + relPath, mode: 0o644, buf });
965
1011
  }
966
1012
 
1013
+ // 4b. The release notes (task 1004218): what each release brought in, generated from this
1014
+ // ref's history, redacted as a doc, and staged into the same arrays so the pin covers
1015
+ // them. A worktree build or a shallow clone has no history to read, so it ships without
1016
+ // — and an upgrade onto it says its notes are unavailable rather than listing nothing.
1017
+ const notesGen = fromWorktree
1018
+ ? { skipped: 'built from the working tree, which carries no history' }
1019
+ : generateReleaseNotes({ git, ref: sha, version });
1020
+ let notesStaged = false;
1021
+ if (notesGen.skipped) {
1022
+ console.error(`[package-core] ! ${RELEASE_NOTES_FILE} not generated — ${notesGen.skipped}.`);
1023
+ } else {
1024
+ const art = releaseNotesArtifact(notesGen.notes, { rules, sensitiveLiterals, envSecrets, knownDocNames });
1025
+ for (const l of art.leaks) leaks.push({ path: RELEASE_NOTES_FILE, label: l.label, sample: l.sample });
1026
+ filesMeta.push({ path: RELEASE_NOTES_FILE, mode: '100644', sha256: sha256hex(art.buf) });
1027
+ staged.push({ name: TAR_ROOT + RELEASE_NOTES_FILE, mode: 0o644, buf: art.buf });
1028
+ notesStaged = true;
1029
+ const kept = Object.keys(art.notes);
1030
+ const trimmed = art.dropped.length
1031
+ ? ` — the ${art.dropped.length} oldest release(s) DROPPED to stay under ${RELEASE_NOTES_MAX_BYTES / 1024} KiB`
1032
+ : '';
1033
+ console.error(`[package-core] ${RELEASE_NOTES_FILE}: ${kept.length} release(s) ${kept[0]} → ${kept[kept.length - 1]}, ${art.summaries} summaries (${art.redacted} redacted), ${(art.buf.length / 1024).toFixed(1)} KiB${trimmed}.`);
1034
+ }
1035
+
967
1036
  if (leaks.length > 0) {
968
1037
  console.error(`\n[package-core] NO-LEAK GATE FAILED — ${leaks.length} real leak(s) (doc redaction residue or a live credential in a functional file). PRODUCING NOTHING (fail-closed):`);
969
1038
  for (const l of leaks.slice(0, 50)) {
@@ -977,7 +1046,7 @@ function main() {
977
1046
  console.error(' for a live-credential leak, remove the secret at SOURCE (it should never be committed). Then re-run. dist/ was NOT touched.');
978
1047
  process.exit(1);
979
1048
  }
980
- console.error(`[package-core] no-leak gate PASSED — ${staged.length} files clean (${docCount} docs redacted, ${stubCount} agent-docs stubbed, ${funcCount} functional verbatim).`);
1049
+ console.error(`[package-core] no-leak gate PASSED — ${staged.length} files clean (${docCount} docs redacted, ${stubCount} agent-docs stubbed, ${funcCount} functional verbatim${notesStaged ? `, ${RELEASE_NOTES_FILE} generated` : ''}).`);
981
1050
 
982
1051
  // Advisory: brand-identity + public-IP literals that ship VERBATIM inside functional
983
1052
  // files. Not a leak (these are public, not secrets; a downstream rebrands via config) —
@@ -1139,6 +1208,8 @@ module.exports = {
1139
1208
  PRIOR_INSTANCE_FLOOR,
1140
1209
  // task 1003995 — --from-worktree's enumerator, for a core checkout with no git history
1141
1210
  walkWorktreeEntries,
1211
+ // task 1004218 — release-notes.json: the history-derived file, redacted as a doc and bounded
1212
+ releaseNotesArtifact,
1142
1213
  };
1143
1214
 
1144
1215
  if (require.main === module) main();
@@ -281,6 +281,10 @@ const CARDS = Object.freeze({
281
281
  'Your work touches a protected part of the project that your rank may not change, so the server would not accept it.',
282
282
  'Asking again will be refused again. I will take the protected files out and hand in the rest, or we pass the task to someone with the rank.',
283
283
  'A builder with a high enough rank, for the protected part.', RULE),
284
+ grade_unbacked_score: card(
285
+ 'One of the automatic reviewers gave your work a low mark without saying what is wrong, so there is nothing listed to fix. It is not accepted yet, but it was not turned down for a named problem either.',
286
+ 'I will ask for the review again. If the low mark comes back still without a reason, I will ask for your work to be accepted by hand, with an explanation.',
287
+ 'Only if it keeps happening — an Archon decides whether to accept it by hand.', FAULT),
284
288
  grade_failed: card(
285
289
  'The automatic reviewers found problems in your work, so it is not accepted yet.',
286
290
  'I will fix what they listed, save it, and ask for the review again. That costs nothing extra.',
@@ -28,6 +28,7 @@
28
28
  const nodeFs = require('node:fs');
29
29
  const nodePath = require('node:path');
30
30
  const { spawnSync } = require('node:child_process');
31
+ const { resolveEnv, envPrefix, LEGACY_ENV_PREFIXES } = require('../../src/instance-config');
31
32
 
32
33
  const REPO_ROOT = nodePath.resolve(__dirname, '..', '..');
33
34
  const MIN_NODE_MAJOR = 22;
@@ -155,9 +156,16 @@ function preflightChecks({
155
156
  // /builder-start caller via depsFinding() (task 1002403).
156
157
  out.push(depsFinding({ repoRoot, fs }));
157
158
 
158
- // 3. `claude` CLI launchable — same resolution order the grader uses.
159
- const bin = env.OTB_CLAUDE_BIN || env.CLAUDE_BIN || 'claude';
160
- const via = env.OTB_CLAUDE_BIN ? ' (via OTB_CLAUDE_BIN)' : env.CLAUDE_BIN ? ' (via CLAUDE_BIN)' : '';
159
+ // 3. `claude` CLI launchable — same resolution order the grader uses:
160
+ // <PREFIX>_CLAUDE_BIN (legacy spellings still read), then CLAUDE_BIN, then `claude`.
161
+ const prefixed = resolveEnv('CLAUDE_BIN', { env });
162
+ const bin = prefixed || env.CLAUDE_BIN || 'claude';
163
+ // The label names the spelling that actually supplied the value, so a builder who
164
+ // set the legacy OTB_CLAUDE_BIN is told so rather than shown a name they never set.
165
+ const viaName = prefixed
166
+ ? [envPrefix(), ...LEGACY_ENV_PREFIXES].map((p) => `${p}_CLAUDE_BIN`).find((n) => env[n] === prefixed)
167
+ : env.CLAUDE_BIN ? 'CLAUDE_BIN' : null;
168
+ const via = viaName ? ` (via ${viaName})` : '';
161
169
  let r;
162
170
  try {
163
171
  r = spawn(bin, ['--version'], { encoding: 'utf8', shell: platform === 'win32', windowsHide: true, timeout: 15000 });
@@ -174,7 +182,7 @@ function preflightChecks({
174
182
  level: 'fail',
175
183
  title: 'claude CLI',
176
184
  detail: `not launchable (${bin}: ${why}) — the grader spawns it per worker, so ships fail grading with SUBAGENT_SPAWN_FAILED.`,
177
- fix: 'Run `npm i -g @anthropic-ai/claude-code` (puts a real claude on PATH), or set OTB_CLAUDE_BIN to its full path.',
185
+ fix: 'Run `npm i -g @anthropic-ai/claude-code` (puts a real claude on PATH), or set ' + envPrefix() + '_CLAUDE_BIN to its full path.',
178
186
  });
179
187
  }
180
188
 
@@ -28,10 +28,15 @@
28
28
  // Its own file because scripts/gds/provision.js sits at the 1500-line oversized-file
29
29
  // ratchet, the same reason provision-net.js and provision-units.js were carved out.
30
30
 
31
+ const crypto = require('crypto');
32
+ const fs = require('fs');
33
+ const os = require('os');
31
34
  const path = require('path');
35
+ const { spawnSync } = require('child_process');
32
36
  const { CONFIG, MANIFEST_VERIFY_INTERVAL_MS, MANIFEST_VERIFY_TRIES, REPO_ROOT } = require('./provision-config.js');
33
- const { appUser, dbName, instanceUser, selfInstanceRoot, standaloneRoot } = require('./provision-repo.js');
37
+ const { appUser, dbName, instanceUser, parseTargetRef, resolveOwnerGithubToken, selfInstanceRoot, standaloneRoot } = require('./provision-repo.js');
34
38
  const { isValidSlug } = require('../../modules/provisioning/provisioning.js');
39
+ const { RELEASE_NOTES_FILE, RELEASE_NOTES_MAX_BYTES, parseChangeRecords, notesBetween } = require('./release-notes.js');
35
40
 
36
41
  // The shapes whose box this runner can actually reach. Mirrors UPGRADE_SHAPES in the
37
42
  // web-tier half, and deliberately EXCLUDES 'dedicated': a dedicated droplet is stood up
@@ -269,14 +274,161 @@ async function coreUpgradeInstance(inst, deps, intent) {
269
274
  // reaching here means the two disagree. Report what was SEEN rather than a verdict.
270
275
  return { ok: false, error: `the box is serving core ${served}, not the requested ${to} — the move did not take` };
271
276
  }
277
+
278
+ // ---- 5. the pin reaches the project's own repo, pushed from HERE (task 1004065) ----
279
+ // Only now: a pin worth saving is one the box is serving. upgrade.js was told
280
+ // --pin-no-push for this shape, so it committed and stopped; see pushUpgradePin.
281
+ const repo = hubPushesPin(inst) ? await pushUpgradePin(inst, deps, { instanceDir }) : null;
282
+ const unsaved = repo && !repo.pushed ? `; GitHub was not updated (${repo.code})` : '';
272
283
  await provisioning.recordEvent(db, {
273
284
  instanceId: inst.id, ownerBuilderId: inst.owner_builder_id, event: 'core-upgraded',
274
- detail: served ? `core pin moved to ${served}` : `core pin moved to ${to}; the box did not answer /version, so this is unconfirmed`,
285
+ detail: (served ? `core pin moved to ${served}` : `core pin moved to ${to}; the box did not answer /version, so this is unconfirmed`) + unsaved,
275
286
  actor: intent.requested_by || 'runner',
276
287
  }).catch(() => {});
277
288
  if (provisioning.clearErrorNote) await provisioning.clearErrorNote(db, inst.id, 'core-upgrade').catch(() => {});
278
289
  log(served ? ` ✓ ${inst.slug} serves core ${served}` : ` ⚠ ${inst.slug} upgraded to ${to}, but did not answer /version — unconfirmed`);
279
- return { ok: true, served, confirmed: served === to };
290
+ // `doneNote`: the drain loop resolves the intent DONE and stores this beside it, which
291
+ // is how the deploy page learns the upgrade landed but GitHub did not get it.
292
+ return { ok: true, served, confirmed: served === to, repo, doneNote: repo && !repo.pushed ? repo.note : null };
293
+ }
294
+
295
+ // ── the pin reaches the project's GitHub repo, pushed from the HUB (task 1004065) ──
296
+ //
297
+ // THE BUG THIS EXISTS FOR. upgrade.js commits the new pin on the box and then pushes it.
298
+ // On a hosted project it runs connected to the PROJECT's database — it has to, for
299
+ // migrations — while the owner's GitHub token lives in the HUB's. Its token lookup found
300
+ // no provisioning table there (ADR 0281), fell back to `git push origin`, and origin is a
301
+ // read-only deploy key (ADR 0176). GitHub refused, the refusal reached only the journal
302
+ // and named the wrong cause, and the intent resolved `done`. The box ran the new core,
303
+ // GitHub still pinned the old one, and the box's local commit stopped its fast-forward
304
+ // pulls from taking the owner's new work: hermeslines-marketing served 1.19.994 against
305
+ // a repo pinning 1.19.881, and pages pushed to that repo 404'd on the live site.
306
+ //
307
+ // WHY THE RUNNER PUSHES rather than handing upgrade.js the token. This process already
308
+ // holds the hub's database, where the token is, and it runs as the account that owns the
309
+ // checkout (the project's own account only READS it — grantInstanceRepoReadCmd). Passing
310
+ // the token into the upgrade would put a credential that can write the repo inside the
311
+ // project's own process. So the move passes --pin-no-push, upgrade.js commits, and the
312
+ // push happens here, in the shape refreshStandaloneCorePin already uses for this repo.
313
+ //
314
+ // A FAILED PUSH IS NOT A FAILED UPGRADE. The site runs the new core, and rolling a good
315
+ // deploy back would be worse than the split. The move still resolves `done`, carrying a
316
+ // note the deploy page reads: `repo_not_updated:<code>: <sentence>`, parsed by
317
+ // parseRepoNote in modules/provisioning/core-upgrade.js (re-stated there because a
318
+ // module may not require a script; tests/core_upgrade_runner.mjs round-trips the two).
319
+ //
320
+ // NOTHING IS FORCED OR REBASED. When GitHub has commits the box does not, putting the pin
321
+ // on top of them would change what the live box runs with no restart and no health check.
322
+ // That is reported for an operator to reconcile, never resolved here.
323
+ //
324
+ // THE LIMIT, stated so nobody mistakes this for durable: the owner's token is kept for an
325
+ // hour after they sign in with repository access (GITHUB_TOKEN_TTL_MS in src/bongos/db.js
326
+ // — one wizard sitting). An upgrade days later usually finds none; the box's own remote
327
+ // is then tried, and it can only read a private repo. The note says so in words.
328
+
329
+ const REPO_NOTE_PREFIX = 'repo_not_updated';
330
+ const REPO_NOTES = Object.freeze({
331
+ no_repo: 'This project has no GitHub repository on record, so the new version was saved on the server only.',
332
+ no_branch: 'The server\'s copy of the project is not on a branch, so there was nothing to save to GitHub.',
333
+ no_access: 'The hub had no permission to write to the project\'s GitHub repository: the owner\'s GitHub access is only kept for an hour after they connect GitHub, and the server\'s own key can only read the repository.',
334
+ refused: 'GitHub turned down the owner\'s GitHub access — it may have expired, or lack permission to write to the repository.',
335
+ behind: 'GitHub has changes the server does not have, so the new version could not be added on top of them. An operator needs to bring the two back in line; the site itself is running normally.',
336
+ failed: 'The hub could not save the new version to GitHub.',
337
+ });
338
+ // A branch name that is safe to interpolate. Narrower than git's own ref grammar on
339
+ // purpose: a name outside it is refused, never quoted.
340
+ const BRANCH_RE = /^[A-Za-z0-9][A-Za-z0-9._/-]{0,99}$/;
341
+
342
+ /** The note a done-but-unsaved move carries, bounded for the DOM it reaches. PURE. */
343
+ function repoNote(code, extra = '') {
344
+ const known = Object.prototype.hasOwnProperty.call(REPO_NOTES, code) ? code : 'failed';
345
+ return `${REPO_NOTE_PREFIX}:${known}: ${REPO_NOTES[known]}${extra ? ` ${extra}` : ''}`.slice(0, 600);
346
+ }
347
+
348
+ // Which shapes get their pin pushed from here: a hosted project, whose repo is the
349
+ // owner's own. The platform's own row pushes to a repo its key CAN write, and a
350
+ // co-tenant shares the platform's checkout, so neither changes.
351
+ function hubPushesPin(inst) { return !!inst && inst.hosting_shape === 'cloud-host'; }
352
+
353
+ // Why a push did not happen, from what git said. `behind` is checked first: a rejected
354
+ // update means the credential WORKED and the history is what stopped it.
355
+ function pushFault(r) {
356
+ const said = `${(r && r.stdout) || ''}\n${(r && r.stderr) || ''}`;
357
+ if (/\[rejected\]|non-fast-forward|fetch first|Updates were rejected/i.test(said)) return 'behind';
358
+ if (/\b403\b|Authentication failed|Invalid username or password|Write access to repository not granted|Permission to \S+ denied|marked as read only|Permission denied \(publickey\)|could not read Username/i.test(said)) return 'refused';
359
+ return 'failed';
360
+ }
361
+
362
+ // GitHub's own line beats git's closing `fatal:`, which names no reason (upgrade.js
363
+ // gitSaid has the same preference).
364
+ function gitLine(r) {
365
+ const lines = String((r && r.stderr) || '').split('\n').map((l) => l.trim()).filter(Boolean);
366
+ const server = lines.filter((l) => /^(ERROR:|remote:|!)/i.test(l));
367
+ const said = server.length ? server[server.length - 1] : lines[lines.length - 1];
368
+ return said ? `(git: ${said.slice(0, 200)})` : '';
369
+ }
370
+
371
+ /**
372
+ * Push a confirmed upgrade's committed pin to the project's repo. NEVER THROWS: every
373
+ * miss returns `{ pushed:false, code, note }`, because the drain loop turns a throw into
374
+ * a broken instance and this instance is running fine.
375
+ *
376
+ * The token rides a chmod-600 credential file that is removed whatever the push did —
377
+ * never argv (ps, the journal), never a log line. The helper list is reset first
378
+ * (helpers are cumulative, so a global one would answer with some other account) and
379
+ * hooks are off, so nothing in the project's checkout runs as this account.
380
+ */
381
+ async function pushUpgradePin(inst, deps, { instanceDir }) {
382
+ const log = deps.log || (() => {});
383
+ // The CONTROL PLANE's executor and writer: the checkout, the hub db and the token are
384
+ // all here, never on a remote co-hosting box (refreshStandaloneCorePin's routing).
385
+ const exec = deps.controlExec || deps.exec;
386
+ const writeFile = deps.controlWriteFile || deps.writeFile || ((p, body, o) => fs.writeFileSync(p, body, o));
387
+ const failedRun = (r) => !r || r.softFailed === true || r.ok === false;
388
+ const miss = (code, extra = '') => {
389
+ log(` ⚠ ${inst.slug}: GitHub was not updated — ${REPO_NOTES[code] || REPO_NOTES.failed}${extra ? ` ${extra}` : ''}`);
390
+ return { pushed: false, code, note: repoNote(code, extra) };
391
+ };
392
+ const repo = parseTargetRef(inst.target_ref);
393
+ if (!repo) return miss('no_repo');
394
+ const head = exec(`git -C ${instanceDir} symbolic-ref --short HEAD`, { allowFail: true });
395
+ const branch = failedRun(head) ? '' : String(head.stdout || '').trim();
396
+ if (!BRANCH_RE.test(branch)) return miss('no_branch');
397
+ const at = `${repo.owner}/${repo.name}@${branch}`;
398
+ const hardened = `git -C ${instanceDir} -c core.hooksPath=/dev/null`;
399
+ const env = { GIT_TERMINAL_PROMPT: '0' }; // a refused credential fails; it never waits at a prompt
400
+
401
+ const token = await resolveOwnerGithubToken(inst, deps, { tag: '[core-upgrade]' });
402
+ if (token) {
403
+ const credFile = path.join(os.tmpdir(), `bongos-gitcred-upgrade-${inst.slug}-${process.pid}-${crypto.randomBytes(6).toString('hex')}`);
404
+ let r = null;
405
+ try {
406
+ writeFile(credFile, `https://x-access-token:${token}@github.com\n`, { mode: 0o600 });
407
+ r = exec(`${hardened} -c credential.helper= -c credential.helper='store --file=${credFile}' push https://x-access-token@github.com/${repo.owner}/${repo.name}.git HEAD:${branch}`,
408
+ { allowFail: true, env });
409
+ } catch (e) {
410
+ return miss('failed', `(could not stage the owner's credential: ${String((e && e.message) || e).slice(0, 160)})`);
411
+ } finally {
412
+ exec(`rm -f ${credFile}`, { allowFail: true });
413
+ }
414
+ if (!failedRun(r)) {
415
+ log(` ✓ pushed the pin to ${at} from the hub, over the owner's GitHub token`);
416
+ return { pushed: true, via: 'owner-token', branch };
417
+ }
418
+ return miss(pushFault(r), gitLine(r));
419
+ }
420
+
421
+ // No token stored — which is the ordinary case (see THE LIMIT above). The box's own
422
+ // remote is still tried: a checkout wired with a key that can write is not wrong to use.
423
+ const r = exec(`${hardened} push origin HEAD:${branch}`, { allowFail: true, env });
424
+ if (!failedRun(r)) {
425
+ log(` ✓ pushed the pin to ${at} from the hub, over the checkout's own remote`);
426
+ return { pushed: true, via: 'origin', branch };
427
+ }
428
+ const fault = pushFault(r);
429
+ // A refusal here is not the owner's access being turned down — there was none to turn
430
+ // down. It is the read-only key, which is the no-access case.
431
+ return miss(fault === 'refused' ? 'no_access' : fault, gitLine(r));
280
432
  }
281
433
 
282
434
  // The pre-flight lines a dry run emits, and the refusal when it has one. upgrade.js writes
@@ -400,6 +552,9 @@ function upgradeInvocation(inst, to, { privileged = false, dryRun = false, reque
400
552
  `--health-url http://127.0.0.1:${inst.port}/healthz`,
401
553
  `--version-url http://127.0.0.1:${inst.port}/version`,
402
554
  '--commit-pin',
555
+ // A hosted project's pin is pushed by the RUNNER, which can reach the owner's
556
+ // token; the upgrade process cannot (task 1004065, pushUpgradePin).
557
+ hubPushesPin(inst) ? '--pin-no-push' : '',
403
558
  requestedBy ? `--requested-by ${requestedBy}` : '',
404
559
  requestedBy ? '--upgrade-source owner-control' : ''];
405
560
  return { cmd: [...shared, ...tail].filter(Boolean).join(' '), runDir: REPO_ROOT, instanceDir, runAs: account };
@@ -452,74 +607,101 @@ function preflightUpgrade(inst, to, deps) {
452
607
  // requires one and posts it to Discord. So this reads the merge summaries between the two
453
608
  // releases rather than inventing a changelog nobody would maintain.
454
609
  //
455
- // IT READS THE CONTROL PLANE'S OWN CORE CHECKOUT, ALWAYS, whichever instance is being
456
- // previewed. "What changed in the core between 1.19.881 and 1.19.972" is a question about
457
- // the CORE repository, and a tenant's instance directory is a checkout of the TENANT's
458
- // repo with the core vendored in from npm — no core history in it at all, and the wrong
459
- // history if you squint. The control plane is the machine that builds the core, so it is
460
- // the machine that can answer.
610
+ // IT READS THE NOTES THE CORE PACKAGE CARRIES, and a core checkout only as a fallback
611
+ // (task 1004218). "What changed in the core between 1.19.881 and 1.19.972" is a question
612
+ // about the CORE repository, and no tenant box will ever hold its history — every project
613
+ // is a checkout of its OWN repo with the core vendored in from npm. So the packer writes
614
+ // the summaries down once, from the full history CI packs with (release-notes.json, built
615
+ // by scripts/gds/release-notes.js), and any machine that can download a core version can
616
+ // read what it carries. The checkout path below is kept for a machine that has one.
461
617
  const CHANGE_CAP = 20;
462
- const CHANGE_TEXT_MAX = 400;
618
+ // One registry download of a core tarball. The preview's owner is waiting on the dry run
619
+ // beside it anyway, but a hung registry must not hold the runner forever.
620
+ const NOTES_FETCH_TIMEOUT_MS = 60000;
621
+
622
+ function runFault(r) {
623
+ if (!r) return 'no answer';
624
+ if (r.error) return String(r.error.message || r.error).slice(0, 160);
625
+ const tail = String(r.stderr || '').trim().split('\n').filter(Boolean).slice(-1)[0] || '';
626
+ return (tail || `exit ${r.status}`).slice(0, 160);
627
+ }
463
628
 
464
629
  /**
465
- * The merge summaries in a `git log --merges --format=%s%x1f%b%x1e` dump. PURE.
630
+ * The release notes the core THIS runner executes from carries, if any. Free to read, and
631
+ * enough on its own whenever the target is at or below this core's own version — a tenant
632
+ * catching up to where the platform already is.
633
+ */
634
+ function readInstalledNotes(deps = {}) {
635
+ const fsImpl = deps.fs || fs;
636
+ let raw;
637
+ try { raw = fsImpl.readFileSync(path.join(REPO_ROOT, RELEASE_NOTES_FILE), 'utf8'); }
638
+ catch { return { missing: 'this core carries no release notes' }; }
639
+ try { return { notes: JSON.parse(raw) }; }
640
+ catch { return { error: "this core's release notes are not valid JSON" }; }
641
+ }
642
+
643
+ /**
644
+ * The release notes inside ONE published core package — the TARGET's own (task 1004218).
466
645
  *
467
- * TWO MERGE SHAPES CARRY A SUMMARY AND THE SECOND IS THE COMMON ONE. A hand-landed ship
468
- * writes `Merge <branch>: <summary>`; the CI auto-merge writes `Merge pull request #N
469
- * from <owner>/<branch>` with `task <id>: <summary>` in the BODY. Measured over
470
- * 1.19.881→1.19.972: 114 usable summaries, of which 98 were the second shape. A parser
471
- * that read subjects only would have found eleven of them and quietly called that the
472
- * release.
646
+ * WHY THE TARGET'S. The installed core's notes stop at its own version, which is zero
647
+ * coverage for the move looked at most — the platform's own, onto a release newer than
648
+ * itself. The target's package is the one guaranteed to reach the target, because that is
649
+ * where the target was packed. `npm pack` downloads it (the core is public, so no
650
+ * credential — the same registry listAvailableVersions reads) and `tar -xzOf` reads the one
651
+ * file out of it rather than unpacking the rest.
473
652
  *
474
- * EVERYTHING ELSE IS DROPPED, and the drops are the majority: `Merge origin/main into
475
- * <branch> (pre-stage freshness…)`, `Merge remote-tracking branch…`, a regen chore. Those
476
- * are the mechanics of landing work, not the work, and listing them would bury the
477
- * eleven sentences the owner actually wants under a hundred they do not.
653
+ * Returns `{ notes }`, `{ missing }` when the package carries none (every release published
654
+ * before the notes were packaged), or `{ error }`. The download never outlives the call.
478
655
  */
479
- function parseChangeRecords(stdout) {
480
- const out = [];
481
- const seen = new Set();
482
- for (const rec of String(stdout || '').split('\x1e')) {
483
- const r = rec.trim();
484
- if (!r) continue;
485
- const i = r.indexOf('\x1f');
486
- const subject = (i < 0 ? r : r.slice(0, i)).trim();
487
- const body = i < 0 ? '' : r.slice(i + 1);
488
- let text = null;
489
- let id = null;
490
- // The negative lookahead is what keeps the two housekeeping shapes out: both begin
491
- // "Merge " and both would otherwise match a `<token>:` somewhere later in the line.
492
- let m = /^Merge\s+(?!pull request\b|remote-tracking\b)([^\s:]+):\s*(\S.*)$/.exec(subject);
493
- if (m) {
494
- text = m[2];
495
- } else {
496
- const first = body.split('\n').map((l) => l.trim()).find(Boolean) || '';
497
- m = /^task\s+(\d+):\s*(\S.*)$/.exec(first);
498
- if (m) { id = m[1]; text = m[2]; }
656
+ function readPackagedNotes(version, deps = {}) {
657
+ if (!CORE_VERSION_RE.test(String(version || ''))) return { error: 'could not work out which package to read' };
658
+ const run = deps.spawn || spawnSync;
659
+ const fsImpl = deps.fs || fs;
660
+ let dir = null;
661
+ try {
662
+ dir = fsImpl.mkdtempSync(path.join(deps.tmpDir || os.tmpdir(), 'bongos-release-notes-'));
663
+ const packed = run('npm', ['pack', `@bongos/core@${version}`, '--pack-destination', dir, '--silent'],
664
+ { cwd: dir, env: deps.env || process.env, encoding: 'utf8', timeout: NOTES_FETCH_TIMEOUT_MS });
665
+ if (!packed || packed.error || packed.status !== 0) {
666
+ return { error: `could not download the core ${version} package to read its release notes (${runFault(packed)})` };
499
667
  }
500
- if (!text) continue;
501
- // It reaches a DOM, and a ship summary is occasionally a paragraph.
502
- text = text.length > CHANGE_TEXT_MAX ? `${text.slice(0, CHANGE_TEXT_MAX - 1)}…` : text;
503
- if (seen.has(text)) continue; // a branch merged twice says its sentence once
504
- seen.add(text);
505
- out.push(id ? { id, text } : { text });
668
+ // Whatever npm named it: a scoped package's tarball is <scope>-<name>-<version>.tgz today.
669
+ const tgz = fsImpl.readdirSync(dir).find((f) => f.endsWith('.tgz'));
670
+ if (!tgz) return { error: `npm reported the core ${version} package downloaded, but left no tarball` };
671
+ const x = run('tar', ['-xzOf', path.join(dir, tgz), `package/${RELEASE_NOTES_FILE}`],
672
+ { encoding: 'utf8', maxBuffer: 4 * RELEASE_NOTES_MAX_BYTES, timeout: NOTES_FETCH_TIMEOUT_MS });
673
+ if (!x || x.error || x.status !== 0) {
674
+ if (/not found in archive/i.test(String((x && x.stderr) || ''))) {
675
+ return { missing: `the core ${version} package carries no release notes` };
676
+ }
677
+ return { error: `could not read the release notes out of the core ${version} package (${runFault(x)})` };
678
+ }
679
+ try { return { notes: JSON.parse(x.stdout) }; }
680
+ catch { return { error: `the release notes in the core ${version} package are not valid JSON` }; }
681
+ } catch (e) {
682
+ return { error: `could not fetch the core ${version} package (${String((e && e.message) || e).slice(0, 160)})` };
683
+ } finally {
684
+ if (dir) { try { fsImpl.rmSync(dir, { recursive: true, force: true }); } catch { /* best-effort */ } }
506
685
  }
507
- return out;
508
686
  }
509
687
 
510
688
  /**
511
- * What landed between two core releases, as the summaries their ships wrote.
512
- *
513
- * Returns `{ from, to, entries, more }`, or `{ from, to, error }` — NEVER an empty
514
- * `entries` for a range it could not resolve. An empty list reads as "nothing changed",
515
- * which on a page whose job is to say what a restart buys is the one wrong impression it
516
- * must not give, and it is exactly what a missing checkout or a shallow clone produces.
517
- *
518
- * Every version reaching a shell is re-checked against the strict three-number form
519
- * FIRST. They arrive from an intent row whose column has the same CHECK, but a value that
520
- * is interpolated into a command is checked where it is interpolated — the rule
521
- * upgradeInvocation states about the slug.
689
+ * The range, answered from release notes alone: `{ entries }`, or `{ why }` they cannot.
690
+ * The installed core first, when its notes already reach the target; the target's own
691
+ * package otherwise. No git, no SHAs, no checkout.
522
692
  */
693
+ function changesFromNotes(fromVersion, toVersion, deps) {
694
+ const installed = (deps.readInstalledNotes || readInstalledNotes)(deps);
695
+ if (installed.notes) {
696
+ const r = notesBetween(installed.notes, fromVersion, toVersion);
697
+ if (r.entries) return r;
698
+ }
699
+ const target = (deps.readPackagedNotes || readPackagedNotes)(toVersion, deps);
700
+ if (!target.notes) return { why: target.missing || target.error || `the core ${toVersion} package's release notes could not be read` };
701
+ const r = notesBetween(target.notes, fromVersion, toVersion);
702
+ return r.entries ? r : { why: `the release notes in the core ${toVersion} package ${r.uncovered}` };
703
+ }
704
+
523
705
  /**
524
706
  * Classify a git command that did not succeed. PURE.
525
707
  *
@@ -598,21 +780,11 @@ function findCoreCheckout(deps, git) {
598
780
  }
599
781
 
600
782
  /**
601
- * What landed between two core releases, as the summaries their ships wrote.
602
- *
603
- * Returns `{ from, to, entries, more }`, or `{ from, to, error }` — NEVER an empty
604
- * `entries` for a range it could not resolve. An empty list reads as "nothing changed",
605
- * which on a page whose job is to say what a restart buys is the one wrong impression it
606
- * must not give, and it is exactly what a missing checkout produces.
607
- *
608
- * Every version reaching a shell is re-checked against the strict three-number form
609
- * FIRST. They arrive from an intent row whose column has the same CHECK, but a value that
610
- * is interpolated into a command is checked where it is interpolated — the rule
611
- * upgradeInvocation states about the slug.
783
+ * The range, answered from a core CHECKOUT's git history: `{ entries }` or `{ error }`.
784
+ * The fallback for a machine that has one, since the package's notes only exist for
785
+ * releases published after they were introduced (task 1004218).
612
786
  */
613
- function changesBetween(fromVersion, toVersion, deps) {
614
- const bad = [fromVersion, toVersion].find((v) => !CORE_VERSION_RE.test(String(v || '')));
615
- if (bad !== undefined) return { from: fromVersion || null, to: toVersion || null, error: 'could not work out which releases this covers' };
787
+ function changesFromCheckout(fromVersion, toVersion, deps) {
616
788
  // The CONTROL PLANE's executor, never the previewed instance's: under
617
789
  // PROVISION_REMOTE_HOST `exec` is the remote box, while the core history — and the whole
618
790
  // question of what changed in the CORE — belongs to the machine that builds it.
@@ -635,7 +807,7 @@ function changesBetween(fromVersion, toVersion, deps) {
635
807
  };
636
808
  };
637
809
  const found = findCoreCheckout(deps, git);
638
- if (found.error) return { from: fromVersion, to: toVersion, error: found.error };
810
+ if (found.error) return { error: found.error };
639
811
  const dir = found.dir;
640
812
  const shaOf = (v) => {
641
813
  const r = git(dir, `log --format=%H --max-count=1 -F --grep='release: core ${v} '`);
@@ -645,18 +817,48 @@ function changesBetween(fromVersion, toVersion, deps) {
645
817
  };
646
818
  const from = shaOf(fromVersion);
647
819
  const to = shaOf(toVersion);
648
- if (from.fault || to.fault) return { from: fromVersion, to: toVersion, error: from.fault || to.fault };
820
+ if (from.fault || to.fault) return { error: from.fault || to.fault };
649
821
  // LOOKED, AND DID NOT FIND — a different sentence from "could not look", and it names
650
822
  // WHICH version is missing so the reader can tell a not-yet-published target from a
651
823
  // checkout that has not been fetched.
652
824
  const missing = [!from.sha && fromVersion, !to.sha && toVersion].filter(Boolean);
653
825
  if (missing.length) {
654
- return { from: fromVersion, to: toVersion, error: `core ${missing.join(' and ')} ${missing.length > 1 ? 'are' : 'is'} not in the core history on this machine yet` };
826
+ return { error: `core ${missing.join(' and ')} ${missing.length > 1 ? 'are' : 'is'} not in the core history on this machine yet` };
655
827
  }
656
828
  const r = git(dir, `log --merges --format=%s%x1f%b%x1e ${from.sha}..${to.sha}`);
657
- if (!r.ok) return { from: fromVersion, to: toVersion, error: gitFault(r, dir) };
658
- const all = parseChangeRecords(r.stdout);
659
- return { from: fromVersion, to: toVersion, entries: all.slice(0, CHANGE_CAP), more: Math.max(0, all.length - CHANGE_CAP) };
829
+ if (!r.ok) return { error: gitFault(r, dir) };
830
+ return { entries: parseChangeRecords(r.stdout) };
831
+ }
832
+
833
+ /**
834
+ * What landed between two core releases, as the summaries their ships wrote.
835
+ *
836
+ * Returns `{ from, to, entries, more, source }`, or `{ from, to, error }` — NEVER an empty
837
+ * `entries` for a range it could not resolve. An empty list reads as "nothing changed",
838
+ * which on a page whose job is to say what a restart buys is the one wrong impression it
839
+ * must not give, and it is exactly what a missing checkout produces.
840
+ *
841
+ * THE PACKAGE'S NOTES FIRST, THE CHECKOUT SECOND (task 1004218). The notes answer on every
842
+ * box; the checkout only where one exists, which is never a tenant. When neither can, the
843
+ * error carries BOTH reasons — the notes' and the checkout's own precise sentence (task
844
+ * 1004212) — so the reader is told everything that was tried and why each fell short.
845
+ *
846
+ * Every version reaching a shell is re-checked against the strict three-number form
847
+ * FIRST. They arrive from an intent row whose column has the same CHECK, but a value that
848
+ * is interpolated into a command is checked where it is interpolated — the rule
849
+ * upgradeInvocation states about the slug.
850
+ */
851
+ function changesBetween(fromVersion, toVersion, deps) {
852
+ const bad = [fromVersion, toVersion].find((v) => !CORE_VERSION_RE.test(String(v || '')));
853
+ if (bad !== undefined) return { from: fromVersion || null, to: toVersion || null, error: 'could not work out which releases this covers' };
854
+ const listed = (all, source) => ({
855
+ from: fromVersion, to: toVersion, entries: all.slice(0, CHANGE_CAP), more: Math.max(0, all.length - CHANGE_CAP), source,
856
+ });
857
+ const notes = changesFromNotes(fromVersion, toVersion, deps);
858
+ if (notes.entries) return listed(notes.entries, 'release-notes');
859
+ const checkout = changesFromCheckout(fromVersion, toVersion, deps);
860
+ if (checkout.entries) return listed(checkout.entries, 'checkout');
861
+ return { from: fromVersion, to: toVersion, error: `${notes.why}; ${checkout.error}` };
660
862
  }
661
863
 
662
864
  // Run a dry run for one instance and cache its verdict — the ON-DEMAND leg, drained from
@@ -780,4 +982,4 @@ async function snapshotAllCoreVersions(deps) {
780
982
  return { seen };
781
983
  }
782
984
 
783
- module.exports = { UPGRADE_SHAPES, CHANGE_CAP, parseChangeRecords, changesBetween, coreCheckoutCandidates, findCoreCheckout, gitFault, resolveRunAs, versionCmd, parseServedVersion, parseSchemaPending, targetFault, parsePreflight, upgradeInvocation, preflightUpgrade, preflightCoreUpgrade, coreUpgradeInstance, snapshotCoreVersions, snapshotAllCoreVersions };
985
+ module.exports = { UPGRADE_SHAPES, CHANGE_CAP, REPO_NOTES, repoNote, parseChangeRecords, readInstalledNotes, readPackagedNotes, changesBetween, coreCheckoutCandidates, findCoreCheckout, gitFault, resolveRunAs, versionCmd, parseServedVersion, parseSchemaPending, targetFault, parsePreflight, upgradeInvocation, preflightUpgrade, preflightCoreUpgrade, coreUpgradeInstance, snapshotCoreVersions, snapshotAllCoreVersions };