@bongos/core 1.19.1046 → 1.19.1047

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.1046",
6
- "core_contract": "1.19.1046",
7
- "source_commit": "e319f60c3630db9d566a402e2b7ce9604c72ba82",
5
+ "core_version": "1.19.1047",
6
+ "core_contract": "1.19.1047",
7
+ "source_commit": "a83322a2af0bab901b143a098fb0a5cc337f9e64",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-26T19:22:25.895Z",
9
+ "built_at": "2026-09-26T23:42:01.064Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 543,
@@ -17,7 +17,7 @@
17
17
  "gate": "passed"
18
18
  },
19
19
  "file_count": 3082,
20
- "tree_sha256": "928acc82709aeebb76b1b0be69082adaebccaa8bd9e6697408c945b85406c1a8",
20
+ "tree_sha256": "0fdbcc97739c2b07898a2eb701e2f0a991cf6b1f173450143e6df73720b60231",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/ask-for-help/SKILL.md",
@@ -2772,7 +2772,7 @@
2772
2772
  {
2773
2773
  "path": "docs/module-api-changelog.md",
2774
2774
  "mode": "0000644",
2775
- "sha256": "00718f6a26dafa79be474121370153e96be1931395f4ca24da7f53f25e1de0ac"
2775
+ "sha256": "de85c4f3489d17d7b82c0cc64a3049a1cba09589f67e551ffcafbc9354ec3a8d"
2776
2776
  },
