@bongos/core 1.19.748 → 1.19.749

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/.bongos-core.json CHANGED
@@ -2,11 +2,11 @@
2
2
  "artifact": "bongos-core",
3
3
  "manifest_schema": 1,
4
4
  "generator": "scripts/gds/package-core.js",
5
- "core_version": "1.19.748",
6
- "core_contract": "1.19.748",
7
- "source_commit": "c7239277baa5d7123d6e0d1a60749b6aba472f70",
5
+ "core_version": "1.19.749",
6
+ "core_contract": "1.19.749",
7
+ "source_commit": "238806532985c069a1b8a778e5a3d223cd6b01c4",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-15T21:40:07.956Z",
9
+ "built_at": "2026-09-15T22:00:53.285Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 482,
@@ -17,7 +17,7 @@
17
17
  "gate": "passed"
18
18
  },
19
19
  "file_count": 2616,
20
- "tree_sha256": "f52311a51ab40e147838cb38e88a7327f02df848a8bba552a509cc0185fcdc6b",
20
+ "tree_sha256": "ddc2e6cffdc346ee09a971dd7f237459cf8c142ad8cf058845f48638fa01a3e7",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/ask-for-help/SKILL.md",
@@ -2507,7 +2507,7 @@
2507
2507
  {
2508
2508
  "path": "docs/module-api-changelog.md",
2509
2509
  "mode": "0000644",
2510
- "sha256": "958174405a14b2dae06adc92ccbf638cb3289a38e68b8ebb9c6c314a1479ae40"
2510
+ "sha256": "207ce89a17ab5bd75cb956f9e41497ef39eb10d4f59eb634f0d1c1933304ced0"
2511
2511
  },
