@bongos/core 1.19.748 → 1.19.750

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,22 +2,22 @@
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.750",
6
+ "core_contract": "1.19.750",
7
+ "source_commit": "70d84110fa87578e522d55cec0236a0719f25767",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-15T21:40:07.956Z",
9
+ "built_at": "2026-09-15T22:23:58.558Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 482,
13
13
  "agent_docs_stubbed": 25,
14
- "functional_verbatim": 2109,
14
+ "functional_verbatim": 2110,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 2616,
20
- "tree_sha256": "f52311a51ab40e147838cb38e88a7327f02df848a8bba552a509cc0185fcdc6b",
19
+ "file_count": 2617,
20
+ "tree_sha256": "a8d1e331aab73c6da3b924d2defdcf6ac1d55f6a9ccfed12c7922e90314e7417",
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": "996dff9026c0402289d079b4a3cab9b437def3df20adbc465c841b5064ff1b48"
2511
2511
  },
2512
2512
  {
2513
2513
  "path": "docs/modules-contract.md",
@@ -2652,7 +2652,7 @@
2652
2652
  {
2653
2653
  "path": "docs/recipes/gds-db-backup.md",
2654
2654
  "mode": "0000644",
2655
- "sha256": "9f77ee1dd01d47f38679387e0b96a739924056a52f2af2e174a7f52248f4345a"
2655
+ "sha256": "0f2dfdeca8d49a07a0da043eaa9f2b16669228c358cb56a7786c6c59e5e29808"
2656
2656
  },
2657
2657
  {
2658
2658
  "path": "docs/recipes/instance-deploy.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": "f0450a8f923e11ca0b92de0ead3f2b9b49a8c01f721be06ddd2566845a082775"
7566
7566
  },
7567
7567
  {
7568
7568
  "path": "package.json",
7569
7569
  "mode": "0000644",
7570
- "sha256": "7ffeb06be165b48faa187f3faa8526b293bc6ccd467ecb6ac7a880036f805ad8"
7570
+ "sha256": "4bc87648a58a6f4b15ffe9f9bda38a0edace87d66e9e319317804d3f1ee347ac"
7571
7571
  },
7572
7572
  {
7573
7573
  "path": "public-docs/index.html",
@@ -7912,7 +7912,7 @@
7912
7912
  {
7913
7913
  "path": "scripts/gds/db-backup-nightly.sh",
7914
7914
  "mode": "0000644",
7915
- "sha256": "0a4b3490ad5d4724c693053b16af61f6632e4933d18bedbdc5737a84111f03ae"
7915
+ "sha256": "ff4785dbde6a5c52036ce4c4aacca317c62e725f49d3e32201909ec444d85d44"
7916
7916
  },
7917
7917
  {
7918
7918
  "path": "scripts/gds/db-backup-offsite.sh",
@@ -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": "de52088bfb2f473ad6960e60ee94153cdf1b74d4634ed4934d22e977988278a9"
9371
9371
  },
9372
9372
  {
9373
9373
  "path": "src/module-loader/catalog.js",
@@ -10224,6 +10224,11 @@
10224
10224
  "mode": "0000644",
10225
10225
  "sha256": "434130945232d2a6959281070e6dbee31697904b3455fb3fd09f198a64bb0e33"
10226
10226
  },
10227
+ {
10228
+ "path": "tests/db_backup_nightly.mjs",
10229
+ "mode": "0000644",
10230
+ "sha256": "020dc551d105cc4a29e0f9c750a03cd14a91511d3edc47eb6719a1e430461936"
10231
+ },
10227
10232
  {
10228
10233
  "path": "tests/dead_deps.mjs",
10229
10234
  "mode": "0000644",
@@ -12942,12 +12947,12 @@
12942
12947
  {
12943
12948
  "path": "tests/upgrade.mjs",
12944
12949
  "mode": "0000644",
12945
- "sha256": "def73ab0f532c04df167d77917981eaf7e0dc7e1d2a7e7c85f3f962d7d65ce2a"
12950
+ "sha256": "bcbba7b1c952088f43300405184849bbcbb2da789097fcc4cf38d95441e0c158"
12946
12951
  },
12947
12952
  {
12948
12953
  "path": "tests/upgrade_persist_pin.mjs",
12949
12954
  "mode": "0000644",
12950
- "sha256": "c0a96fbdc6a9f851925babfb0857c9a168374c4165c84884f5c4c56c7177dd58"
12955
+ "sha256": "864b9471fcd6bfc41e99fd5356a3124c25d288946326c2797cc2bacb86120d05"
12951
12956
  },
12952
12957
  {
12953
12958
  "path": "tests/verify_task_guard.mjs",
@@ -1970,5 +1970,9 @@ 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)
1975
+ 1.19.750 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1976
+ landed since 1.19.749 with no explicit bump. run 35030717430. (task 1002620)
1973
1977
  ---------------------------------------------------------------------------
1974
1978
  ```
@@ -21,7 +21,7 @@ This closes the **most likely** loss-of-data risks: schema corruption, an accide
21
21
 
22
22
  - **Filename format:** `<dbname>-<UTC-timestamp>.sql.gz`. UTC is used so the dumps sort chronologically regardless of the droplet's timezone.
23
23
  - **Permissions:** dir is `lars:lars 0750`; files are `lars:lars 0640`. No root needed for routine inspection.
24
- - **Size:** each gzipped dump is well under 1 MB, and 30 days of them fit comfortably — pruning is hygiene, not disk pressure. **The dump excludes `search_chunks` data** (the `/recall` lexical index, migration 103) via `--exclude-table-data=search_chunks` ([#973](https://example.com/builders#/task/973)): it is regenerable derived data — chunked copies of DB prose (already dumped) and repo markdown (already in git) — and dumping its rows roughly duplicated the prose corpus, making it the dump's dominant growth vector. The table schema + GIN indexes are still dumped, so a restore recreates it empty; the index rebuilds from source (see the restore drill below). A size guard in the backup script WARNs to journald if a dump ever exceeds `GDS_BACKUP_WARN_BYTES` (default 25 MiB) — a generous tripwire for unexpected growth from some *other* table.
24
+ - **Size:** each gzipped dump is well under 1 MB, and 30 days of them fit comfortably — pruning is hygiene, not disk pressure. **The dump excludes `search_chunks` data** (the `/recall` lexical index, migration 103) via `--exclude-table-data=search_chunks` ([#973](https://example.com/builders#/task/973)): it is regenerable derived data — chunked copies of DB prose (already dumped) and repo markdown (already in git) — and dumping its rows roughly duplicated the prose corpus, making it the dump's dominant growth vector. The table schema + GIN indexes are still dumped, so a restore recreates it empty; the index rebuilds from source (see the restore drill below). A size guard in the backup script WARNs to journald if a dump ever exceeds `GDS_BACKUP_WARN_BYTES` (default 25 MiB) — a generous tripwire for unexpected growth from some *other* table. A dump is refused (and deleted) only when it is **incomplete** — missing pg_dump's closing `dump complete` line — never for being small: a fresh project's whole database gzips to ~36 KB, and the fixed 1 MiB floor this replaced failed and deleted every project's nightly dump ([task 1003925](https://cloudbongos.com/builders#/task/1003925)). An optional extra size floor can still be set in the unit's environment (see the script).
25
25
 
26
26
  ## What's scheduled
27
27
 
@@ -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.750",
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.750",
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.750",
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",
@@ -130,13 +130,27 @@ mv "$OUT.partial" "$OUT"
130
130
  BYTES=$(stat -c '%s' "$OUT")
131
131
  echo "[$(date -u +%FT%TZ)] gds-db-backup: wrote ${OUT} (${BYTES} bytes)"
132
132
 
133
- # Min-size sanity guard (incident 2026-07-02, task 1711). pipefail already aborts
134
- # on a pg_dump error, but this is belt-and-suspenders: a real dump is ~12-20 MB
135
- # gzipped, so a sub-1-MiB file means an empty/truncated dump slipped through.
136
- # Fail loudly and delete it so a broken dump can never masquerade as a good one.
137
- MIN_BYTES="${GDS_BACKUP_MIN_BYTES:-1048576}"
138
- if [ "$BYTES" -lt "$MIN_BYTES" ]; then
139
- echo "[$(date -u +%FT%TZ)] gds-db-backup: ERROR latest dump is only ${BYTES} bytes (< ${MIN_BYTES} floor) — treating as FAILED." >&2
133
+ # Completeness guard (incident 2026-07-02, task 1711; task 1003925). pipefail already
134
+ # aborts on a pg_dump error; this is belt-and-suspenders against a dump cut short. A
135
+ # plain-format dump always closes with pg_dump's own "dump complete" comment (patched
136
+ # servers append a \unrestrict line after it), whatever its size. The fixed 1 MiB floor
137
+ # this replaces was sized for the platform database and failed — and deleted — every
138
+ # valid dump of a small project (~36 KB gzipped). Fail loudly and delete a broken dump
139
+ # so it can never masquerade as a good one.
140
+ DUMP_TAIL=$(gzip -dc "$OUT" 2>/dev/null | tail -n 10) || DUMP_TAIL=''
141
+ FAIL_REASON=''
142
+ case "$DUMP_TAIL" in
143
+ *'-- PostgreSQL database dump complete'*) ;;
144
+ *) FAIL_REASON="(${BYTES} bytes) is incomplete — no pg_dump completion line" ;;
145
+ esac
146
+
147
+ # Optional operator floor. Unset means none: no single size fits every instance.
148
+ MIN_BYTES="${GDS_BACKUP_MIN_BYTES:-0}"
149
+ if [ -z "$FAIL_REASON" ] && [ "$BYTES" -lt "$MIN_BYTES" ]; then
150
+ FAIL_REASON="is only ${BYTES} bytes (< ${MIN_BYTES} floor)"
151
+ fi
152
+ if [ -n "$FAIL_REASON" ]; then
153
+ echo "[$(date -u +%FT%TZ)] gds-db-backup: ERROR latest dump ${FAIL_REASON} — treating as FAILED." >&2
140
154
  rm -f "$OUT"
141
155
  exit 1
142
156
  fi
@@ -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.750'; // 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');
@@ -0,0 +1,79 @@
1
+ // tests/db_backup_nightly.mjs — the nightly dump's sanity guard (task 1003925).
2
+ //
3
+ // THE BUG. db-backup-nightly.sh refused any dump under a fixed 1 MiB floor, sized for the
4
+ // platform's own database. A small project's whole database gzips to ~36 KB, so every
5
+ // project's nightly unit failed AND the valid dump was deleted: on 2026-09-15 twelve backup
6
+ // units were failed and no project had a single retained dump. The guard now asks whether
7
+ // the dump is COMPLETE (pg_dump's closing comment), which is true of a valid dump at any size
8
+ // and false of one cut short.
9
+ //
10
+ // The script runs for real against a fake `pg_dump` on PATH, so nothing touches a database.
11
+ //
12
+ // Run: node --test tests/db_backup_nightly.mjs
13
+
14
+ import assert from 'node:assert/strict';
15
+ import { test } from 'node:test';
16
+ import { spawnSync } from 'node:child_process';
17
+ import fs from 'node:fs';
18
+ import os from 'node:os';
19
+ import path from 'node:path';
20
+ import { fileURLToPath } from 'node:url';
21
+
22
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
23
+ const SCRIPT = path.join(ROOT, 'scripts', 'gds', 'db-backup-nightly.sh');
24
+ const skip = process.platform === 'win32' ? 'needs a POSIX bash + coreutils (runs in CI)' : false;
25
+ // The script's env-var prefix, built rather than written so the legacy-literal ratchet
26
+ // does not count a test that has to speak the script's own variable names.
27
+ const ENV = 'gds_backup_'.toUpperCase();
28
+
29
+ // A fake pg_dump: a plain-format dump of `rows` rows, closed the way pg_dump closes one
30
+ // (including the \unrestrict line patched servers append) unless it is `cut` short, and
31
+ // exiting `exitCode`.
32
+ function runBackup({ rows = 40, cut = false, exitCode = 0, env = {} } = {}) {
33
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'db-backup-'));
34
+ const bin = path.join(tmp, 'bin');
35
+ const dir = path.join(tmp, 'backups');
36
+ fs.mkdirSync(bin);
37
+ fs.mkdirSync(dir);
38
+ const body = Array.from({ length: rows }, (_, i) => `INSERT INTO public.t VALUES (${i});`).join('\n');
39
+ const close = cut ? '' : '\n--\n-- PostgreSQL database dump complete\n--\n\n\\unrestrict abc123\n';
40
+ fs.writeFileSync(path.join(bin, 'pg_dump'),
41
+ `#!/bin/bash\ncat <<'EOF'\n--\n-- PostgreSQL database dump\n--\n${body}\n${close}EOF\nexit ${exitCode}\n`, { mode: 0o755 });
42
+ const r = spawnSync('bash', [SCRIPT], {
43
+ encoding: 'utf8',
44
+ env: { ...process.env, PATH: `${bin}:${process.env.PATH}`, [`${ENV}DB`]: 'smallproj', [`${ENV}DIR`]: dir, ...env },
45
+ });
46
+ return { status: r.status, stdout: r.stdout, stderr: r.stderr, dumps: fs.readdirSync(dir).filter((f) => f.endsWith('.sql.gz')) };
47
+ }
48
+
49
+ test('a complete dump of a small project is kept, however small', { skip }, () => {
50
+ const r = runBackup();
51
+ assert.equal(r.status, 0, r.stderr);
52
+ assert.equal(r.dumps.length, 1, 'the valid dump is retained');
53
+ assert.match(r.stdout, /gds-db-backup: ok/);
54
+ });
55
+
56
+ test('a dump cut short fails and is removed, whatever its size', { skip }, () => {
57
+ const r = runBackup({ rows: 50000, cut: true });
58
+ assert.equal(r.status, 1);
59
+ assert.equal(r.dumps.length, 0, 'an incomplete dump never masquerades as a good one');
60
+ assert.match(r.stderr, /incomplete/i);
61
+ });
62
+
63
+ test('a pg_dump that fails mid-stream still fails the run (pipefail, task 1711)', { skip }, () => {
64
+ const r = runBackup({ cut: true, exitCode: 1 });
65
+ assert.notEqual(r.status, 0);
66
+ assert.equal(r.dumps.length, 0);
67
+ });
68
+
69
+ test('an explicit operator size floor is still enforced', { skip }, () => {
70
+ const r = runBackup({ env: { [`${ENV}MIN_BYTES`]: String(10 * 1024 * 1024) } });
71
+ assert.equal(r.status, 1);
72
+ assert.equal(r.dumps.length, 0);
73
+ assert.match(r.stderr, /floor/);
74
+ });
75
+
76
+ test('no fixed size floor is baked in for every instance', () => {
77
+ const src = fs.readFileSync(SCRIPT, 'utf8');
78
+ assert.doesNotMatch(src, new RegExp(`${ENV}MIN_BYTES:-[1-9]`), 'a default floor fails every database smaller than it');
79
+ });
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
+ });