@bongos/core 1.19.675 → 1.19.676

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.675",
6
- "core_contract": "1.19.675",
7
- "source_commit": "1341cb9d37a0965db7f80483f7f1cb8f3622b2c5",
5
+ "core_version": "1.19.676",
6
+ "core_contract": "1.19.676",
7
+ "source_commit": "9aad4d0005991e8ba38f4443b4e5611a13aa3af8",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-11T19:32:52.079Z",
9
+ "built_at": "2026-09-11T20:44:28.469Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 474,
@@ -17,7 +17,7 @@
17
17
  "gate": "passed"
18
18
  },
19
19
  "file_count": 2621,
20
- "tree_sha256": "554ecf20aaad28b8461021791d1e5c4d2c890a860a458885227f20429b946339",
20
+ "tree_sha256": "b556a6e9644d99b65ffdf9ac64384eee16b20ecfb7e31ed60c875d76297310f3",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/ask-for-help/SKILL.md",
@@ -2792,7 +2792,7 @@
2792
2792
  {
2793
2793
  "path": "docs/module-api-changelog.md",
2794
2794
  "mode": "0000644",
2795
- "sha256": "af50f99c27d85db0067d45e5e6eba133edcae8b3dd7388041b6824f4cb192e22"
2795
+ "sha256": "b8786c58575f93eb93661ee1d7e0745f0993f5666e5237ca073177df2d3ee9f4"
2796
2796
  },