2512
2512
  {
2513
2513
  "path": "docs/modules-contract.md",
@@ -2767,7 +2767,7 @@
2767
2767
  {
2768
2768
  "path": "docs/recipes/upgrading-the-core.md",
2769
2769
  "mode": "0000644",
2770
- "sha256": "3691b017562bde760fbe3ff29a5efb80d886dd770f1187dc4673459871cec513"
2770
+ "sha256": "408b4792ffb869f2fa13201485c1ab5b8306b93996bb75f01b4117de3fd39303"
2771
2771
  },
2772
2772
  {
2773
2773
  "path": "docs/recipes/windows-builders.md",
@@ -7562,12 +7562,12 @@
7562
7562
  {
7563
7563
  "path": "package-lock.json",
7564
7564
  "mode": "0000644",
7565
- "sha256": "ad67710386b227895b4042eaad4f4461a4435433be5e1eafaf6970118b63a759"
7565
+ "sha256": "1cf816c4ca4b4b56d3fefe33e86e8ce4caa511ee69fd8dd6c2ecc2940dc07f7f"
7566
7566
  },
7567
7567
  {
7568
7568
  "path": "package.json",
7569
7569
  "mode": "0000644",
7570
- "sha256": "7ffeb06be165b48faa187f3faa8526b293bc6ccd467ecb6ac7a880036f805ad8"
7570
+ "sha256": "d890fe5b32e9b8158a19a40b765570972673f8558cb5d2e8e13ba396eee4476f"
7571
7571
  },
7572
7572
  {
7573
7573
  "path": "public-docs/index.html",
@@ -8927,7 +8927,7 @@
8927
8927
  {
8928
8928
  "path": "scripts/gds/upgrade.js",
8929
8929
  "mode": "0000644",
8930
- "sha256": "7a82fb0cf919e14700ceba718e28da1324df2eab669a847348271c85b2f7bd66"
8930
+ "sha256": "fe3abe00b43163eb1b24dd2da57d4c8fd51194e79bab8a6683355980787a9e8c"
8931
8931
  },
8932
8932
  {
8933
8933
  "path": "scripts/gds/validate-design.js",
@@ -9367,7 +9367,7 @@
9367
9367
  {
9368
9368
  "path": "src/module-api.js",
9369
9369
  "mode": "0000644",
9370
- "sha256": "fddd61204720aa0027e000d95f5933d756b3ed59befe634229c4492ae50afa5f"
9370
+ "sha256": "6c9cce81108782c099d58259ce776bed22f815f7276fa9fb9a50264aeedd3835"
9371
9371
  },
9372
9372
  {
9373
9373
  "path": "src/module-loader/catalog.js",
@@ -12942,12 +12942,12 @@
12942
12942
  {
12943
12943
  "path": "tests/upgrade.mjs",
12944
12944
  "mode": "0000644",
12945
- "sha256": "def73ab0f532c04df167d77917981eaf7e0dc7e1d2a7e7c85f3f962d7d65ce2a"
12945
+ "sha256": "bcbba7b1c952088f43300405184849bbcbb2da789097fcc4cf38d95441e0c158"
12946
12946
  },
12947
12947
  {
12948
12948
  "path": "tests/upgrade_persist_pin.mjs",
12949
12949
  "mode": "0000644",
12950
- "sha256": "c0a96fbdc6a9f851925babfb0857c9a168374c4165c84884f5c4c56c7177dd58"
12950
+ "sha256": "864b9471fcd6bfc41e99fd5356a3124c25d288946326c2797cc2bacb86120d05"
12951
12951
  },
12952
12952
  {
12953
12953
  "path": "tests/verify_task_guard.mjs",
@@ -1970,5 +1970,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
1970
1970
  landed since 1.19.746 with no explicit bump. run 35025151628. (task 1002620)
1971
1971
  1.19.748 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1972
1972
  landed since 1.19.747 with no explicit bump. run 35026862340. (task 1002620)
1973
+ 1.19.749 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1974
+ landed since 1.19.748 with no explicit bump. run 35028684745. (task 1002620)
1973
1975
  ---------------------------------------------------------------------------
1974
1976
  ```
@@ -55,7 +55,7 @@ The instance runs on the droplet (co-tenant): systemd `emersonian-circles.servic
55
55
 
56
56
  > **The pin MUST reach the repo of record, or the bump silently un-does itself** (task 1002712). A pull-deploy instance resets to `origin/main`; a pin that lives only in the working tree is reverted the next time anything lands, and the lockfile change then trips `npm ci`, which reinstalls the OLD core. Nothing errors. cloudbongos.com lost thirteen releases exactly this way — the `core_upgrades` ledger held two successful `auto-subscription (patch)` rows whose result no longer existed on disk, because deploy.sh short-circuits while `HEAD == origin/main` so the upgrade *looks* fine right up until someone else pushes.
57
57
  >
58
- > `bongos upgrade` now **warns loudly** whenever it leaves the pin uncommitted in a git repo, and `--commit-pin` (optionally `--git-remote <name>`) commits + pushes `package.json` + `package-lock.json` for you. A commit that fails to push is reported as a failure too — deploy.sh resets to the *remote*, so committing locally saves nothing. Verify durability by forcing a deploy tick (`sudo systemctl start <instance>-deploy.service`) and re-reading the pin.
58
+ > `bongos upgrade` now **warns loudly** whenever it leaves the pin uncommitted in a git repo, and `--commit-pin` (optionally `--git-remote <name>`) commits + pushes `package.json` + `package-lock.json` for you, together with any regenerated API spec, reference and typed client the bump rewrote (task 1003926 — left uncommitted, they made the next upgrade fail its clean-tree pre-flight). A commit that fails to push is reported as a failure too — deploy.sh resets to the *remote*, so committing locally saves nothing. Verify durability by forcing a deploy tick (`sudo systemctl start <instance>-deploy.service`) and re-reading the pin.
59
59
  7. **Align the droplet checkout** — the droplet has no deploy key (`git pull` fails; idea 509), so `git bundle` the new commit on the laptop, `scp`, `git fetch <bundle> && git reset --hard FETCH_HEAD`. `node_modules` is already the new version, so no re-`npm ci`; a final restart makes `/version` report the new commit.
60
60
 
61
61
  ## Auto-rollback on failure (task 2149)
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.748",
3
+ "version": "1.19.749",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.748",
9
+ "version": "1.19.749",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.748",
3
+ "version": "1.19.749",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
@@ -157,6 +157,17 @@ function gitTreeClean(instanceDir, run = spawnSync) {
157
157
  // Hence: --commit-pin lands it, and its absence WARNS rather than staying quiet.
158
158
  const PIN_FILES = ['package.json', 'package-lock.json'];
159
159
 
160
+ // The tracked files regenerateApiArtifacts' generators rewrite WHOLE, by generator (task
161
+ // 1003926). Committing only the pin left these dirty, so the NEXT `bongos upgrade` refused
162
+ // its clean-tree pre-flight. They are generated end to end, so once a generator has run
163
+ // their content is its output whatever they held before: committing them can never carry
164
+ // an owner's edit. tests/upgrade_persist_pin.mjs pins this list against both generators.
165
+ const API_ARTIFACT_FILES = {
166
+ 'gen-api-docs.js': ['docs/api/openapi.json', 'docs/api-reference.md', 'docs/routes-permissions.md'],
167
+ 'gen-api-client.js': ['index.mjs', 'index.cjs', 'bongos-client.global.js', 'index.d.ts', 'package.json', 'README.md']
168
+ .map((f) => `clients/bongos-client/${f}`),
169
+ };
170
+
160
171
  function gitCurrentBranch(instanceDir, run = spawnSync) {
161
172
  const r = run('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: instanceDir, encoding: 'utf8' });
162
173
  if (!r || r.error || r.status !== 0) return null;
@@ -164,13 +175,17 @@ function gitCurrentBranch(instanceDir, run = spawnSync) {
164
175
  return b && b !== 'HEAD' ? b : null;
165
176
  }
166
177
 
167
- // Which of the pin files git currently reports as modified.
168
- function dirtyPinFiles(instanceDir, run = spawnSync) {
169
- const r = run('git', ['status', '--porcelain', '--', ...PIN_FILES], { cwd: instanceDir, encoding: 'utf8' });
178
+ // Which of `paths` git currently reports as changed — never a path it was not asked about,
179
+ // and no git call for an empty list (a pathspec of nothing is the whole tree).
180
+ function dirtyPaths(instanceDir, paths, run = spawnSync) {
181
+ if (!paths.length) return [];
182
+ const r = run('git', ['status', '--porcelain', '--', ...paths], { cwd: instanceDir, encoding: 'utf8' });
170
183
  if (!r || r.error || r.status !== 0) return null; // not a git repo — nothing to persist
171
- return (r.stdout || '').split('\n').map((l) => l.slice(3).trim()).filter(Boolean);
184
+ return (r.stdout || '').split('\n').map((l) => l.slice(3).trim()).filter((p) => paths.includes(p));
172
185
  }
173
186
 
187
+ function dirtyPinFiles(instanceDir, run = spawnSync) { return dirtyPaths(instanceDir, PIN_FILES, run); }
188
+
174
189
  // ---- which credential the pin push rides (task 1003081, ADR 0176 follow-up) ----
175
190
  //
176
191
  // THE BUG THIS EXISTS FOR. A PRIVATE standalone instance's `origin` authenticates with
@@ -285,11 +300,16 @@ function gitSaid(r) {
285
300
  // GitHub token over HTTPS (task 1003081); null pushes via `remote`. The result carries
286
301
  // `via: 'owner-token'` on the token path and `refused` ('read-only-deploy-key' |
287
302
  // 'owner-token') with a plain-sentence `error` when GitHub turned the push away.
288
- function persistPin({ instanceDir, fromVersion, toVersion, commitPin, push = true, remote = 'origin', dryRun = false, pushAuth = null },
303
+ // `artifactPaths` (task 1003926) are the generated files this upgrade rewrote; the ones
304
+ // that changed are staged and committed with the pin.
305
+ function persistPin({ instanceDir, fromVersion, toVersion, commitPin, push = true, remote = 'origin', dryRun = false, pushAuth = null, artifactPaths = [] },
289
306
  run = spawnSync, log = console.log, err = console.error) {
290
- const dirty = dirtyPinFiles(instanceDir, run);
291
- if (dirty === null) return { applicable: false, reason: 'not a git repo' };
307
+ const dirtyPins = dirtyPinFiles(instanceDir, run);
308
+ if (dirtyPins === null) return { applicable: false, reason: 'not a git repo' };
309
+ const dirtyArtifacts = dirtyPaths(instanceDir, artifactPaths, run) || [];
310
+ const dirty = [...dirtyPins, ...dirtyArtifacts];
292
311
  if (!dirty.length) return { applicable: false, reason: 'pin files already match the repo' };
312
+ const toStage = [...PIN_FILES, ...dirtyArtifacts];
293
313
 
294
314
  const branch = gitCurrentBranch(instanceDir, run);
295
315
  const target = `HEAD:${branch || 'main'}`;
@@ -314,7 +334,7 @@ function persistPin({ instanceDir, fromVersion, toVersion, commitPin, push = tru
314
334
  err(' On a pull-deploy instance the next push to the repo of record makes deploy.sh');
315
335
  err(` 'git reset --hard ${remote}/${branch || 'main'}', which REVERTS this pin and lets npm ci`);
316
336
  err(` reinstall ${fromVersion || 'the previous core'}. Re-run with --commit-pin, or commit + push:`);
317
- err(` ${at} add ${PIN_FILES.map(q).join(' ')} && ${at} commit -m ${q(`pin @bongos/core ${toVersion}`)} && ${at} push ${q(remote)} ${q(target)}`);
337
+ err(` ${at} add ${toStage.map(q).join(' ')} &&${at} commit -m ${q(`pin @bongos/core ${toVersion}`)} && ${at} push ${q(remote)} ${q(target)}`);
318
338
  return { applicable: true, committed: false, pushed: false, dirty, warned: true };
319
339
  }
320
340
 
@@ -326,7 +346,7 @@ function persistPin({ instanceDir, fromVersion, toVersion, commitPin, push = tru
326
346
  }
327
347
 
328
348
  const message = `pin @bongos/core ${toVersion}${fromVersion ? ` (was ${fromVersion})` : ''}`;
329
- const add = run('git', ['add', ...PIN_FILES], { cwd: instanceDir, encoding: 'utf8' });
349
+ const add = run('git', ['add', ...toStage], { cwd: instanceDir, encoding: 'utf8' });
330
350
  if (!add || add.error || add.status !== 0) {
331
351
  err(' ! could not stage the pin files — commit + push them by hand or the next deploy reverts this bump.');
332
352
  return { applicable: true, committed: false, pushed: false, dirty, error: 'git add failed' };
@@ -572,7 +592,7 @@ function resolveInstanceDb({ instanceDir }, run = spawnSync) {
572
592
  function regenerateApiArtifacts({ instanceDir, coreRoot }, run = spawnSync, fsImpl = fs) {
573
593
  const env = { ...process.env, BONGOS_INSTANCE_ROOT: instanceDir };
574
594
  const ran = [];
575
- for (const gen of ['gen-api-docs.js', 'gen-api-client.js']) {
595
+ for (const gen of Object.keys(API_ARTIFACT_FILES)) {
576
596
  const script = path.join(coreRoot, 'scripts', 'gds', gen);
577
597
  if (!fsImpl.existsSync(script)) return { ok: false, ran, reason: `${gen} not found in core (${coreRoot})` };
578
598
  const r = run(process.execPath, [script], { cwd: instanceDir, env, stdio: 'inherit' });
@@ -1185,9 +1205,11 @@ async function runUpgrade(opts, deps = {}) {
1185
1205
  // 5b. regenerate the per-instance API artifacts (OpenAPI spec + typed client) from the
1186
1206
  // new core's routes — the claude-materialize sibling (task 2051). Best-effort: a regen
1187
1207
  // hiccup is a warning, not a failed bump (the next ship's fitness gate refreshes them).
1208
+ let regenArtifacts = [];
1188
1209
  if (!opts.skipRegenDocs) {
1189
1210
  const coreRoot = path.join(instanceDir, 'node_modules', CORE_PKG);
1190
1211
  const regen = (deps.regenerateApiArtifacts || regenerateApiArtifacts)({ instanceDir, coreRoot }, run);
1212
+ regenArtifacts = (regen.ran || []).flatMap((gen) => API_ARTIFACT_FILES[gen] || []);
1191
1213
  if (regen.ok) log(` ✓ regenerated API docs + typed client from core ${targetVersion}`);
1192
1214
  else err(` ! API-artifact regen incomplete (${regen.reason}) — ran ${regen.ran.join(', ') || 'nothing'}; the next ship's fitness gate refreshes them.`);
1193
1215
  // 5c. regenerate the whole-file navigation docs (repo-map + session-index) from the new
@@ -1301,7 +1323,8 @@ async function runUpgrade(opts, deps = {}) {
1301
1323
  // actually coming: --commit-pin without --pin-no-push, not a dry run, and the pin
1302
1324
  // files really are dirty. Nothing reads a credential it will not spend (task 1003081).
1303
1325
  const remote = opts.gitRemote || 'origin';
1304
- const pushComing = !!opts.commitPin && !opts.pinNoPush && !dryRun && (dirtyPinFiles(instanceDir, run) || []).length > 0;
1326
+ const pushComing = !!opts.commitPin && !opts.pinNoPush && !dryRun
1327
+ && ((dirtyPinFiles(instanceDir, run) || []).length > 0 || (dirtyPaths(instanceDir, regenArtifacts, run) || []).length > 0);
1305
1328
  const pushAuth = pushComing ? await (deps.resolvePinPushAuth || resolvePinPushAuth)({ instanceDir, remote }, deps, run) : null;
1306
1329
  const pin = persistPin({
1307
1330
  instanceDir,
@@ -1312,6 +1335,7 @@ async function runUpgrade(opts, deps = {}) {
1312
1335
  remote,
1313
1336
  dryRun: opts.dryRun,
1314
1337
  pushAuth,
1338
+ artifactPaths: regenArtifacts,
1315
1339
  }, run, log, err);
1316
1340
 
1317
1341
  const ok = versionOk && healthOk !== false && servedOk !== false;
@@ -1394,7 +1418,7 @@ async function main(argv = process.argv.slice(2)) {
1394
1418
  module.exports = {
1395
1419
  runUpgrade, parseArgs, main, rollback,
1396
1420
  versionFromTgzPath, versionFromDep, readInstalledCoreVersion, readPinnedCoreVersion, writePin, writeRegistryPin, restorePin,
1397
- vendorTarball, gitTreeClean, persistPin, dirtyPinFiles, gitCurrentBranch, PIN_FILES, preflightDbIdentity,
1421
+ vendorTarball, gitTreeClean, persistPin, dirtyPinFiles, gitCurrentBranch, PIN_FILES, API_ARTIFACT_FILES, preflightDbIdentity,
1398
1422
  resolveInstanceDb, preflightModules, reportModulePreflight, npmInstall, runMigrate, regenerateApiArtifacts, regenerateNavDocs, NAV_WHOLE_FILE_GENERATORS, missingDocAssets, healDocAssets, DOC_ASSET_GENERATORS, restartService, recordLedger, pollHealth, deriveVersionUrl, pollServedVersion,
1399
1423
  resolvePinPushAuth, remoteGithubRepo, pinnedDeployKey, // task 1003081 — the pin push prefers the owner's GitHub token (ADR 0176 follow-up)
1400
1424
  readManifest, resolveReferenceManifest, verifyInstalledPin, // task 1002216 (audit H8) — integrity-pin verification
package/src/module-api.js CHANGED
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
71
71
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
72
72
  // the entry to that file. Look for a version's history there, not here.
73
73
  // ---------------------------------------------------------------------------
74
- const CORE_VERSION = '1.19.748'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.749'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
75
75
 
76
76
  // A namespaced logger so a module's log lines are attributable + consistent.
77
77
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
package/tests/upgrade.mjs CHANGED
@@ -228,7 +228,9 @@ t('full: pre-flight → pin → install → migrate → materialize → restart
228
228
  // pin is committed (task 1002712). It runs LAST — after verify — and without
229
229
  // --commit-pin it only warns, so no git add/commit/push appears here.
230
230
  ['git status --porcelain', 'npm install --no-audit --no-fund', 'npm run migrate', 'systemctl restart emersonian.service',
231
- 'git status --porcelain -- package.json package-lock.json']
231
+ 'git status --porcelain -- package.json package-lock.json',
232
+ // task 1003926: both generators ran, so the files they rewrote are the pin commit's too
233
+ `git status --porcelain -- ${Object.values(u.API_ARTIFACT_FILES).flat().join(' ')}`]
232
234
  );
233
235
  // materialize pointed at the freshly-installed core
234
236
  assert.ok(materialized && materialized.coreRoot.endsWith(join('node_modules', '@bongos', 'core')));
@@ -1676,6 +1678,42 @@ t('runUpgrade: the token store is consulted only when a push is actually coming'
1676
1678
  }
1677
1679
  });
1678
1680
 
1681
+ // task 1003926 — the pin commit carries the files the generators that RAN rewrote, and no others.
1682
+ t('runUpgrade --commit-pin --pin-no-push: a generator that did not run contributes nothing to the pin commit', async () => {
1683
+ const dir = scratchConsumer({ pinned: '1.14.1', installed: '1.14.1' });
1684
+ const src = mkdtempSync(join(tmpdir(), 'otb-upgrade-src-'));
1685
+ writeFileSync(join(src, 'bongos-core-1.15.0.tgz'), 'TGZ');
1686
+ const calls = [];
1687
+ const changed = ['package.json', 'package-lock.json', 'docs/api/openapi.json', 'clients/bongos-client/package.json'];
1688
+ const run = (cmd, args) => {
1689
+ calls.push(`${cmd} ${(args || []).join(' ')}`.trim());
1690
+ if (cmd === 'git' && args[0] === 'status') {
1691
+ if (!args.includes('--')) return { status: 0, stdout: '' }; // pre-flight: clean
1692
+ const asked = args.slice(args.indexOf('--') + 1);
1693
+ return { status: 0, stdout: changed.filter((p) => asked.includes(p)).map((p) => ` M ${p}\n`).join('') };
1694
+ }
1695
+ if (cmd === 'git' && args[0] === 'rev-parse') return { status: 0, stdout: 'main\n' };
1696
+ if (cmd === 'npm' && args[0] === 'install') {
1697
+ writeFileSync(join(dir, 'node_modules', '@bongos', 'core', 'package.json'), JSON.stringify({ name: '@bongos/core', version: '1.15.0' }) + '\n');
1698
+ }
1699
+ return { status: 0 };
1700
+ };
1701
+ const res = await u.runUpgrade(
1702
+ { to: '1.15.0', from: join(src, 'bongos-core-1.15.0.tgz'), instance: dir, service: 'acme.service', healthCheck: false, commitPin: true, pinNoPush: true },
1703
+ {
1704
+ log: hush, err: hush, run, materialize: () => ({}),
1705
+ // the client generator failed, so its files are not the upgrade's output
1706
+ regenerateApiArtifacts: () => ({ ok: false, ran: ['gen-api-docs.js'], reason: 'gen-api-client.js exited 1' }),
1707
+ databaseUrl: '', pgDatabase: '',
1708
+ },
1709
+ );
1710
+ assert.equal(res.ok, true, `upgrade should succeed: ${res.error}`);
1711
+ assert.equal(res.pin && res.pin.committed, true);
1712
+ assert.ok(calls.includes('git add package.json package-lock.json docs/api/openapi.json'),
1713
+ `got ${JSON.stringify(calls.filter((c) => c.startsWith('git add')))}`);
1714
+ assert.ok(!calls.some((c) => c.startsWith('git push')), '--pin-no-push');
1715
+ });
1716
+
1679
1717
  await Promise.all(pending);
1680
1718
  console.log(`\nupgrade: ${passed} passed, ${failed} failed`);
1681
1719
  process.exit(failed ? 1 : 0);
@@ -21,9 +21,14 @@
21
21
  import { test } from 'node:test';
22
22
  import assert from 'node:assert/strict';
23
23
  import { createRequire } from 'node:module';
24
+ import { spawnSync } from 'node:child_process';
25
+ import { mkdtempSync, mkdirSync, writeFileSync, readFileSync } from 'node:fs';
26
+ import { tmpdir } from 'node:os';
27
+ import { join, dirname } from 'node:path';
28
+ import { API_ARTIFACT_FILES } from '../scripts/gds/upgrade.js';
24
29
 
25
30
  const require = createRequire(import.meta.url);
26
- const { persistPin, dirtyPinFiles, preflightDbIdentity, PIN_FILES } = require('../scripts/gds/upgrade.js');
31
+ const { persistPin, dirtyPinFiles, preflightDbIdentity, gitTreeClean, PIN_FILES } = require('../scripts/gds/upgrade.js');
27
32
 
28
33
  // A fake `run` driven by a table of [matcher, result]. Records every invocation.
29
34
  function fakeRun(handlers = {}) {
@@ -250,3 +255,99 @@ test('dirtyPinFiles only ever reports the pin files', () => {
250
255
  const statusCall = run.calls.find((c) => c.startsWith('git status'));
251
256
  assert.match(statusCall, /--\s+package\.json package-lock\.json$/, 'pathspec-limited');
252
257
  });
258
+
259
+ // ── the upgrade's own regenerated files ride the pin commit (task 1003926) ───────
260
+ //
261
+ // regenerateApiArtifacts rewrites the instance's TRACKED spec, reference and typed client
262
+ // WHOLE. With only the pin committed they stayed dirty, and the next `bongos upgrade`
263
+ // refused its clean-tree pre-flight: the stranding the pin commit exists to prevent.
264
+
265
+ const ARTIFACTS = Object.values(API_ARTIFACT_FILES).flat();
266
+ // A `git status --porcelain -- <paths>` fake that reports only what was asked AND changed.
267
+ const statusOf = (changed) => (args) => {
268
+ const asked = args.slice(args.indexOf('--') + 1);
269
+ return { status: 0, stdout: changed.filter((p) => asked.includes(p)).map((p) => ` M ${p}\n`).join('') };
270
+ };
271
+
272
+ test('API_ARTIFACT_FILES names exactly the files the two generators write', () => {
273
+ const client = require('../scripts/gds/gen-api-client.js').build();
274
+ assert.deepEqual(API_ARTIFACT_FILES['gen-api-client.js'], Object.keys(client.files).map((f) => `clients/bongos-client/${f}`));
275
+ const docsSrc = readFileSync(new URL('../scripts/gds/gen-api-docs.js', import.meta.url), 'utf8');
276
+ assert.deepEqual(API_ARTIFACT_FILES['gen-api-docs.js'], [...docsSrc.matchAll(/\brel: '([^']+)'/g)].map((m) => m[1]));
277
+ });
278
+
279
+ test('the generated files that changed are staged and committed WITH the pin', () => {
280
+ const run = fakeRun({ status: statusOf([...PIN_FILES, 'docs/api/openapi.json', 'clients/bongos-client/package.json']), 'rev-parse': ON_MAIN });
281
+ const r = persistPin({ ...base, commitPin: true, push: false, artifactPaths: ARTIFACTS }, run, quiet, quiet);
282
+ assert.equal(r.committed, true);
283
+ assert.deepEqual(r.dirty, [...PIN_FILES, 'docs/api/openapi.json', 'clients/bongos-client/package.json']);
284
+ assert.ok(run.calls.includes(`git add ${PIN_FILES.join(' ')} docs/api/openapi.json clients/bongos-client/package.json`),
285
+ `stages the pin + only the generated files that changed; got ${JSON.stringify(run.calls)}`);
286
+ });
287
+
288
+ test('generated files that did not change add nothing to the staging argv', () => {
289
+ const run = fakeRun({ status: statusOf([...PIN_FILES]), 'rev-parse': ON_MAIN });
290
+ persistPin({ ...base, commitPin: true, push: false, artifactPaths: ARTIFACTS }, run, quiet, quiet);
291
+ assert.ok(run.calls.includes(`git add ${PIN_FILES.join(' ')}`));
292
+ });
293
+
294
+ test('an already-committed pin still commits the generated files the upgrade rewrote', () => {
295
+ const run = fakeRun({ status: statusOf(['docs/api-reference.md']), 'rev-parse': ON_MAIN });
296
+ const r = persistPin({ ...base, commitPin: true, push: false, artifactPaths: ARTIFACTS }, run, quiet, quiet);
297
+ assert.equal(r.committed, true, 'a dirty generated file alone is still the upgrade\'s to persist');
298
+ assert.ok(run.calls.includes(`git add ${PIN_FILES.join(' ')} docs/api-reference.md`));
299
+ });
300
+
301
+ test('a path nobody asked about is never reported, and no artifact paths means no extra git call', () => {
302
+ const run = fakeRun({ status: { status: 0, stdout: ' M config/branding.json\n M docs/api/openapi.json\n' }, 'rev-parse': ON_MAIN });
303
+ const r = persistPin({ ...base, commitPin: true, push: false, artifactPaths: ['docs/api/openapi.json'] }, run, quiet, quiet);
304
+ assert.deepEqual(r.dirty, ['docs/api/openapi.json'], 'config/branding.json is not the upgrade\'s to commit');
305
+ const bare = fakeRun({ status: DIRTY, 'rev-parse': ON_MAIN });
306
+ persistPin({ ...base, commitPin: true, push: false }, bare, quiet, quiet);
307
+ assert.equal(bare.calls.filter((c) => c.startsWith('git status')).length, 1, 'an empty pathspec would mean the WHOLE tree');
308
+ });
309
+
310
+ test('without --commit-pin, the paste-ready fix stages the changed generated files too', () => {
311
+ const warnings = [];
312
+ const run = fakeRun({ status: statusOf([...PIN_FILES, 'docs/api/openapi.json']), 'rev-parse': ON_MAIN });
313
+ persistPin({ ...base, commitPin: false, artifactPaths: ARTIFACTS }, run, quiet, (m) => warnings.push(m));
314
+ const cmd = warnings.find((w) => w.includes('git -C'));
315
+ assert.match(cmd, /add 'package\.json' 'package-lock\.json' 'docs\/api\/openapi\.json' &&/);
316
+ });
317
+
318
+ // Real git, no fakes: the task's done-when, reproduced on a scratch repo.
319
+ function git(dir, ...args) { return spawnSync('git', args, { cwd: dir, encoding: 'utf8' }); }
320
+ function put(dir, rel, content) { mkdirSync(dirname(join(dir, rel)), { recursive: true }); writeFileSync(join(dir, rel), content); }
321
+ function scratchInstance() {
322
+ const dir = mkdtempSync(join(tmpdir(), 'upgrade-regen-'));
323
+ git(dir, 'init', '-q');
324
+ git(dir, 'config', 'user.email', 'upgrade-test@example.invalid');
325
+ git(dir, 'config', 'user.name', 'upgrade test');
326
+ git(dir, 'config', 'core.autocrlf', 'false');
327
+ for (const f of [...PIN_FILES, ...ARTIFACTS, 'config/branding.json']) put(dir, f, 'scaffold\n');
328
+ git(dir, 'add', '-A');
329
+ git(dir, 'commit', '-q', '-m', 'scaffold');
330
+ return dir;
331
+ }
332
+ // What one upgrade writes: the moved pin, and the generators' whole files.
333
+ const upgradeWrites = (dir, version) => { for (const f of [...PIN_FILES, ...ARTIFACTS]) put(dir, f, `${version}\n`); };
334
+
335
+ test('real git: two consecutive co-tenant upgrades each leave the tree clean, so the next pre-flight passes', () => {
336
+ const dir = scratchInstance();
337
+ for (const [from, to] of [['1.19.737', '1.19.745'], ['1.19.745', '1.19.746']]) {
338
+ assert.equal(gitTreeClean(dir).ok, true, `pre-flight before ${to}`);
339
+ upgradeWrites(dir, to);
340
+ const r = persistPin({ instanceDir: dir, fromVersion: from, toVersion: to, commitPin: true, push: false, artifactPaths: ARTIFACTS }, spawnSync, quiet, quiet);
341
+ assert.equal(r.committed, true, `upgrade to ${to} committed`);
342
+ assert.equal(git(dir, 'status', '--porcelain').stdout, '', `upgrade to ${to} leaves nothing uncommitted`);
343
+ }
344
+ });
345
+
346
+ test('real git: an owner edit outside the generated files is never swept into the pin commit', () => {
347
+ const dir = scratchInstance();
348
+ put(dir, 'config/branding.json', 'the owner was here\n');
349
+ upgradeWrites(dir, '1.19.745');
350
+ persistPin({ instanceDir: dir, fromVersion: '1.19.737', toVersion: '1.19.745', commitPin: true, push: false, artifactPaths: ARTIFACTS }, spawnSync, quiet, quiet);
351
+ assert.equal(git(dir, 'status', '--porcelain').stdout, ' M config/branding.json\n');
352
+ assert.ok(!git(dir, 'show', '--name-only', '--format=', 'HEAD').stdout.includes('config/branding.json'));
353
+ });