2777
2777
  {
2778
2778
  "path": "docs/modules-contract.md",
@@ -2892,7 +2892,7 @@
2892
2892
  {
2893
2893
  "path": "docs/recipes/core-release-pipeline.md",
2894
2894
  "mode": "0000644",
2895
- "sha256": "4f006e07e58a861399d8d04ab0b52a1752f4ff605475288d60b60bffb7e123e7"
2895
+ "sha256": "1558473dea0e6c41e623ede06f0bf006fcd58cb1ab388b7e7e149872fcdbb00b"
2896
2896
  },
2897
2897
  {
2898
2898
  "path": "docs/recipes/failed-ship-recovery-proof.md",
@@ -8497,12 +8497,12 @@
8497
8497
  {
8498
8498
  "path": "package-lock.json",
8499
8499
  "mode": "0000644",
8500
- "sha256": "4ef150815146dadfcc78e160733153e875b5c015c86bc837b3972b31eb3d9406"
8500
+ "sha256": "71c9088d184288d783c7ca384d4cff140ec48251157bb3557c5c76854d1829e8"
8501
8501
  },
8502
8502
  {
8503
8503
  "path": "package.json",
8504
8504
  "mode": "0000644",
8505
- "sha256": "310d214694937411a160474d6b971db66ed5c1f968cf4d573e94f980d5a394b1"
8505
+ "sha256": "888aceb332ce59d940cc7c4b42b63a896d773a387b957e7581a10f9b4d2a56be"
8506
8506
  },
8507
8507
  {
8508
8508
  "path": "public-docs/index.html",
@@ -8522,7 +8522,7 @@
8522
8522
  {
8523
8523
  "path": "release-notes.json",
8524
8524
  "mode": "0000644",
8525
- "sha256": "d8ecbe8ede04357e6201a6acea252912d6f123b39dc7e382b18599af424a7bb3"
8525
+ "sha256": "b2e95686e68d9e0321f3dba1807d8747ff43f2739aa3738c7d0eed8516eb20cd"
8526
8526
  },
8527
8527
  {
8528
8528
  "path": "scripts/bongos-mcp.js",
@@ -9197,7 +9197,7 @@
9197
9197
  {
9198
9198
  "path": "scripts/gds/go-live.js",
9199
9199
  "mode": "0000644",
9200
- "sha256": "3ff8b2370bd93c5469708900ba3d36066aa82f4858831ce6e4b1b7e817e29dcb"
9200
+ "sha256": "cf1e7da0a1a386a73c5ecf9bb37b2e3f593217bf8250f574cf95b9fbf73b1910"
9201
9201
  },
9202
9202
  {
9203
9203
  "path": "scripts/gds/goal-names.js",
@@ -10592,7 +10592,7 @@
10592
10592
  {
10593
10593
  "path": "src/module-api.js",
10594
10594
  "mode": "0000644",
10595
- "sha256": "95535ac13b1e19fcb5537fa9bf72cc8a182db008c1247c0920bd6a221bad9b7d"
10595
+ "sha256": "948f2239e756563cee5b97cc98254d5041e1edcb283139087f7e07bbf3368441"
10596
10596
  },
10597
10597
  {
10598
10598
  "path": "src/module-loader/catalog.js",
@@ -12102,7 +12102,7 @@
12102
12102
  {
12103
12103
  "path": "tests/go_live.mjs",
12104
12104
  "mode": "0000644",
12105
- "sha256": "fb937d66f8efa9fdcfb58715785a0c9d94e52034d04175aec78310c70bd53a7f"
12105
+ "sha256": "e62b39da9f1e5538dfc4d642a82c75ac400885b371170e722690b3adc756d1f5"
12106
12106
  },
12107
12107
  {
12108
12108
  "path": "tests/goal_abuse_matrix.mjs",
@@ -2579,5 +2579,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
2579
2579
  landed since 1.19.1044 with no explicit bump. run 36263855490. (task 1002620)
2580
2580
  1.19.1046 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2581
2581
  landed since 1.19.1045 with no explicit bump. run 36265788618. (task 1002620)
2582
+ 1.19.1047 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2583
+ landed since 1.19.1046 with no explicit bump. run 36280220559. (task 1002620)
2582
2584
  ---------------------------------------------------------------------------
2583
2585
  ```
@@ -222,6 +222,25 @@ answerable without going and looking. Two different unknowns, two different verd
222
222
  will not answer, and a broken read must never be a quieter way past it than the owner's own
223
223
  override. The fix is in the instance's logs, not on anyone's review queue.
224
224
 
225
+ **What `--apply` does, in order** (task 1004306):
226
+
227
+ 1. **Rehearse.** It runs the exact `bongos upgrade` it is about to run, plus `--dry-run`. That
228
+ asks every refusal the bump has (the artist gate, module compatibility, the database
229
+ identity, the downgrade guard) and changes nothing. A refusal stops here: no timer stopped,
230
+ no backup written, and the report says "Nothing changed". Before this, the backup came
231
+ first, so on cloudbongos.com every attempt the artist gate refused had already written a
232
+ 65 MB dump: one a day at the daily cadence, a full disk in about three days at every fifteen
233
+ minutes.
234
+ 2. **Stop the pull-deploy timer and back up** to `backupDir` as
235
+ `pre-<version>-<timestamp>.sql.gz`. A dump that fails now fails the go-live (before, a failed
236
+ `pg_dump` still left a tiny gzip that read as a backup) and the timer is put back.
237
+ 3. **Prune:** keep the newest **10** of those pre-upgrade dumps, by time, and delete the rest.
238
+ Only that exact name pattern is touched: a dump named by hand (`pre-lane-sweep-…`) and the
239
+ nightly backups (`db-backup-nightly.sh`, `<db>-<timestamp>.sql.gz`, kept 30 days by that
240
+ script) are never pruned here. A
241
+ prune that cannot run is a ⚠ warning, not a failed deploy.
242
+ 4. **Bump, restart, read back**, as before.
243
+
225
244
  Host topology (ssh alias, box paths, service names, health URL) lives in a host-owned
226
245
  `config/go-live.json`, never in the core — run `node scripts/gds/go-live.js --help` for the
227
246
  schema. A core that named one instance's box would not be a core.
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.1046",
3
+ "version": "1.19.1047",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.1046",
9
+ "version": "1.19.1047",
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.1046",
3
+ "version": "1.19.1047",
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",
@@ -7577,5 +7577,11 @@
7577
7577
  "id": "1003444",
7578
7578
  "text": "The hall's People page is gone, as you asked. An old People link now opens the Builders page instead, and People is no longer in the side menu. Searching for builders across projects comes back when the hub's community page is"
7579
7579
  }
7580
+ ],
7581
+ "1.19.1047": [
7582
+ {
7583
+ "id": "1004306",
7584
+ "text": "An update that would be refused no longer takes a database backup first, and the server keeps only the ten newest pre-update backups, so frequent update checks can't fill the disk."
7585
+ }
7580
7586
  ]
7581
7587
  }
@@ -571,7 +571,23 @@ function renderReport(r) {
571
571
  L.push(` Go-live · ${r.targetName}`);
572
572
  L.push(BAR);
573
573
  L.push(` ${r.from} → ${r.to}`);
574
- for (const step of r.steps || []) L.push(` ${step.ok ? '✓' : '✖'} ${step.name}${step.detail ? ` — ${step.detail}` : ''}`);
574
+ for (const step of r.steps || []) L.push(` ${step.ok ? (step.warn ? '⚠' : '✓') : '✖'} ${step.name}${step.detail ? ` — ${step.detail}` : ''}`);
575
+
576
+ // A refused rehearsal moved nothing, so the pin and read-back lines below would only
577
+ // alarm: "pin committed NO — the next reset will revert this bump" about a bump that
578
+ // never started (task 1004306).
579
+ if (r.failedAt === 'PREFLIGHT') {
580
+ const ex = failureExcerpt(r.raw);
581
+ if (ex.length) {
582
+ L.push('');
583
+ L.push(' what the upgrade said:');
584
+ for (const l of ex) L.push(` ${l}`);
585
+ }
586
+ L.push('');
587
+ L.push(` Nothing changed — still on ${r.from || 'the current core'}. No timer was stopped and no backup was written.`);
588
+ L.push('');
589
+ return L.join('\n');
590
+ }
575
591
 
576
592
  if (!r.ok) {
577
593
  const ex = failureExcerpt(r.raw);
@@ -681,24 +697,96 @@ function pinStep(mode, pinDirty, head) {
681
697
  };
682
698
  }
683
699
 
700
+ // How many pre-upgrade dumps a backupDir keeps (task 1004306). Nothing pruned them, so
701
+ // the directory grew by one dump per attempt for as long as the lane ran: 37 dumps and
702
+ // 1.7 GB on cloudbongos.com by 2026-09-26, at about ONE attempt a day. The count, not an age,
703
+ // is the bound because the cadence is what varies — ten is ten at daily or at every
704
+ // fifteen minutes. These are the extra copies taken right before a pin move; the
705
+ // nightly backup (db-backup-nightly.sh, 30 days) is the history and is not touched.
706
+ const PRE_UPGRADE_KEEP = 10;
707
+ // Exactly the name Phase A writes: pre-<version>-<YYYYMMDDTHHMMSSZ>.sql.gz. A looser
708
+ // `pre-*` would also take a dump an operator named by hand — the box has a
709
+ // `pre-lane-sweep-<ts>.sql.gz` in that directory, and it is not ours to delete.
710
+ const D8 = '[0-9]'.repeat(8);
711
+ const D6 = '[0-9]'.repeat(6);
712
+ const PRE_UPGRADE_GLOB = `pre-[0-9]*-${D8}T${D6}Z.sql.gz`;
713
+
714
+ // Delete all but the newest PRE_UPGRADE_KEEP pre-upgrade dumps and print how many went.
715
+ // Newest by mtime, never by name: the name sorts 1.19.99 after 1.19.100. It runs only
716
+ // after a dump succeeded, so a run of failures can never empty the directory, and the
717
+ // dump just written is always the newest and always kept. No `$` anywhere, so the
718
+ // outer shell that feeds this to `bash -c` expands nothing in it.
719
+ function pruneCommand(dir, keep = PRE_UPGRADE_KEEP) {
720
+ return `bash -c "cd ${dir} && ls -1t -- ${PRE_UPGRADE_GLOB} 2>/dev/null | tail -n +${keep + 1} | xargs -r rm -v -- | wc -l"`;
721
+ }
722
+
723
+ // Read the prune section back into a step. A prune that could not run is a WARNING,
724
+ // not a failure: the dump it follows is good, and refusing the upgrade over a full
725
+ // cleanup would trade a real deploy for tidiness. It still says so on screen.
726
+ function pruneStep(section, keep = PRE_UPGRADE_KEEP) {
727
+ const name = `prune old pre-upgrade backups (keep the newest ${keep})`;
728
+ const s = String(section == null ? '' : section).trim();
729
+ if (/^\d+$/.test(s)) return { name, ok: true, detail: s === '0' ? 'nothing to remove' : `removed ${s}` };
730
+ return { name, ok: true, warn: true, detail: `could not prune${s ? ` (${s.split('\n').pop()})` : ''} — the directory will keep growing until this is fixed` };
731
+ }
732
+
684
733
  function applyGoLive(target, targetName, targetVersion, from, opts = {}) {
685
734
  const pkg = target.registryPackage || '@bongos/core';
686
735
  const pinMode = pinModeOf(target);
687
736
  const steps = [];
737
+ const upgrade = upgradeCommand(target, targetVersion);
738
+
739
+ // Phase 0 — ask the upgrade whether it WILL go, before anything is stopped or copied
740
+ // (task 1004306). `bongos upgrade --dry-run` runs every refusal the real bump has —
741
+ // the artist gate, module compatibility, the database identity, the downgrade guard —
742
+ // and returns before it changes anything. Phase A used to come first, so on
743
+ // cloudbongos.com (2026-09-26 17:10 and 17:15 UTC) each attempt the artist gate
744
+ // refused had already stopped the deploy timer and written a 65 MB dump: one a day at
745
+ // the daily cadence, a full disk in about three days at every fifteen minutes.
746
+ // runGoLive's own gate read did not stop those runs either, and left no trace of what
747
+ // it saw: on --apply a plan with no halts is never printed, notes included. The bump's
748
+ // pre-flight asks the instance directly, and it was the first thing to say no.
749
+ const rehearsal = remote(target, `
750
+ set -uo pipefail
751
+ ${asOwner(target, `bash -c "cd ${target.instanceDir} && ${upgrade} --dry-run"`)}
752
+ `, opts);
753
+ steps.push({
754
+ name: 'pre-flight (the upgrade, rehearsed with --dry-run)',
755
+ ok: rehearsal.ok,
756
+ detail: rehearsal.ok ? null : 'refused — nothing was stopped and nothing was backed up',
757
+ });
758
+ if (!rehearsal.ok) return { ok: false, steps, from, to: targetVersion, targetName, pinMode, failedAt: 'PREFLIGHT', refused: true, raw: rehearsal };
688
759
 
689
760
  // Phase A — stop the pull-deploy timer, then back up. The timer's `reset --hard`
690
761
  // is the hazard: it would revert an uncommitted pin mid-flight. A co-tenant has no
691
762
  // such timer (see REQUIRED_FIELDS), so there is nothing to stop and no hazard.
763
+ //
764
+ // The dump must FAIL when pg_dump fails. `pg_dump | gzip > f` without pipefail
765
+ // reports gzip's success, leaving a 20-byte file that reads as a backup — harmless
766
+ // while nothing pruned, but the prune below keeps the newest ten, so a streak of
767
+ // those would retire every real dump (task 1004306). A failed dump removes its file
768
+ // and fails the phase, which stops the go-live before the pin moves: a bump with no
769
+ // backup behind it is not one to make unattended. $TS is expanded once by this
770
+ // script so the dump and its cleanup name the same file.
771
+ const backupDir = target.backupDir || '~/backups';
772
+ const dumpFile = `${backupDir}/pre-${targetVersion}-$TS.sql.gz`;
692
773
  const backup = target.backupDb
693
- ? asOwner(target, `bash -c "mkdir -p ${target.backupDir || '~/backups'} && pg_dump ${target.backupDb} | gzip > ${target.backupDir || '~/backups'}/pre-${targetVersion}-$(date -u +%Y%m%dT%H%M%SZ).sql.gz"`)
774
+ ? asOwner(target, `bash -c "set -o pipefail; mkdir -p ${backupDir} && { pg_dump ${target.backupDb} | gzip > ${dumpFile} || { rm -f ${dumpFile}; exit 1; }; }"`)
694
775
  : 'echo "(no backupDb configured — skipping)"';
776
+ // `|| echo`: under `set -e` a failed prune would otherwise fail the whole phase and
777
+ // stop a deploy whose backup is already safely on disk (see pruneStep).
778
+ const prune = target.backupDb
779
+ ? `echo "##PRUNE##"; ${asOwner(target, pruneCommand(backupDir))} || echo "prune exited non-zero"`
780
+ : '';
695
781
  const phaseA = remote(target, `
696
782
  set -euo pipefail
697
783
  ${SUDO_PREAMBLE}
698
784
  echo "##TIMER##"; ${target.deployTimer
699
785
  ? `$SUDO systemctl stop ${target.deployTimer} && $SUDO systemctl is-active ${target.deployTimer} || true`
700
786
  : 'echo "(no deployTimer configured — no pull-deploy to stop)"'}
787
+ TS="$(date -u +%Y%m%dT%H%M%SZ)"
701
788
  echo "##BACKUP##"; ${backup}
789
+ ${prune}
702
790
  echo "##END##"
703
791
  `, opts);
704
792
  steps.push({
@@ -706,11 +794,19 @@ echo "##END##"
706
794
  ok: phaseA.ok,
707
795
  detail: phaseA.ok ? null : (phaseA.stderr || '').trim().split('\n').pop(),
708
796
  });
709
- if (!phaseA.ok) return { ok: false, steps, from, to: targetVersion, targetName, pinMode, failedAt: 'PREPARE', raw: phaseA };
797
+ if (!phaseA.ok) {
798
+ // The timer may already be stopped — Phase A stops it before the dump, and a dump
799
+ // can now fail on its own (above). Put it back, as a failed upgrade does below.
800
+ if (target.deployTimer) remote(target, `${SUDO_PREAMBLE}\n$SUDO systemctl start ${target.deployTimer} || true`, opts);
801
+ return { ok: false, steps, from, to: targetVersion, targetName, pinMode, failedAt: 'PREPARE', raw: phaseA };
802
+ }
803
+ if (target.backupDb) {
804
+ const m = (phaseA.stdout || '').split('##PRUNE##')[1];
805
+ steps.push(pruneStep(m === undefined ? null : m.split('##')[0]));
806
+ }
710
807
 
711
808
  // Phase B — the upgrade itself. --commit-pin makes the bump durable against the
712
809
  // pull-deploy reset; auto-rollback stays ON (do NOT pass --no-rollback-on-failure).
713
- const upgrade = upgradeCommand(target, targetVersion);
714
810
  const phaseB = remote(target, `
715
811
  set -uo pipefail
716
812
  ${asOwner(target, `bash -c "cd ${target.instanceDir} && ${upgrade}"`)}
@@ -970,6 +1066,6 @@ module.exports = {
970
1066
  loadConfig, selectTarget, validateTarget, planGoLive, artistGateEntries, readArtistGate, ARTIST_GATE_PATH, ARTIST_GATE_LEVELS,
971
1067
  parseMarker, parseMarkerHits,
972
1068
  renderPlan, renderReport, failureExcerpt, asOwner, isSafeToken, refreshMain, isLocalTarget, gitRefExists, SUDO_PREAMBLE,
973
- applyGoLive, upgradeCommand, pinStep, pinModeOf,
1069
+ applyGoLive, upgradeCommand, pinStep, pinModeOf, pruneCommand, pruneStep, PRE_UPGRADE_KEEP, PRE_UPGRADE_GLOB,
974
1070
  CONFIG_TEMPLATE, HALTS, SEMVER_RE, PIN_MODES, DEFAULT_PIN_MODE,
975
1071
  };
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.1046'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.1047'; // 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/go_live.mjs CHANGED
@@ -595,15 +595,22 @@ const phaseC = ({ timer = true, pin = '', version = '1.19.306', installed = '1.1
595
595
  '##END##',
596
596
  ].filter(Boolean).join('\n');
597
597
 
598
- // Records every script fed to a shell. `upgradeFails` drives the rollback path.
599
- function fakeShell({ upgradeFails = false, phaseCOut = phaseC() } = {}) {
598
+ // Records every script fed to a shell. `upgradeFails` drives the rollback path;
599
+ // `rehearsalRefuses` makes the --dry-run pre-flight say no (task 1004306).
600
+ function fakeShell({ upgradeFails = false, rehearsalRefuses = false, backupFails = false, pruned = '0', phaseCOut = phaseC() } = {}) {
600
601
  const scripts = [];
601
602
  const fn = (cmd, args, opts = {}) => {
602
603
  const input = opts.input || '';
603
604
  scripts.push(input);
605
+ if (backupFails && input.includes('##BACKUP##')) return { status: 1, stdout: '##TIMER##\n##BACKUP##\n', stderr: 'pg_dump: error: connection to server failed' };
606
+ if (input.includes('bongos upgrade') && input.includes('--dry-run')) {
607
+ return rehearsalRefuses
608
+ ? { status: 1, stdout: '', stderr: "\n✖ 5 unresolved artist reviews hold this deploy — the project runs artist_gate 'strict'\n" }
609
+ : { status: 0, stdout: ' [dry-run] would pin', stderr: '' };
610
+ }
604
611
  if (input.includes('bongos upgrade')) return { status: upgradeFails ? 1 : 0, stdout: '', stderr: upgradeFails ? 'boom' : '' };
605
612
  if (input.includes('##PIN##')) return { status: 0, stdout: phaseCOut, stderr: '' };
606
- return { status: 0, stdout: '##TIMER##\n##BACKUP##\n##END##', stderr: '' };
613
+ return { status: 0, stdout: `##TIMER##\n##BACKUP##\n${input.includes('##PRUNE##') ? `##PRUNE##\n${pruned}\n` : ''}##END##`, stderr: '' };
607
614
  };
608
615
  fn.scripts = scripts;
609
616
  fn.all = () => scripts.join('\n');
@@ -660,7 +667,7 @@ test('with no deployTimer, nothing is stopped, started, or reported as restarted
660
667
  assert.ok(!/systemctl (stop|start)/.test(run.all()), 'must not touch a timer it has not got');
661
668
  assert.ok(!run.all().includes('undefined'), 'no field may interpolate as undefined');
662
669
  assert.ok(!stepNames(r).some((n) => /restart/.test(n)), 'no restart step for a timer that does not exist');
663
- assert.match(stepNames(r)[0], /^backup \(no pull-deploy timer\)$/);
670
+ assert.match(stepNames(r)[1], /^backup \(no pull-deploy timer\)$/);
664
671
  });
665
672
 
666
673
  test('the platform instance still stops the timer and puts it back', () => {
@@ -728,3 +735,135 @@ test('the shipped co-tenant template is itself valid config', () => {
728
735
  assert.equal(sel.target.pinMode, 'local-commit');
729
736
  assert.equal(sel.target.deployTimer, undefined);
730
737
  });
738
+
739
+ // ---------------------------------------------------------------------------
740
+ // Refuse before you copy; keep ten (task 1004306)
741
+ //
742
+ // On cloudbongos.com, 2026-09-26, the artist gate refused two upgrades at 17:10 and
743
+ // 17:15 UTC — and each had already stopped the deploy timer and written a 65 MB
744
+ // pg_dump, because the backup was Phase A and the refusal lived inside Phase B.
745
+ // Nothing pruned the directory (37 dumps, 1.7 GB, at about one attempt a day). At a
746
+ // fifteen-minute cadence that fills the disk in about three days.
747
+ // ---------------------------------------------------------------------------
748
+
749
+ const withBackup = { ...okTarget, backupDb: 'instdb', backupDir: '~/backups', runAs: 'me' };
750
+
751
+ test('a refused rehearsal stops the go-live before any timer is stopped or any dump is written', () => {
752
+ const run = fakeShell({ rehearsalRefuses: true });
753
+ const r = G.applyGoLive(withBackup, 'web', '1.19.306', '1.19.300', { run });
754
+
755
+ assert.equal(r.ok, false);
756
+ assert.equal(r.failedAt, 'PREFLIGHT');
757
+ assert.equal(r.refused, true);
758
+ assert.equal(run.scripts.length, 1, 'the rehearsal is the only thing that may run');
759
+ assert.ok(!/pg_dump/.test(run.all()), 'no backup may be written for a refused move');
760
+ assert.ok(!/systemctl stop/.test(run.all()), 'the deploy timer must be left running');
761
+ assert.deepEqual(stepNames(r), ['pre-flight (the upgrade, rehearsed with --dry-run)']);
762
+ });
763
+
764
+ test('the rehearsal is the REAL upgrade command plus --dry-run, and it runs first', () => {
765
+ const run = fakeShell();
766
+ const r = G.applyGoLive(withBackup, 'web', '1.19.306', '1.19.300', { run });
767
+ assert.equal(r.ok, true, `expected a clean run, got ${JSON.stringify(r.steps)}`);
768
+
769
+ // Same flags as the bump, so it asks exactly the gates the bump will ask — a
770
+ // rehearsal of a different command would be a check of something else.
771
+ assert.ok(run.scripts[0].includes(`${G.upgradeCommand(withBackup, '1.19.306')} --dry-run`));
772
+ const order = (needle) => run.scripts.findIndex((s) => s.includes(needle));
773
+ assert.ok(order('--dry-run') < order('pg_dump'), 'rehearse before the backup');
774
+ assert.ok(order('--dry-run') < order('systemctl stop'), 'rehearse before the timer stops');
775
+ assert.ok(order('pg_dump') < run.scripts.findIndex((s) => s.includes('bongos upgrade') && !s.includes('--dry-run')), 'the backup still precedes the real bump');
776
+ });
777
+
778
+ test('a refusal renders as "nothing changed", never as an unpushed pin', () => {
779
+ const run = fakeShell({ rehearsalRefuses: true });
780
+ const r = G.applyGoLive(withBackup, 'web', '1.19.306', '1.19.300', { run });
781
+ const out = G.renderReport(r);
782
+ assert.match(out, /Nothing changed — still on 1\.19\.300\. No timer was stopped and no backup was written\./);
783
+ assert.match(out, /5 unresolved artist reviews hold this deploy/, "the upgrade's own reason is shown");
784
+ assert.ok(!/pin committed/.test(out), 'no pin line for a bump that never started');
785
+ });
786
+
787
+ test('a successful dump is followed by a prune, and the step says what it removed', () => {
788
+ const run = fakeShell({ pruned: '3' });
789
+ const r = G.applyGoLive(withBackup, 'web', '1.19.306', '1.19.300', { run });
790
+ assert.equal(r.ok, true);
791
+ const phaseA = run.scripts.find((s) => s.includes('##BACKUP##'));
792
+ assert.ok(phaseA.indexOf('##PRUNE##') > phaseA.indexOf('pg_dump'), 'prune only after the dump');
793
+ const step = r.steps.find((s) => /prune/.test(s.name));
794
+ assert.equal(step.name, `prune old pre-upgrade backups (keep the newest ${G.PRE_UPGRADE_KEEP})`);
795
+ assert.equal(step.detail, 'removed 3');
796
+ assert.equal(G.PRE_UPGRADE_KEEP, 10);
797
+ });
798
+
799
+ test('no backupDb, no dump, and no prune', () => {
800
+ const run = fakeShell();
801
+ const r = G.applyGoLive(okTarget, 'web', '1.19.306', '1.19.300', { run });
802
+ assert.equal(r.ok, true);
803
+ assert.ok(!/##PRUNE##/.test(run.all()));
804
+ assert.ok(!r.steps.some((s) => /prune/.test(s.name)));
805
+ });
806
+
807
+ test('a prune that cannot run warns and does not fail the deploy', () => {
808
+ const step = G.pruneStep('prune exited non-zero');
809
+ assert.equal(step.ok, true, 'the dump is good; tidiness must not cost the deploy');
810
+ assert.equal(step.warn, true);
811
+ assert.match(step.detail, /could not prune \(prune exited non-zero\)/);
812
+ assert.equal(G.pruneStep(null).warn, true, 'a missing section is not "nothing to remove"');
813
+ assert.equal(G.pruneStep('0').detail, 'nothing to remove');
814
+ assert.match(G.renderReport({ targetName: 'x', from: 'a', to: 'b', ok: true, steps: [step] }), /⚠ prune old pre-upgrade backups/);
815
+ });
816
+
817
+ test('the dump fails when pg_dump fails, and a failed dump puts the stopped timer back', () => {
818
+ // The composed command: pipefail inside, and the partial file removed on failure.
819
+ const run = fakeShell();
820
+ G.applyGoLive(withBackup, 'web', '1.19.306', '1.19.300', { run });
821
+ const phaseA = run.scripts.find((s) => s.includes('##BACKUP##'));
822
+ assert.ok(phaseA.includes('bash -c "set -o pipefail; mkdir -p ~/backups && { pg_dump instdb | gzip > ~/backups/pre-1.19.306-$TS.sql.gz || { rm -f ~/backups/pre-1.19.306-$TS.sql.gz; exit 1; }; }"'), phaseA);
823
+ assert.match(phaseA, /^TS="\$\(date -u \+%Y%m%dT%H%M%SZ\)"$/m, 'one timestamp for the dump and its cleanup');
824
+
825
+ const failing = fakeShell({ backupFails: true });
826
+ const r = G.applyGoLive(withBackup, 'web', '1.19.306', '1.19.300', { run: failing });
827
+ assert.equal(r.ok, false);
828
+ assert.equal(r.failedAt, 'PREPARE');
829
+ assert.ok(!failing.scripts.some((s) => s.includes('bongos upgrade') && !s.includes('--dry-run')), 'no bump without a backup');
830
+ assert.match(failing.scripts[failing.scripts.length - 1], /systemctl start inst-deploy\.timer/, 'the stopped timer is restarted');
831
+ });
832
+
833
+ // The prune command itself, run for real in a scratch directory. The composed-string
834
+ // tests above cannot tell a glob that matches nothing from one that matches too much.
835
+ test('the prune keeps the newest ten by time, and leaves every other file alone', { skip: process.platform === 'win32' && 'needs a POSIX shell' }, async () => {
836
+ const { spawnSync } = await import('node:child_process');
837
+ const os = await import('node:os');
838
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'golive-prune-'));
839
+ try {
840
+ const dumps = [];
841
+ // 13 dumps, oldest first, whose NAME order disagrees with their TIME order.
842
+ for (let i = 0; i < 13; i++) {
843
+ const name = `pre-1.19.${i % 2 ? 99 - i : 100 + i}-202609${String(10 + i).padStart(2, '0')}T000000Z.sql.gz`;
844
+ const f = path.join(dir, name);
845
+ fs.writeFileSync(f, 'x');
846
+ const t = Date.UTC(2026, 8, 10 + i) / 1000;
847
+ fs.utimesSync(f, t, t);
848
+ dumps.push(name);
849
+ }
850
+ const others = ['pre-lane-sweep-20260823T203810Z.sql.gz', 'cloudbongos-2026-09-26T040000Z.sql.gz', 'notes.txt'];
851
+ for (const o of others) {
852
+ fs.writeFileSync(path.join(dir, o), 'x');
853
+ fs.utimesSync(path.join(dir, o), 1, 1); // older than every dump
854
+ }
855
+
856
+ const res = spawnSync('sh', ['-c', G.pruneCommand(dir)], { encoding: 'utf8' });
857
+ assert.equal(res.status, 0, res.stderr);
858
+ assert.equal(res.stdout.trim(), '3');
859
+ const left = new Set(fs.readdirSync(dir));
860
+ for (const d of dumps.slice(0, 3)) assert.ok(!left.has(d), `${d} is one of the three oldest and must go`);
861
+ for (const d of dumps.slice(3)) assert.ok(left.has(d), `${d} is one of the newest ten and must stay`);
862
+ for (const o of others) assert.ok(left.has(o), `${o} is not a pre-upgrade dump and must never be pruned`);
863
+
864
+ // A second run has nothing left to do.
865
+ assert.equal(spawnSync('sh', ['-c', G.pruneCommand(dir)], { encoding: 'utf8' }).stdout.trim(), '0');
866
+ } finally {
867
+ fs.rmSync(dir, { recursive: true, force: true });
868
+ }
869
+ });