2797
2797
  {
2798
2798
  "path": "docs/modules-contract.md",
@@ -2912,7 +2912,7 @@
2912
2912
  {
2913
2913
  "path": "docs/recipes/core-release-pipeline.md",
2914
2914
  "mode": "0000644",
2915
- "sha256": "06b938c2ad38915d3ed92b20a94c57621ae835f617b01ab02fc01129cd154e2c"
2915
+ "sha256": "86d266be2c1833484225886c1ed961b8f19d511e84226032dc65c9ccf18f5550"
2916
2916
  },
2917
2917
  {
2918
2918
  "path": "docs/recipes/devbox-app-release.md",
@@ -7767,12 +7767,12 @@
7767
7767
  {
7768
7768
  "path": "package-lock.json",
7769
7769
  "mode": "0000644",
7770
- "sha256": "795b9955321b5ecffd9d78f515649442016285cb21f728c4001ee97ebcc32ac5"
7770
+ "sha256": "3b0ebf2f1fb870085a40531295fb3c5989be5ab399e2493e43a1e3e844b0633c"
7771
7771
  },
7772
7772
  {
7773
7773
  "path": "package.json",
7774
7774
  "mode": "0000644",
7775
- "sha256": "33bcb1180ab5e54281e7c50d39a712d142b259f77f37b685f2bb4512b1c010fa"
7775
+ "sha256": "b4c1e3c5189ac7220af056624bdc0476a0d751c00aa76aba2e84416128280bc7"
7776
7776
  },
7777
7777
  {
7778
7778
  "path": "public-docs/index.html",
@@ -8327,7 +8327,7 @@
8327
8327
  {
8328
8328
  "path": "scripts/gds/go-live.js",
8329
8329
  "mode": "0000644",
8330
- "sha256": "1e14a452c0ae53384facd6a20526a90106cb479c414937ebb7fe180f11c5fa31"
8330
+ "sha256": "d1383b714ba0d8e4d0f5ceb097e45c1ed1af09caf648f7a43a6c6431c38fb422"
8331
8331
  },
8332
8332
  {
8333
8333
  "path": "scripts/gds/goal-names.js",
@@ -9102,7 +9102,7 @@
9102
9102
  {
9103
9103
  "path": "scripts/gds/upgrade.js",
9104
9104
  "mode": "0000644",
9105
- "sha256": "430d1f55212254d75144fb15a438ffc61aad1ea7d5176b36f39e87c30efeb2df"
9105
+ "sha256": "7a173ecf47b25007bba28f68787893352f429a52b34b7f7b4eae0d3791a5cdcf"
9106
9106
  },
9107
9107
  {
9108
9108
  "path": "scripts/gds/validate-design.js",
@@ -9532,7 +9532,7 @@
9532
9532
  {
9533
9533
  "path": "src/module-api.js",
9534
9534
  "mode": "0000644",
9535
- "sha256": "35f42575db36eb956e2479c681562e18a93d2d4973c8df0d79ac5a54048d4542"
9535
+ "sha256": "406c218b56b011b4560887f2ae4e13f20e227fa4404e6763649e2eac2b0f5292"
9536
9536
  },
9537
9537
  {
9538
9538
  "path": "src/module-loader/catalog.js",
@@ -10667,7 +10667,7 @@
10667
10667
  {
10668
10668
  "path": "tests/go_live.mjs",
10669
10669
  "mode": "0000644",
10670
- "sha256": "ce97c74d749ec3574c0683160b3ce5dad8a7af2dcf1ce53fd23e95f5e0ad480d"
10670
+ "sha256": "7528da9c6cd81d9876f0ffae0bf836844cb726a9bbb24cda44f0675d6f6ac0ea"
10671
10671
  },
10672
10672
  {
10673
10673
  "path": "tests/goal_abuse_matrix.mjs",
@@ -12982,7 +12982,7 @@
12982
12982
  {
12983
12983
  "path": "tests/upgrade_persist_pin.mjs",
12984
12984
  "mode": "0000644",
12985
- "sha256": "07fd48b255bbd2370580b62a8f77d7fc981b0dfdefcc5be6973a9c4d6cb49add"
12985
+ "sha256": "c0a96fbdc6a9f851925babfb0857c9a168374c4165c84884f5c4c56c7177dd58"
12986
12986
  },
12987
12987
  {
12988
12988
  "path": "tests/verify_task_guard.mjs",
@@ -1809,5 +1809,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
1809
1809
  landed since 1.19.673 with no explicit bump. run 34634515595. (task 1002620)
1810
1810
  1.19.675 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1811
1811
  landed since 1.19.674 with no explicit bump. run 34639371461. (task 1002620)
1812
+ 1.19.676 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1813
+ landed since 1.19.675 with no explicit bump. run 34645751352. (task 1002620)
1812
1814
  ---------------------------------------------------------------------------
1813
1815
  ```
@@ -168,6 +168,18 @@ Host topology (ssh alias, box paths, service names, health URL) lives in a host-
168
168
  `config/go-live.json`, never in the core — run `node scripts/gds/go-live.js --help` for the
169
169
  schema. A core that named one instance's box would not be a core.
170
170
 
171
+ **Upgrading a provisioned co-tenant** (task 1003521). A co-tenant under `/srv/<host>/<slug>` is
172
+ the same engine with two fields different, because two of this instance's assumptions do not hold
173
+ there: it has no pull-deploy timer to stop (only its `<slug>-db-backup.timer`), and its repo of
174
+ record is the **customer's** own GitHub repo, so pushing the pin there is both wrong and
175
+ uncredentialed. So omit `deployTimer` — the stop and the restart are skipped, and there is no
176
+ `reset --hard` to race — and set `"pinMode": "local-commit"`, which commits the pin without
177
+ pushing it. (`"leave-dirty"` leaves the bump in the working tree; `"push"` is the default and is
178
+ what the platform instance needs.) Everything else — the registry proof, `--verify-marker`, the
179
+ `pg_dump`, the health-gated upgrade with auto-rollback, the box-side version read-back — is
180
+ unchanged. The co-tenant's own scaffold gap is separate and still open:
181
+ [#1003520](https://cloudbongos.com/builders#/task/1003520).
182
+
171
183
  ### Step 1 — pick the version (your machine, main checkout)
172
184
 
173
185
  ```bash
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.675",
3
+ "version": "1.19.676",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.675",
9
+ "version": "1.19.676",
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.675",
3
+ "version": "1.19.676",
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",
@@ -77,8 +77,32 @@ function isSafeToken(v) {
77
77
  // rather than ignored: a typo'd `healthURL` that silently disables the health gate
78
78
  // is precisely the class of quiet failure this pipeline keeps being bitten by.
79
79
  // `ssh` is NOT required: a target with no ssh host is LOCAL (the box driving itself).
80
- const REQUIRED_FIELDS = ['instanceDir', 'service', 'healthUrl', 'versionUrl', 'deployTimer'];
81
- const OPTIONAL_FIELDS = ['ssh', 'local', 'deployService', 'npmEnvFile', 'runAs', 'backupDb', 'backupDir', 'env', 'registryPackage'];
80
+ //
81
+ // `deployTimer` is NOT required either that is CO-TENANT MODE (task 1003521). A
82
+ // provisioned instance under /srv/<host>/<slug> has no pull-deploy timer at all (only
83
+ // its <slug>-db-backup.timer), so requiring the field forced an operator to invent a
84
+ // unit name that the script would then try to `systemctl stop`. Absent, the stop and
85
+ // the restart are simply skipped: there is no `reset --hard` to race.
86
+ const REQUIRED_FIELDS = ['instanceDir', 'service', 'healthUrl', 'versionUrl'];
87
+ const OPTIONAL_FIELDS = ['ssh', 'local', 'deployTimer', 'deployService', 'npmEnvFile', 'runAs', 'backupDb', 'backupDir', 'env', 'registryPackage', 'pinMode'];
88
+
89
+ // How the bump is made durable — the other half of co-tenant mode (task 1003521).
90
+ //
91
+ // On the PLATFORM instance the repo of record is ours, and a pull-deploy
92
+ // `git reset --hard origin/main` reverts any pin that was not PUSHED (task 1002712 —
93
+ // thirteen releases were lost exactly that way). So the default is `push`.
94
+ //
95
+ // On a provisioned co-tenant the repo of record is the CUSTOMER's own GitHub repo, so
96
+ // that push is both wrong and, in practice, uncredentialed. With no pull-deploy timer
97
+ // nothing will revert the pin, so a local commit is enough to leave the tree clean:
98
+ // local-commit — commit the pin, do not push it
99
+ // leave-dirty — do not commit at all; the bump lives in the working tree by design
100
+ const PIN_MODES = ['push', 'local-commit', 'leave-dirty'];
101
+ const DEFAULT_PIN_MODE = 'push';
102
+
103
+ function pinModeOf(target) {
104
+ return (target && target.pinMode) || DEFAULT_PIN_MODE;
105
+ }
82
106
 
83
107
  function validateTarget(name, t) {
84
108
  const errors = [];
@@ -105,6 +129,10 @@ function validateTarget(name, t) {
105
129
  if (typeof v !== 'boolean') errors.push(`target "${name}".local must be true or false`);
106
130
  continue;
107
131
  }
132
+ if (k === 'pinMode') { // an enum, never interpolated into a command
133
+ if (!PIN_MODES.includes(v)) errors.push(`target "${name}".pinMode must be one of ${PIN_MODES.join(', ')} (got ${JSON.stringify(v)})`);
134
+ continue;
135
+ }
108
136
  if (!isSafeToken(v)) errors.push(`target "${name}".${k} contains characters that are unsafe in a shell (got ${JSON.stringify(v)})`);
109
137
  }
110
138
  return errors;
@@ -123,14 +151,27 @@ const CONFIG_TEMPLATE = `{
123
151
  "service": "my-instance.service", // systemd unit to restart
124
152
  "healthUrl": "http://127.0.0.1:3002/healthz", // box-side, NOT the apex
125
153
  "versionUrl": "http://127.0.0.1:3002/version", // box-side truth read back after
126
- "deployTimer": "my-deploy.timer", // pull-deploy timer (its reset --hard is the hazard)
154
+ "deployTimer": "my-deploy.timer", // optional: pull-deploy timer (its reset --hard is the hazard)
127
155
  "deployService": "my-deploy.service", // optional: forced tick proves the pin stuck
128
156
  "npmEnvFile": "/etc/my/npm.env", // optional: root-only NPM_TOKEN for a PRIVATE package
129
157
  "runAs": "me", // optional: unprivileged owner of the instance
130
158
  "backupDb": "mydb", // optional: pg_dump before the bump
131
159
  "backupDir": "~/backups",
132
160
  "env": { "PGDATABASE": "mydb" }, // optional: load-bearing for migrate
133
- "registryPackage": "@bongos/core"
161
+ "registryPackage": "@bongos/core",
162
+ "pinMode": "push" // optional: push (default) | local-commit | leave-dirty
163
+ },
164
+ "a-co-tenant": {
165
+ // A PROVISIONED instance: no pull-deploy timer to stop, and the repo of record
166
+ // is the customer's own GitHub repo — so the pin is committed, never pushed.
167
+ "ssh": "my-box",
168
+ "instanceDir": "/srv/my-host/their-slug",
169
+ "service": "their-slug.service",
170
+ "healthUrl": "http://127.0.0.1:3101/healthz",
171
+ "versionUrl": "http://127.0.0.1:3101/version",
172
+ "runAs": "their-slug",
173
+ "env": { "PGDATABASE": "their_slug" },
174
+ "pinMode": "local-commit"
134
175
  }
135
176
  }
136
177
  }`;
@@ -455,7 +496,10 @@ function renderReport(r) {
455
496
 
456
497
  L.push('');
457
498
  L.push(` live reads back ${r.liveVersion || '(unread)'}`);
458
- L.push(` pin committed ${r.pinCommitted ? 'yes' : 'NO — the next pull-deploy reset will revert this bump'}`);
499
+ const mode = r.pinMode || DEFAULT_PIN_MODE;
500
+ if (mode === 'leave-dirty') L.push(' pin left in the working tree (pinMode leave-dirty)');
501
+ else if (r.pinCommitted) L.push(` pin committed yes${mode === 'local-commit' ? ' (local commit — this target\'s remote is not ours to push)' : ''}`);
502
+ else L.push(' pin committed NO — the next pull-deploy reset will revert this bump');
459
503
  L.push('');
460
504
  return L.join('\n');
461
505
  }
@@ -506,36 +550,78 @@ echo "##END##"
506
550
  };
507
551
  }
508
552
 
553
+ // The `bongos upgrade` invocation, as its own function so the pin-mode wiring is
554
+ // assertable without a box.
555
+ //
556
+ // Both non-default modes pass --pin-no-push, which is what tells upgrade.js the repo of
557
+ // record is not ours: `local-commit` pairs it with --commit-pin (commit, no push), and
558
+ // `leave-dirty` sends it alone, which downgrades upgrade.js's "PIN NOT COMMITTED" alarm
559
+ // to a note. That alarm is correct on a pull-deploy instance and misleading here, and an
560
+ // alarm an operator learns to ignore is worse than no alarm.
561
+ function upgradeCommand(target, targetVersion) {
562
+ const mode = pinModeOf(target);
563
+ return [
564
+ `./node_modules/.bin/bongos upgrade`,
565
+ `--to ${targetVersion}`, '--registry',
566
+ `--instance ${target.instanceDir}`,
567
+ `--service ${target.service}`,
568
+ `--health-url ${target.healthUrl}`,
569
+ ...(mode === 'leave-dirty' ? [] : ['--commit-pin']),
570
+ ...(mode === 'push' ? [] : ['--pin-no-push']),
571
+ '--force',
572
+ ].join(' ');
573
+ }
574
+
575
+ // Whether the post-bump pin state is what this target ASKED for. Under `leave-dirty` a
576
+ // modified working tree is the configured outcome, so reporting it as the durability
577
+ // failure it is on a pull-deploy instance would make a correct co-tenant run read as
578
+ // broken — the one place the three modes must not share a verdict.
579
+ function pinStep(mode, pinDirty, head) {
580
+ if (mode === 'leave-dirty') {
581
+ return {
582
+ name: 'pin left in the working tree (pinMode leave-dirty)',
583
+ ok: true,
584
+ detail: pinDirty ? 'package.json/-lock MODIFIED by design — this target has no pull-deploy to revert it' : (head || null),
585
+ };
586
+ }
587
+ return {
588
+ name: mode === 'local-commit' ? 'pin committed locally (not pushed)' : 'pin committed + pushed',
589
+ ok: !pinDirty,
590
+ detail: pinDirty ? 'package.json/-lock still MODIFIED — the next reset --hard reverts this bump' : (head || null),
591
+ };
592
+ }
593
+
509
594
  function applyGoLive(target, targetName, targetVersion, from, opts = {}) {
510
595
  const pkg = target.registryPackage || '@bongos/core';
596
+ const pinMode = pinModeOf(target);
511
597
  const steps = [];
512
598
  const src = target.npmEnvFile ? `set -a; . ${target.npmEnvFile}; set +a` : 'true';
513
599
 
514
600
  // Phase A — stop the pull-deploy timer, then back up. The timer's `reset --hard`
515
- // is the hazard: it would revert an uncommitted pin mid-flight.
601
+ // is the hazard: it would revert an uncommitted pin mid-flight. A co-tenant has no
602
+ // such timer (see REQUIRED_FIELDS), so there is nothing to stop and no hazard.
516
603
  const backup = target.backupDb
517
604
  ? 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"`)
518
605
  : 'echo "(no backupDb configured — skipping)"';
519
606
  const phaseA = remote(target, `
520
607
  set -euo pipefail
521
608
  ${SUDO_PREAMBLE}
522
- echo "##TIMER##"; $SUDO systemctl stop ${target.deployTimer} && $SUDO systemctl is-active ${target.deployTimer} || true
609
+ echo "##TIMER##"; ${target.deployTimer
610
+ ? `$SUDO systemctl stop ${target.deployTimer} && $SUDO systemctl is-active ${target.deployTimer} || true`
611
+ : 'echo "(no deployTimer configured — no pull-deploy to stop)"'}
523
612
  echo "##BACKUP##"; ${backup}
524
613
  echo "##END##"
525
614
  `, opts);
526
- steps.push({ name: `stop ${target.deployTimer} + backup`, ok: phaseA.ok, detail: phaseA.ok ? null : (phaseA.stderr || '').trim().split('\n').pop() });
527
- if (!phaseA.ok) return { ok: false, steps, from, to: targetVersion, targetName, failedAt: 'PREPARE', raw: phaseA };
615
+ steps.push({
616
+ name: target.deployTimer ? `stop ${target.deployTimer} + backup` : 'backup (no pull-deploy timer)',
617
+ ok: phaseA.ok,
618
+ detail: phaseA.ok ? null : (phaseA.stderr || '').trim().split('\n').pop(),
619
+ });
620
+ if (!phaseA.ok) return { ok: false, steps, from, to: targetVersion, targetName, pinMode, failedAt: 'PREPARE', raw: phaseA };
528
621
 
529
622
  // Phase B — the upgrade itself. --commit-pin makes the bump durable against the
530
623
  // pull-deploy reset; auto-rollback stays ON (do NOT pass --no-rollback-on-failure).
531
- const upgrade = [
532
- `./node_modules/.bin/bongos upgrade`,
533
- `--to ${targetVersion}`, '--registry',
534
- `--instance ${target.instanceDir}`,
535
- `--service ${target.service}`,
536
- `--health-url ${target.healthUrl}`,
537
- '--commit-pin', '--force',
538
- ].join(' ');
624
+ const upgrade = upgradeCommand(target, targetVersion);
539
625
  const phaseB = remote(target, `
540
626
  set -uo pipefail
541
627
  ${src}
@@ -549,9 +635,10 @@ ${asOwner(target, `bash -c "cd ${target.instanceDir} && ${upgrade}"`, { needsNpm
549
635
  });
550
636
  if (!phaseB.ok || rolledBack) {
551
637
  // Restart the timer even on failure — leaving it stopped silently disables
552
- // the instance's pull-deploy until someone notices.
553
- remote(target, `${SUDO_PREAMBLE}\n$SUDO systemctl start ${target.deployTimer} || true`, opts);
554
- return { ok: false, steps, from, to: targetVersion, targetName, failedAt: 'UPGRADE', rolledBack, raw: phaseB };
638
+ // the instance's pull-deploy until someone notices. Nothing was stopped on a
639
+ // co-tenant, so there is nothing to put back.
640
+ if (target.deployTimer) remote(target, `${SUDO_PREAMBLE}\n$SUDO systemctl start ${target.deployTimer} || true`, opts);
641
+ return { ok: false, steps, from, to: targetVersion, targetName, pinMode, failedAt: 'UPGRADE', rolledBack, raw: phaseB };
555
642
  }
556
643
 
557
644
  // Phase C — restart the timer, force one tick, and read back the BOX-SIDE truth.
@@ -560,7 +647,7 @@ ${asOwner(target, `bash -c "cd ${target.instanceDir} && ${upgrade}"`, { needsNpm
560
647
  const phaseC = remote(target, `
561
648
  set -uo pipefail
562
649
  ${SUDO_PREAMBLE}
563
- echo "##TIMERSTART##"; $SUDO systemctl start ${target.deployTimer}; $SUDO systemctl is-active ${target.deployTimer}
650
+ ${target.deployTimer ? `echo "##TIMERSTART##"; $SUDO systemctl start ${target.deployTimer}; $SUDO systemctl is-active ${target.deployTimer}` : ''}
564
651
  echo "##PIN##"; ( cd ${target.instanceDir} && ${target.runAs ? `sudo -u ${target.runAs} -H ` : ''}git status --porcelain -- package.json package-lock.json )
565
652
  echo "##HEAD##"; ( cd ${target.instanceDir} && ${target.runAs ? `sudo -u ${target.runAs} -H ` : ''}git log -1 --format='%h %s' )
566
653
  ${target.deployService ? `echo "##TICK##"; $SUDO systemctl start ${target.deployService} || true; $SUDO journalctl -u ${target.deployService} -n 20 --no-pager | tail -8` : ''}
@@ -578,13 +665,13 @@ echo "##END##"
578
665
  let liveVersion = null;
579
666
  try { liveVersion = JSON.parse(section('VERSION') || '{}').coreVersion || null; } catch { liveVersion = null; }
580
667
 
581
- steps.push({ name: `restart ${target.deployTimer}`, ok: (section('TIMERSTART') || '').includes('active') });
582
- steps.push({ name: 'pin committed + pushed', ok: !pinDirty, detail: pinDirty ? 'package.json/-lock still MODIFIED — the next reset --hard reverts this bump' : (section('HEAD') || null) });
668
+ if (target.deployTimer) steps.push({ name: `restart ${target.deployTimer}`, ok: (section('TIMERSTART') || '').includes('active') });
669
+ steps.push(pinStep(pinMode, pinDirty, section('HEAD')));
583
670
  steps.push({ name: 'installed package version', ok: installed === targetVersion, detail: installed === targetVersion ? installed : `reports ${installed}, expected ${targetVersion}` });
584
671
  steps.push({ name: 'live reads back (box-side)', ok: liveVersion === targetVersion, detail: liveVersion === targetVersion ? liveVersion : `reports ${liveVersion}, expected ${targetVersion}` });
585
672
 
586
673
  const ok = steps.every((s) => s.ok);
587
- return { ok, steps, from, to: targetVersion, targetName, liveVersion, installed, pinCommitted: !pinDirty, raw: phaseC };
674
+ return { ok, steps, from, to: targetVersion, targetName, pinMode, liveVersion, installed, pinCommitted: !pinDirty, raw: phaseC };
588
675
  }
589
676
 
590
677
  // ---------------------------------------------------------------------------
@@ -608,6 +695,11 @@ go-live — move an enrolled instance's core pin, as one command.
608
695
  node scripts/gds/go-live.js --no-divergence skip the git-derived checks (needs --to)
609
696
  node scripts/gds/go-live.js --json machine-readable
610
697
 
698
+ CO-TENANT TARGETS. A provisioned instance has no pull-deploy timer and its repo of
699
+ record is the customer's own, so omit "deployTimer" (the stop/restart is skipped) and
700
+ set "pinMode": "local-commit" (the pin is committed, never pushed). "leave-dirty"
701
+ leaves the bump in the working tree; "push" (the default) is the platform instance.
702
+
611
703
  Host topology is NOT baked into the core. ${DEFAULT_CONFIG} (host-owned, not published):
612
704
 
613
705
  ${CONFIG_TEMPLATE}
@@ -770,5 +862,6 @@ if (require.main === module) {
770
862
  module.exports = {
771
863
  loadConfig, selectTarget, validateTarget, planGoLive, parseMarker, parseMarkerHits,
772
864
  renderPlan, renderReport, failureExcerpt, asOwner, isSafeToken, NPM_AUTH_PREAMBLE, refreshMain, isLocalTarget, gitRefExists, SUDO_PREAMBLE,
773
- CONFIG_TEMPLATE, HALTS, SEMVER_RE,
865
+ applyGoLive, upgradeCommand, pinStep, pinModeOf,
866
+ CONFIG_TEMPLATE, HALTS, SEMVER_RE, PIN_MODES, DEFAULT_PIN_MODE,
774
867
  };
@@ -160,7 +160,12 @@ function dirtyPinFiles(instanceDir, run = spawnSync) {
160
160
  return (r.stdout || '').split('\n').map((l) => l.slice(3).trim()).filter(Boolean);
161
161
  }
162
162
 
163
- function persistPin({ instanceDir, fromVersion, toVersion, commitPin, remote = 'origin', dryRun = false },
163
+ // `push: false` is CO-TENANT MODE (task 1003521). The reasoning above assumes the repo
164
+ // of record is OURS; on a provisioned instance it is the customer's own GitHub repo, so
165
+ // pushing there is both wrong and uncredentialed. Such an instance also has no
166
+ // pull-deploy timer, so nothing will `reset --hard` the pin away — the commit alone
167
+ // makes it durable, and leaves the tree clean for the customer's own pulls.
168
+ function persistPin({ instanceDir, fromVersion, toVersion, commitPin, push = true, remote = 'origin', dryRun = false },
164
169
  run = spawnSync, log = console.log, err = console.error) {
165
170
  const dirty = dirtyPinFiles(instanceDir, run);
166
171
  if (dirty === null) return { applicable: false, reason: 'not a git repo' };
@@ -169,6 +174,15 @@ function persistPin({ instanceDir, fromVersion, toVersion, commitPin, remote = '
169
174
  const branch = gitCurrentBranch(instanceDir, run);
170
175
 
171
176
  if (!commitPin) {
177
+ if (!push) {
178
+ // No pull-deploy of ours reaches this instance, so an uncommitted pin is untidy
179
+ // rather than doomed. Crying wolf here would teach an operator to ignore the
180
+ // warning that DOES matter on the platform instance.
181
+ err(` ! pin not committed — ${dirty.join(', ')} changed in the working tree only.`);
182
+ err(' This target pulls from a repo that is not ours, so nothing here will revert it —');
183
+ err(' but the tree stays dirty until it is committed. Re-run with --commit-pin to commit it.');
184
+ return { applicable: true, committed: false, pushed: false, dirty, warned: true };
185
+ }
172
186
  // LOUD — this is the whole point. A silent uncommitted pin is the bug.
173
187
  err(` ! PIN NOT COMMITTED — ${dirty.join(', ')} changed in the working tree only.`);
174
188
  err(' On a pull-deploy instance the next push to the repo of record makes deploy.sh');
@@ -184,7 +198,9 @@ function persistPin({ instanceDir, fromVersion, toVersion, commitPin, remote = '
184
198
  }
185
199
 
186
200
  if (dryRun) {
187
- log(` [dry-run] would commit + push ${dirty.join(', ')} to ${remote}/${branch || 'main'}`);
201
+ log(push
202
+ ? ` [dry-run] would commit + push ${dirty.join(', ')} to ${remote}/${branch || 'main'}`
203
+ : ` [dry-run] would commit ${dirty.join(', ')} locally (no push — this remote is not ours)`);
188
204
  return { applicable: true, committed: false, pushed: false, dirty, dryRun: true };
189
205
  }
190
206
 
@@ -201,8 +217,13 @@ function persistPin({ instanceDir, fromVersion, toVersion, commitPin, remote = '
201
217
  }
202
218
  log(` ✓ committed the pin (${dirty.join(', ')})`);
203
219
 
204
- const push = run('git', ['push', remote, `HEAD:${branch || 'main'}`], { cwd: instanceDir, encoding: 'utf8' });
205
- if (!push || push.error || push.status !== 0) {
220
+ if (!push) {
221
+ log(' not pushed, by request this instance\'s repo of record is not ours, and no pull-deploy of ours can revert the commit');
222
+ return { applicable: true, committed: true, pushed: false, dirty, pushSkipped: true };
223
+ }
224
+
225
+ const pushed = run('git', ['push', remote, `HEAD:${branch || 'main'}`], { cwd: instanceDir, encoding: 'utf8' });
226
+ if (!pushed || pushed.error || pushed.status !== 0) {
206
227
  // Committed but not pushed is still doomed: deploy.sh resets to the REMOTE.
207
228
  err(` ! commit landed locally but the push to ${remote} FAILED — this bump is still not durable.`);
208
229
  err(` Push it: git -C ${instanceDir} push ${remote} HEAD:${branch || 'main'}`);
@@ -984,6 +1005,7 @@ async function runUpgrade(opts, deps = {}) {
984
1005
  fromVersion,
985
1006
  toVersion: targetVersion,
986
1007
  commitPin: opts.commitPin,
1008
+ push: !opts.pinNoPush,
987
1009
  remote: opts.gitRemote || 'origin',
988
1010
  dryRun: opts.dryRun,
989
1011
  }, run, log, err);
@@ -1006,6 +1028,7 @@ function parseArgs(argv) {
1006
1028
  pinPath: arg('--pin-path', argv),
1007
1029
  registry: hasFlag('--registry', argv), // ADR 0134: pull @bongos/core@<--to> from the npm registry instead of vendoring
1008
1030
  commitPin: hasFlag('--commit-pin', argv), // task 1002712: commit + push package.json/-lock so a pull-deploy reset cannot revert the bump
1031
+ pinNoPush: hasFlag('--pin-no-push', argv), // task 1003521 co-tenant mode: commit the pin but leave the customer's own remote alone
1009
1032
  gitRemote: arg('--git-remote', argv),
1010
1033
  dryRun: hasFlag('--dry-run', argv),
1011
1034
  force: hasFlag('--force', argv),
@@ -1042,6 +1065,7 @@ async function main(argv = process.argv.slice(2)) {
1042
1065
  ' --skip-pin-verify do not verify the integrity pin (tree_sha256 + tarball.sha256) after install',
1043
1066
  ' --require-pin treat a MISSING reference manifest as fatal (default: warn — a MISMATCH is always fatal)',
1044
1067
  ' --no-rollback-on-failure do NOT auto-revert on a failed install/pin-verify/migrate/health check (default: auto-rollback ON)',
1068
+ ' --pin-no-push with --commit-pin: commit the pin but do NOT push it — for an instance whose repo of record is not ours (a provisioned co-tenant), where the push is wrong and no pull-deploy can revert the commit',
1045
1069
  ' --dry-run show the plan; change nothing',
1046
1070
  ' --force skip the clean-tree + same-version guards',
1047
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.675'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.676'; // 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
@@ -144,9 +144,11 @@ test('a typo’d field is reported, never silently ignored', () => {
144
144
 
145
145
  test('missing required fields are named individually', () => {
146
146
  const errs = G.validateTarget('t', { ssh: 'box' });
147
- for (const f of ['instanceDir', 'service', 'healthUrl', 'versionUrl', 'deployTimer']) {
147
+ for (const f of ['instanceDir', 'service', 'healthUrl', 'versionUrl']) {
148
148
  assert.match(errs.join(' '), new RegExp(`missing required field "${f}"`));
149
149
  }
150
+ // deployTimer is co-tenant-optional (task 1003521) and must NOT be demanded.
151
+ assert.ok(!/missing required field "deployTimer"/.test(errs.join(' ')));
150
152
  });
151
153
 
152
154
  test('a valid target passes clean', () => {
@@ -393,7 +395,7 @@ test('ssh is OPTIONAL — a local target validates clean', () => {
393
395
 
394
396
  test('the remaining required fields are still enforced for a local target', () => {
395
397
  const errs = G.validateTarget('local', { instanceDir: '/i' });
396
- for (const f of ['service', 'healthUrl', 'versionUrl', 'deployTimer']) {
398
+ for (const f of ['service', 'healthUrl', 'versionUrl']) {
397
399
  assert.match(errs.join(' '), new RegExp(`missing required field "${f}"`));
398
400
  }
399
401
  // ssh must NOT be demanded any more.
@@ -411,3 +413,173 @@ test('gitRefExists reports rather than throwing', () => {
411
413
  assert.equal(G.gitRefExists('x', { run: () => ({ status: 1 }) }), false);
412
414
  assert.equal(G.gitRefExists('x', { run: () => ({ error: new Error('ENOENT') }) }), false);
413
415
  });
416
+
417
+ // ---------------------------------------------------------------------------
418
+ // Co-tenant mode (task 1003521)
419
+ //
420
+ // go-live drove the PLATFORM instance and could not drive a provisioned co-tenant,
421
+ // for two structural reasons, so mercury had to be upgraded by hand from a box-side
422
+ // script — the exact one-command path this file exists to provide:
423
+ //
424
+ // 1. deployTimer was REQUIRED. A co-tenant has no pull-deploy timer at all (only
425
+ // its <slug>-db-backup.timer), so the config could not even be AUTHORED without
426
+ // inventing a unit name that the script would then try to `systemctl stop`.
427
+ // 2. The pin was always PUSHED. On a co-tenant the repo of record is the customer's
428
+ // own GitHub repo, so that push is both wrong and, in practice, uncredentialed.
429
+ //
430
+ // The assertions below are on the scripts applyGoLive COMPOSES and the verdicts it
431
+ // reaches — `run` is injected, so nothing here touches a box.
432
+ // ---------------------------------------------------------------------------
433
+
434
+ const coTenant = {
435
+ ssh: 'my-box',
436
+ instanceDir: '/srv/my-host/their-slug',
437
+ service: 'their-slug.service',
438
+ healthUrl: 'http://127.0.0.1:3101/healthz',
439
+ versionUrl: 'http://127.0.0.1:3101/version',
440
+ pinMode: 'local-commit',
441
+ };
442
+
443
+ // Canned phase-C output. The sections are what applyGoLive parses its verdicts from.
444
+ const phaseC = ({ timer = true, pin = '', version = '1.19.306', installed = '1.19.306' } = {}) => [
445
+ timer ? '##TIMERSTART##\nactive' : '',
446
+ `##PIN##\n${pin}`,
447
+ '##HEAD##\ndeadbee pin @bongos/core 1.19.306',
448
+ `##VERSION##\n{"coreVersion":"${version}"}`,
449
+ `##INSTALLED##\n${installed}`,
450
+ '##END##',
451
+ ].filter(Boolean).join('\n');
452
+
453
+ // Records every script fed to a shell. `upgradeFails` drives the rollback path.
454
+ function fakeShell({ upgradeFails = false, phaseCOut = phaseC() } = {}) {
455
+ const scripts = [];
456
+ const fn = (cmd, args, opts = {}) => {
457
+ const input = opts.input || '';
458
+ scripts.push(input);
459
+ if (input.includes('bongos upgrade')) return { status: upgradeFails ? 1 : 0, stdout: '', stderr: upgradeFails ? 'boom' : '' };
460
+ if (input.includes('##PIN##')) return { status: 0, stdout: phaseCOut, stderr: '' };
461
+ return { status: 0, stdout: '##TIMER##\n##BACKUP##\n##END##', stderr: '' };
462
+ };
463
+ fn.scripts = scripts;
464
+ fn.all = () => scripts.join('\n');
465
+ return fn;
466
+ }
467
+
468
+ const stepNames = (r) => (r.steps || []).map((s) => s.name);
469
+
470
+ test('pinMode defaults to push, so an existing config keeps its behaviour', () => {
471
+ assert.equal(G.pinModeOf(okTarget), 'push');
472
+ assert.equal(G.pinModeOf({}), 'push');
473
+ assert.equal(G.pinModeOf(undefined), 'push');
474
+ assert.equal(G.pinModeOf({ pinMode: 'leave-dirty' }), 'leave-dirty');
475
+ });
476
+
477
+ test('a bogus pinMode is REFUSED, and each documented one validates', () => {
478
+ assert.match(G.validateTarget('t', { ...okTarget, pinMode: 'shove-it' }).join(' '), /pinMode must be one of/);
479
+ for (const m of G.PIN_MODES) assert.deepEqual(G.validateTarget('t', { ...okTarget, pinMode: m }), []);
480
+ });
481
+
482
+ test('push mode pushes the pin; the two co-tenant modes never do', () => {
483
+ const cmd = (t) => G.upgradeCommand(t, '1.19.306');
484
+
485
+ // The platform instance: --commit-pin ALONE, which pushes (task 1002712 — thirteen
486
+ // releases were lost to an unpushed pin, so this is the regression that matters).
487
+ const push = cmd({ ...okTarget, pinMode: 'push' });
488
+ assert.ok(push.includes('--commit-pin'), 'push mode must still commit the pin');
489
+ assert.ok(!push.includes('--pin-no-push'), 'push mode must NOT suppress the push');
490
+
491
+ // local-commit: commit it, leave the customer's remote alone.
492
+ const local = cmd({ ...okTarget, pinMode: 'local-commit' });
493
+ assert.ok(local.includes('--commit-pin') && local.includes('--pin-no-push'));
494
+
495
+ // leave-dirty: no commit at all, and --pin-no-push so upgrade.js does not raise the
496
+ // pull-deploy alarm at an instance that has no pull-deploy.
497
+ const dirty = cmd({ ...okTarget, pinMode: 'leave-dirty' });
498
+ assert.ok(!dirty.includes('--commit-pin'), 'leave-dirty must not commit');
499
+ assert.ok(dirty.includes('--pin-no-push'));
500
+
501
+ // Unchanged for every mode: auto-rollback stays ON.
502
+ for (const c of [push, local, dirty]) assert.ok(!c.includes('--no-rollback-on-failure'));
503
+ });
504
+
505
+ test('a co-tenant target validates with NO deployTimer — the field that blocked the config', () => {
506
+ assert.deepEqual(G.validateTarget('a-co-tenant', coTenant), []);
507
+ });
508
+
509
+ test('with no deployTimer, nothing is stopped, started, or reported as restarted', () => {
510
+ const run = fakeShell({ phaseCOut: phaseC({ timer: false }) });
511
+ const r = G.applyGoLive(coTenant, 'a-co-tenant', '1.19.306', '1.19.300', { run });
512
+
513
+ assert.equal(r.ok, true, `expected a clean run, got ${JSON.stringify(stepNames(r))}`);
514
+ // The bug shape: an invented unit name reaching systemctl, or a literal `undefined`.
515
+ assert.ok(!/systemctl (stop|start)/.test(run.all()), 'must not touch a timer it has not got');
516
+ assert.ok(!run.all().includes('undefined'), 'no field may interpolate as undefined');
517
+ assert.ok(!stepNames(r).some((n) => /restart/.test(n)), 'no restart step for a timer that does not exist');
518
+ assert.match(stepNames(r)[0], /^backup \(no pull-deploy timer\)$/);
519
+ });
520
+
521
+ test('the platform instance still stops the timer and puts it back', () => {
522
+ const run = fakeShell();
523
+ const r = G.applyGoLive({ ...okTarget, deployTimer: 'inst-deploy.timer' }, 'web', '1.19.306', '1.19.300', { run });
524
+
525
+ assert.equal(r.ok, true);
526
+ assert.match(run.all(), /systemctl stop inst-deploy\.timer/);
527
+ assert.match(run.all(), /systemctl start inst-deploy\.timer/);
528
+ assert.ok(stepNames(r).includes('restart inst-deploy.timer'));
529
+ });
530
+
531
+ test('a failed upgrade restarts the timer it stopped — and asks for none when it stopped none', () => {
532
+ const withTimer = fakeShell({ upgradeFails: true });
533
+ const a = G.applyGoLive(okTarget, 'web', '1.19.306', '1.19.300', { run: withTimer });
534
+ assert.equal(a.ok, false);
535
+ assert.equal(a.failedAt, 'UPGRADE');
536
+ assert.match(withTimer.all(), /systemctl start inst-deploy\.timer/);
537
+
538
+ const coT = fakeShell({ upgradeFails: true });
539
+ const b = G.applyGoLive(coTenant, 'a-co-tenant', '1.19.306', '1.19.300', { run: coT });
540
+ assert.equal(b.ok, false);
541
+ assert.ok(!/systemctl start/.test(coT.all()), 'nothing was stopped, so nothing is restarted');
542
+ });
543
+
544
+ test('a dirty pin is the durability failure it is under push and local-commit', () => {
545
+ for (const pinMode of ['push', 'local-commit']) {
546
+ const run = fakeShell({ phaseCOut: phaseC({ pin: ' M package.json' }) });
547
+ const r = G.applyGoLive({ ...okTarget, pinMode }, 'web', '1.19.306', '1.19.300', { run });
548
+ assert.equal(r.ok, false, `${pinMode} must fail on an uncommitted pin`);
549
+ assert.equal(r.pinCommitted, false);
550
+ }
551
+ });
552
+
553
+ test('under leave-dirty the SAME dirty pin is the configured outcome, not a failure', () => {
554
+ const run = fakeShell({ phaseCOut: phaseC({ pin: ' M package.json' }) });
555
+ const r = G.applyGoLive({ ...okTarget, pinMode: 'leave-dirty' }, 'web', '1.19.306', '1.19.300', { run });
556
+ assert.equal(r.ok, true, `a correct leave-dirty run must not read as broken: ${JSON.stringify(stepNames(r))}`);
557
+ assert.match(stepNames(r).join(' '), /leave-dirty/);
558
+ });
559
+
560
+ test('the health and version gates are untouched by pin mode', () => {
561
+ // A co-tenant serving the WRONG version must still fail, or co-tenant mode would be
562
+ // a way to get a green report out of a bump that did not take.
563
+ const run = fakeShell({ phaseCOut: phaseC({ timer: false, version: '1.19.300' }) });
564
+ const r = G.applyGoLive(coTenant, 'a-co-tenant', '1.19.306', '1.19.300', { run });
565
+ assert.equal(r.ok, false);
566
+ assert.match(stepNames(r).join(' '), /live reads back/);
567
+ });
568
+
569
+ test('the report does not promise a push it did not make', () => {
570
+ const base = { targetName: 'x', from: '1.19.300', to: '1.19.306', steps: [], liveVersion: '1.19.306', ok: true };
571
+ assert.match(G.renderReport({ ...base, pinMode: 'push', pinCommitted: true }), /pin committed {5}yes$/m);
572
+ assert.match(G.renderReport({ ...base, pinMode: 'local-commit', pinCommitted: true }), /local commit/);
573
+ assert.match(G.renderReport({ ...base, pinMode: 'leave-dirty', pinCommitted: false }), /left in the working tree/);
574
+ // An absent pinMode (a failure return from an older path) still reads as push.
575
+ assert.match(G.renderReport({ ...base, pinCommitted: false }), /next pull-deploy reset will revert/);
576
+ });
577
+
578
+ test('the shipped co-tenant template is itself valid config', () => {
579
+ const r = G.loadConfig({ configPath: 'x.json', exists: () => true, readFile: () => G.CONFIG_TEMPLATE });
580
+ assert.ok(r.ok, `template did not parse: ${r.detail || ''}`);
581
+ const sel = G.selectTarget(r.config, 'a-co-tenant');
582
+ assert.ok(sel.ok, `co-tenant template target invalid: ${sel.detail || ''}`);
583
+ assert.equal(sel.target.pinMode, 'local-commit');
584
+ assert.equal(sel.target.deployTimer, undefined);
585
+ });
@@ -146,6 +146,67 @@ test('the copy-pasteable fix line is shell-quoted', () => {
146
146
  assert.equal((cmd.match(/'/g) || []).length % 2, 0, 'every quote is closed');
147
147
  });
148
148
 
149
+ // ── push: false — co-tenant mode (task 1003521) ─────────────────────────────
150
+ //
151
+ // The reasoning above assumes the repo of record is OURS. On a PROVISIONED instance it
152
+ // is the customer's own GitHub repo, so the push is both wrong and, in practice,
153
+ // uncredentialed — and with no pull-deploy timer nothing will reset the pin away, so
154
+ // the commit alone is durable. go-live.js passes --pin-no-push for such a target.
155
+
156
+ test('push: false commits the pin and stops there', () => {
157
+ const run = fakeRun({ status: DIRTY, 'rev-parse': ON_MAIN });
158
+ const r = persistPin({ ...base, commitPin: true, push: false }, run, quiet, quiet);
159
+
160
+ assert.equal(r.committed, true);
161
+ assert.equal(r.pushed, false);
162
+ assert.equal(r.pushSkipped, true);
163
+ assert.ok(!r.error, 'a skipped push is not a failure');
164
+ assert.ok(run.calls.some((c) => c === `git add ${PIN_FILES.join(' ')}`), 'still stages the pin files');
165
+ assert.ok(run.calls.some((c) => c.startsWith('git commit -m pin @bongos/core')), 'still commits');
166
+ assert.ok(!run.calls.some((c) => c.startsWith('git push')), 'must NOT touch a remote that is not ours');
167
+ });
168
+
169
+ test('push defaults to true, so every existing caller still pushes', () => {
170
+ // The absent field must not silently become co-tenant mode: that is how thirteen
171
+ // releases were lost the first time.
172
+ const run = fakeRun({ status: DIRTY, 'rev-parse': ON_MAIN });
173
+ const r = persistPin({ ...base, commitPin: true }, run, quiet, quiet);
174
+ assert.equal(r.pushed, true);
175
+ assert.ok(run.calls.some((c) => c === 'git push origin HEAD:main'));
176
+ });
177
+
178
+ test('a co-tenant with no --commit-pin gets a NOTE, not the pull-deploy alarm', () => {
179
+ // The loud warning is correct on a pull-deploy instance and false here. An alarm an
180
+ // operator learns to ignore is worse than no alarm.
181
+ const warnings = [];
182
+ const run = fakeRun({ status: DIRTY, 'rev-parse': ON_MAIN });
183
+ const r = persistPin({ ...base, commitPin: false, push: false }, run, quiet, (m) => warnings.push(m));
184
+
185
+ assert.equal(r.warned, true);
186
+ assert.equal(r.committed, false);
187
+ const text = warnings.join('\n');
188
+ assert.ok(!/PIN NOT COMMITTED/.test(text), 'no shouting about a revert that cannot happen');
189
+ assert.ok(!/reset --hard/.test(text), 'and no claim that a pull-deploy will revert it');
190
+ assert.match(text, /not ours/, 'says why the push is off');
191
+ assert.match(text, /--commit-pin/, 'still names the fix for the dirty tree');
192
+ });
193
+
194
+ test('--dry-run under push: false does not promise a push', () => {
195
+ const logs = [];
196
+ const run = fakeRun({ status: DIRTY, 'rev-parse': ON_MAIN });
197
+ const r = persistPin({ ...base, commitPin: true, push: false, dryRun: true }, run, (m) => logs.push(m), quiet);
198
+ assert.equal(r.dryRun, true);
199
+ assert.ok(!/push/.test(logs.join('\n').replace(/no push/g, '')), `dry-run must not claim a push: ${logs.join('\n')}`);
200
+ assert.ok(!run.calls.some((c) => c.includes('git commit') || c.includes('git push')));
201
+ });
202
+
203
+ test('a failed commit under push: false still reports the failure', () => {
204
+ const run = fakeRun({ status: DIRTY, 'rev-parse': ON_MAIN, commit: { status: 1, stdout: '' } });
205
+ const r = persistPin({ ...base, commitPin: true, push: false }, run, quiet, quiet);
206
+ assert.equal(r.committed, false);
207
+ assert.ok(!r.pushSkipped, 'nothing was skipped — the commit never happened');
208
+ });
209
+
149
210
  // ── preflightDbIdentity (task 1002712) ──────────────────────────────────────
150
211
  // migrate.sh uses peer auth, so `sudo bongos upgrade` connects as root, which is
151
212
  // almost never a Postgres role. That surfaced as a raw FATAL mid-run, AFTER the