@bongos/core 1.19.307 → 1.19.308

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.307",
6
- "core_contract": "1.19.307",
7
- "source_commit": "b8d2ed227fd6cb0b948128e23f0daf0070b50bcc",
5
+ "core_version": "1.19.308",
6
+ "core_contract": "1.19.308",
7
+ "source_commit": "73400ffdb3672b797a1dda57f589d9cc43da2ed1",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-08-23T20:16:34.186Z",
9
+ "built_at": "2026-08-23T20:30:08.216Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 322,
13
13
  "agent_docs_stubbed": 22,
14
- "functional_verbatim": 1418,
14
+ "functional_verbatim": 1420,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 1762,
20
- "tree_sha256": "2ce3db5cb1a893df39826469894f19d4acdee021e5729859eaa8d429933be04d",
19
+ "file_count": 1764,
20
+ "tree_sha256": "bfea309c25b9ba974096b8f0572e268ea80238aaf00e23d700adeb9e58458d49",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/blocker-review/SKILL.md",
@@ -1667,7 +1667,7 @@
1667
1667
  {
1668
1668
  "path": "docs/recipes/core-release-pipeline.md",
1669
1669
  "mode": "0000644",
1670
- "sha256": "100a1bd6bde327c60f37727ce85f75cf5539b007473359692de7ab87876c936b"
1670
+ "sha256": "b42d8b4620355bef0e27480b2ddfaa07140e322de988839f0e102f1a1545ac47"
1671
1671
  },
1672
1672
  {
1673
1673
  "path": "docs/recipes/devbox-app-release.md",
@@ -4577,12 +4577,12 @@
4577
4577
  {
4578
4578
  "path": "package-lock.json",
4579
4579
  "mode": "0000644",
4580
- "sha256": "4815e8ce78e80ac2fff7dad9f77bef508e2f0090459c67e59722b65af3e27294"
4580
+ "sha256": "4293f77940579b7c64fa5bc09f5a132c27efb5e4d21ee37a29ee3cf08d60ad88"
4581
4581
  },
4582
4582
  {
4583
4583
  "path": "package.json",
4584
4584
  "mode": "0000644",
4585
- "sha256": "bdeee91142d9d7f3930c99134be76d03f28329222adcf293deaa8f8354ab2cd8"
4585
+ "sha256": "254eba7acbbc7ba707abfe41dd50f8e83880cf8f93cbe5daff2c88cf02dd46f1"
4586
4586
  },
4587
4587
  {
4588
4588
  "path": "public-docs/index.html",
@@ -5309,6 +5309,11 @@
5309
5309
  "mode": "0000644",
5310
5310
  "sha256": "75130de4a880fc7d76f5a8d79ed65750238df94847c1ccfdc974188f9300a1e6"
5311
5311
  },
5312
+ {
5313
+ "path": "scripts/gds/go-live.js",
5314
+ "mode": "0000644",
5315
+ "sha256": "05734e21763cc0b0614275efa76b7b68afabee8be93f3f2d797400d6a97e86c1"
5316
+ },
5312
5317
  {
5313
5318
  "path": "scripts/gds/goal.js",
5314
5319
  "mode": "0000644",
@@ -6247,7 +6252,7 @@
6247
6252
  {
6248
6253
  "path": "src/module-api.js",
6249
6254
  "mode": "0000644",
6250
- "sha256": "90436d6db6e32424c48549f61ad48a3a77f5c7cac1ae16d5559b7db76fee7b4a"
6255
+ "sha256": "87cec9eba20525297a298914c20d89882db138f0cdbc5c1ae4a5ce3d285147db"
6251
6256
  },
6252
6257
  {
6253
6258
  "path": "src/module-loader/catalog.js",
@@ -7164,6 +7169,11 @@
7164
7169
  "mode": "0000644",
7165
7170
  "sha256": "d0d10628a17717fdef505dcf501915ecfcc31a0c99d9e5d4cf6dc06b47d8c883"
7166
7171
  },
7172
+ {
7173
+ "path": "tests/go_live.mjs",
7174
+ "mode": "0000644",
7175
+ "sha256": "90c8cbfd8bf0b446261f9fd837b9b08b1035210f1cbf1f7ff81998ec535c32f1"
7176
+ },
7167
7177
  {
7168
7178
  "path": "tests/goal_abuse_matrix.mjs",
7169
7179
  "mode": "0000644",
@@ -136,6 +136,38 @@ The generic mechanics are [upgrading-the-core](upgrading-the-core.md); this is t
136
136
 
137
137
  **Cadence: bump once, at session end.** One upgrade covers every release the session landed (publish-on-merge cuts one per merge — you don't chase each). Don't bump per-ship.
138
138
 
139
+ ### Step 0 — try the one command first (task 1003189)
140
+
141
+ The whole sequence below is deterministic, so it is also a script. `go-live.js` composes the
142
+ same parts this runbook narrates — `deploy-divergence.js` for the gap, the registry for proof a
143
+ release exists, and `bongos upgrade --registry --commit-pin` for the bump itself — and reports
144
+ once instead of forty times:
145
+
146
+ ```bash
147
+ node scripts/gds/go-live.js
148
+ ```
149
+
150
+ That is **plan-only and read-only**: it prints live-vs-target, checks the registry actually
151
+ carries the target, and names every halt condition it found. Add `--apply` to move the pin.
152
+
153
+ It **halts rather than guessing** on exactly the things that need judgement — unreleased commits
154
+ on `main`, a registry missing the target, a dirty tree on either end, a failed health check, an
155
+ uncommitted pin. When it halts, fall through to the manual steps below and use your own head;
156
+ that is what the rest of this section is for.
157
+
158
+ Two flags worth knowing:
159
+
160
+ - `--verify-marker <path>=<needle>` refuses a target that does not actually contain your change.
161
+ This is the guard for the trap in Step 1: the carrier sitting on `main` when your fix merged
162
+ was usually cut *before* it, so deploying "the version main was on" is a textbook-clean go-live
163
+ that changes nothing — and every downstream check agrees it worked.
164
+ - `--allow-unreleased` downgrades the UNRELEASED halt to a warning. Reach for it only when you
165
+ have already confirmed *why* main is ahead of its last release.
166
+
167
+ Host topology (ssh alias, box paths, service names, health URL) lives in a host-owned
168
+ `config/go-live.json`, never in the core — run `node scripts/gds/go-live.js --help` for the
169
+ schema. A core that named one instance's box would not be a core.
170
+
139
171
  ### Step 1 — pick the version (your machine, main checkout)
140
172
 
141
173
  ```bash
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.307",
3
+ "version": "1.19.308",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.307",
9
+ "version": "1.19.308",
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.307",
3
+ "version": "1.19.308",
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",
@@ -0,0 +1,666 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ // scripts/gds/go-live.js — run a go-live as ONE command instead of forty tool calls.
5
+ //
6
+ // WHY THIS EXISTS (task 1003189). Gate 3 of docs/recipes/core-release-pipeline.md —
7
+ // moving an instance's core pin, the only act that makes shipped work visible to
8
+ // users — is a DETERMINISTIC sequence that was nonetheless re-derived from prose by
9
+ // hand every time. The 2026-08-23 go-live took 101 tool calls / 211 model turns at a
10
+ // mean 143K context (29.7M cache-read tokens, ~69% of the bill). About four of those
11
+ // calls were engineering; the rest were lookups: what is on main, what is live, which
12
+ // PRs are green, what does the runbook say, what did the box just print. Cost here is
13
+ // turns x context, so collapsing the mechanical legs into one call is the whole fix.
14
+ //
15
+ // WHAT IT IS NOT. Not a new deploy mechanism, and not a privileged surface of its own.
16
+ // Every leg is an EXISTING proven part and this file only sequences them and reports:
17
+ // - deploy-divergence.js check() — live coreVersion vs main, UNRELEASED vs UNDEPLOYED
18
+ // - the published registry — proof a release carrying the work actually exists
19
+ // - `bongos upgrade --registry --commit-pin` — the pin move, health-gated, auto-rollback ON
20
+ // It is also NOT task 1003159: that gives the OWNER a door onto deploy without a shell
21
+ // (an authenticated control + an ADR settling the authority model). This is the AGENT
22
+ // side — a session that ALREADY holds the shell should spend two calls here, not forty.
23
+ //
24
+ // HALT, DO NOT GUESS. Every condition that needs judgement stops the run and names
25
+ // itself: a red CI check, unreleased commits, a registry missing the target, a dirty
26
+ // tree, a failed health check, an uncommitted pin. Those are exactly the parts worth
27
+ // an agent's attention; this script's job is to make everything AROUND them free.
28
+ //
29
+ // NO HOST TOPOLOGY LIVES HERE. This file ships in the portable core, so the ssh alias,
30
+ // paths, service names and health URLs come from a host-owned config (see --help for
31
+ // the schema) that is NOT publishable — the same shape as config/update-subscriptions.json.
32
+ // A core that named one instance's box would not be a core.
33
+ //
34
+ // Usage:
35
+ // node scripts/gds/go-live.js # PLAN only — read-only, the default
36
+ // node scripts/gds/go-live.js --apply # actually move the pin
37
+ // node scripts/gds/go-live.js --to 1.19.306 # pin a target instead of main's CORE_VERSION
38
+ // node scripts/gds/go-live.js --verify-marker src/x.js=needle
39
+ // # refuse a target that lacks the change
40
+ // node scripts/gds/go-live.js --target web --json
41
+ //
42
+ // Exit codes: 0 plan clean / apply succeeded · 1 halted (reason printed) · 2 bad invocation.
43
+
44
+ const fs = require('node:fs');
45
+ const path = require('node:path');
46
+ const { spawnSync } = require('node:child_process');
47
+ const { arg, hasFlag } = require('./cli-lib');
48
+ const divergence = require('./deploy-divergence.js');
49
+
50
+ const ROOT = path.resolve(__dirname, '..', '..');
51
+ const DEFAULT_CONFIG = 'config/go-live.json';
52
+
53
+ // ---------------------------------------------------------------------------
54
+ // Validation. Everything below reaches a remote shell that runs as root, so it
55
+ // is checked against a strict allowlist BEFORE interpolation — never quoted and
56
+ // hoped for. A config file is host-owned, but "host-owned" is not "trusted to
57
+ // contain shell metacharacters".
58
+ // ---------------------------------------------------------------------------
59
+
60
+ const SEMVER_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
61
+ // Deliberately narrow: POSIX-portable path/identifier characters only. No spaces,
62
+ // no quotes, no $ ` \ ; & | < > ( ) newline — i.e. nothing with meaning to a shell.
63
+ //
64
+ // `~` IS allowed, and is the one deliberate exception. It is shell-interpreted, but
65
+ // it cannot start a subshell, chain a command, or redirect — the runbook's backup dir
66
+ // is literally `~/backups`, and under `sudo -u <owner> -H` that expansion to the
67
+ // owner's home is the BEHAVIOUR WE WANT, not a leak. Everything with control meaning
68
+ // stays out.
69
+ const SAFE_TOKEN_RE = /^[A-Za-z0-9._@:/+=~-]+$/;
70
+ const SAFE_ENV_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
71
+
72
+ function isSafeToken(v) {
73
+ return typeof v === 'string' && v.length > 0 && v.length <= 512 && SAFE_TOKEN_RE.test(v);
74
+ }
75
+
76
+ // The fields a target must carry, and the ones it may. Unknown keys are reported
77
+ // rather than ignored: a typo'd `healthURL` that silently disables the health gate
78
+ // is precisely the class of quiet failure this pipeline keeps being bitten by.
79
+ const REQUIRED_FIELDS = ['ssh', 'instanceDir', 'service', 'healthUrl', 'versionUrl', 'deployTimer'];
80
+ const OPTIONAL_FIELDS = ['deployService', 'npmEnvFile', 'runAs', 'backupDb', 'backupDir', 'env', 'registryPackage'];
81
+
82
+ function validateTarget(name, t) {
83
+ const errors = [];
84
+ if (!t || typeof t !== 'object' || Array.isArray(t)) return [`target "${name}" is not an object`];
85
+
86
+ for (const f of REQUIRED_FIELDS) {
87
+ if (t[f] === undefined) errors.push(`target "${name}" is missing required field "${f}"`);
88
+ }
89
+ const known = new Set([...REQUIRED_FIELDS, ...OPTIONAL_FIELDS]);
90
+ for (const k of Object.keys(t)) {
91
+ if (!known.has(k)) errors.push(`target "${name}" has unknown field "${k}" (typo? known: ${[...known].sort().join(', ')})`);
92
+ }
93
+ for (const [k, v] of Object.entries(t)) {
94
+ if (k === 'env') {
95
+ if (typeof v !== 'object' || v === null || Array.isArray(v)) { errors.push(`target "${name}".env must be an object`); continue; }
96
+ for (const [ek, ev] of Object.entries(v)) {
97
+ if (!SAFE_ENV_KEY_RE.test(ek)) errors.push(`target "${name}".env key "${ek}" is not a valid environment-variable name`);
98
+ if (!isSafeToken(String(ev))) errors.push(`target "${name}".env.${ek} contains characters that are unsafe in a shell`);
99
+ }
100
+ continue;
101
+ }
102
+ if (!known.has(k)) continue;
103
+ if (!isSafeToken(v)) errors.push(`target "${name}".${k} contains characters that are unsafe in a shell (got ${JSON.stringify(v)})`);
104
+ }
105
+ return errors;
106
+ }
107
+
108
+ // ---------------------------------------------------------------------------
109
+ // Config
110
+ // ---------------------------------------------------------------------------
111
+
112
+ const CONFIG_TEMPLATE = `{
113
+ "defaultTarget": "web",
114
+ "targets": {
115
+ "web": {
116
+ "ssh": "my-box", // ssh alias or user@host
117
+ "instanceDir": "/home/me/my-instance", // the instance repo (holds the pin)
118
+ "service": "my-instance.service", // systemd unit to restart
119
+ "healthUrl": "http://127.0.0.1:3002/healthz", // box-side, NOT the apex
120
+ "versionUrl": "http://127.0.0.1:3002/version", // box-side truth read back after
121
+ "deployTimer": "my-deploy.timer", // pull-deploy timer (its reset --hard is the hazard)
122
+ "deployService": "my-deploy.service", // optional: forced tick proves the pin stuck
123
+ "npmEnvFile": "/etc/my/npm.env", // optional: root-only NPM_TOKEN for a PRIVATE package
124
+ "runAs": "me", // optional: unprivileged owner of the instance
125
+ "backupDb": "mydb", // optional: pg_dump before the bump
126
+ "backupDir": "~/backups",
127
+ "env": { "PGDATABASE": "mydb" }, // optional: load-bearing for migrate
128
+ "registryPackage": "@bongos/core"
129
+ }
130
+ }
131
+ }`;
132
+
133
+ function loadConfig({ configPath, readFile = fs.readFileSync, exists = fs.existsSync } = {}) {
134
+ const rel = configPath || DEFAULT_CONFIG;
135
+ const abs = path.isAbsolute(rel) ? rel : path.join(ROOT, rel);
136
+ if (!exists(abs)) {
137
+ return { error: 'NO_TARGET_CONFIG', path: rel, template: CONFIG_TEMPLATE };
138
+ }
139
+ let raw;
140
+ try { raw = readFile(abs, 'utf8'); } catch (e) { return { error: 'CONFIG_UNREADABLE', path: rel, detail: e.message }; }
141
+ let parsed;
142
+ // Tolerate // comments so the shipped template can stay self-documenting.
143
+ try { parsed = JSON.parse(raw.replace(/^\s*\/\/.*$/gm, '').replace(/(:\s*"[^"]*"|:\s*[\d.]+|[{[,])\s*\/\/.*$/gm, '$1')); }
144
+ catch (e) { return { error: 'CONFIG_MALFORMED', path: rel, detail: e.message }; }
145
+ if (!parsed || typeof parsed !== 'object' || !parsed.targets || typeof parsed.targets !== 'object') {
146
+ return { error: 'CONFIG_MALFORMED', path: rel, detail: 'expected a top-level "targets" object' };
147
+ }
148
+ return { ok: true, path: rel, config: parsed };
149
+ }
150
+
151
+ function selectTarget(config, name) {
152
+ const wanted = name || config.defaultTarget || Object.keys(config.targets)[0];
153
+ if (!wanted) return { error: 'UNKNOWN_TARGET', detail: 'the config declares no targets' };
154
+ const t = config.targets[wanted];
155
+ if (!t) {
156
+ return { error: 'UNKNOWN_TARGET', detail: `no target "${wanted}" (have: ${Object.keys(config.targets).join(', ') || 'none'})` };
157
+ }
158
+ const errors = validateTarget(wanted, t);
159
+ if (errors.length) return { error: 'CONFIG_INVALID', detail: errors.join('; ') };
160
+ return { ok: true, name: wanted, target: t };
161
+ }
162
+
163
+ // ---------------------------------------------------------------------------
164
+ // Remote execution. One ssh round-trip per phase, script fed on STDIN so no
165
+ // value is ever interpolated into an ssh ARGUMENT (which would be re-parsed by
166
+ // the remote login shell). Values are validated above; this is the second layer.
167
+ // ---------------------------------------------------------------------------
168
+
169
+ function remote(target, script, { run = spawnSync, timeoutMs = 900000 } = {}) {
170
+ const res = run('ssh', ['-o', 'ConnectTimeout=20', '-o', 'BatchMode=yes', target.ssh, 'bash', '-s'], {
171
+ input: script, encoding: 'utf8', timeout: timeoutMs, maxBuffer: 32 * 1024 * 1024,
172
+ });
173
+ if (!res || res.error) return { ok: false, status: null, stdout: '', stderr: res && res.error ? res.error.message : 'spawn failed' };
174
+ return { ok: res.status === 0, status: res.status, stdout: res.stdout || '', stderr: res.stderr || '' };
175
+ }
176
+
177
+ // `sudo -u <user> -H env K=V … <cmd>` — the shape every runbook entry uses. Returns
178
+ // a string safe to embed because every component was allowlist-validated.
179
+ //
180
+ // `forward` names variables to pass through BY REFERENCE (`K="$K"`), for values that
181
+ // exist in the calling root shell but must not appear in this script's text. That is
182
+ // how NPM_TOKEN reaches the upgrade: sudo's env_reset drops it, and an explicit
183
+ // `env K=V` list that omits it drops it too — so `npm install` 404s the PRIVATE
184
+ // package and the upgrade auto-rolls-back, which reads as "the release is broken"
185
+ // when it is only "the token never arrived". Sourcing the env file in the OUTER shell
186
+ // is necessary and NOT sufficient; this is the second half of that trap.
187
+ function asOwner(target, cmd, { forward = [] } = {}) {
188
+ const envPairs = Object.entries(target.env || {}).map(([k, v]) => `${k}=${v}`);
189
+ for (const k of forward) {
190
+ if (!SAFE_ENV_KEY_RE.test(k)) continue; // never interpolate an unvetted name
191
+ envPairs.push(`${k}="$${k}"`); // by reference — the VALUE is never written here
192
+ }
193
+ const joined = envPairs.join(' ');
194
+ const prefix = target.runAs ? `sudo -u ${target.runAs} -H env ${joined} HOME=/home/${target.runAs}` : (joined ? `env ${joined}` : '');
195
+ return `${prefix} ${cmd}`.trim();
196
+ }
197
+
198
+ // POSIX single-quote a value for embedding in a shell command.
199
+ function shq(s) {
200
+ return `'${String(s).replace(/'/g, `'\\''`)}'`;
201
+ }
202
+
203
+ // The content proof, as a shell snippet.
204
+ //
205
+ // TWO bugs live here in the obvious version, and both FAIL OPEN — they report "your
206
+ // change is missing" for a version that has it, which reads exactly like the real
207
+ // ADR-0161 trap and sends you chasing a later release that does not exist:
208
+ //
209
+ // 1. Do NOT derive the tarball name. `npm pack @bongos/core` writes
210
+ // `bongos-core-<v>.tgz`, and hand-rolled `tr`/`sed` on the package name gets the
211
+ // leading dashes wrong — so `tar` reads a file that is not there and greps 0 hits.
212
+ // npm PRINTS the filename it wrote; use that.
213
+ // 2. Pass the path + needle as base64 and match with `grep -F`. Interpolating them
214
+ // lets a quote break out of the command, and an unescaped regex metacharacter in
215
+ // the needle silently changes what is being matched.
216
+ //
217
+ // `set -o pipefail` is deliberately absent: `grep -c` exits 1 on zero matches, which
218
+ // is a legitimate answer here, not a failure.
219
+ function markerProbe(pkg, version, marker) {
220
+ const p64 = Buffer.from(marker.path, 'utf8').toString('base64');
221
+ const n64 = Buffer.from(marker.needle, 'utf8').toString('base64');
222
+ return [
223
+ `set -u`,
224
+ // Run from a neutral cwd. `npm pack <spec>` behaves differently inside a
225
+ // project directory, and an earlier section's `cd` used to leak this far —
226
+ // which made the pack fail and read as "your change is missing".
227
+ `cd /tmp`,
228
+ `P=$(echo ${p64} | base64 -d)`,
229
+ `N=$(echo ${n64} | base64 -d)`,
230
+ `ERR=$(mktemp)`,
231
+ `T=$(npm pack ${pkg}@${version} --pack-destination /tmp 2>"$ERR" | tail -1)`,
232
+ // Say WHY it failed. A bare PACK_FAILED cost four diagnostic round-trips once.
233
+ `if [ -z "$T" ] || [ ! -f "/tmp/$T" ]; then echo "PACK_FAILED $(tail -2 "$ERR" | tr '\\n' ' ')"; rm -f "$ERR"; exit 0; fi`,
234
+ `rm -f "$ERR"`,
235
+ // `grep -c` PRINTS 0 *and* exits 1 on no match, so a bare `|| echo 0` emits TWO
236
+ // lines ("0\n0") — which parses to NaN and compares equal to nothing, so an
237
+ // absent marker silently passed. Emit exactly one line.
238
+ `tar -xzOf "/tmp/$T" "package/$P" 2>/dev/null | grep -c -F -- "$N" | head -1`,
239
+ ].join('; ');
240
+ }
241
+
242
+ // Read the marker section back. Fails CLOSED: anything that is not a clean count is
243
+ // 'PACK_FAILED' (could not check), never a 0 that would read as a confident "absent"
244
+ // — and never a NaN that silently compares equal to nothing and lets the run proceed.
245
+ function parseMarkerHits(raw) {
246
+ const s = String(raw == null ? '' : raw).trim();
247
+ if (!s || s.includes('PACK_FAILED')) return 'PACK_FAILED';
248
+ const first = s.split('\n')[0].trim();
249
+ if (!/^\d+$/.test(first)) return 'PACK_FAILED';
250
+ return Number(first);
251
+ }
252
+
253
+ // Refresh origin/main before reading anything from it.
254
+ //
255
+ // deploy-divergence reads CORE_VERSION at the local `origin/main` REF, and a ref is
256
+ // only as fresh as the last fetch. A worktree that has sat for an hour resolves a
257
+ // target version that is already superseded — and everything downstream agrees with
258
+ // it, because every check is consistent with the same stale ref. This actually
259
+ // happened mid-build: a parallel session merged twice, the plan targeted the older
260
+ // carrier, and only a manual fetch revealed it. Default ON; --no-fetch opts out.
261
+ //
262
+ // A fetch failure is NOT fatal: a box or a tarball checkout legitimately has no
263
+ // remote, and deploy-divergence already falls back to HEAD there.
264
+ function refreshMain({ run = spawnSync } = {}) {
265
+ const r = run('git', ['fetch', 'origin', 'main', '--quiet'], { cwd: ROOT, encoding: 'utf8', timeout: 60000 });
266
+ if (!r || r.error || r.status !== 0) {
267
+ return { ok: false, reason: (r && (r.stderr || '').trim().split('\n').pop()) || 'fetch failed' };
268
+ }
269
+ return { ok: true };
270
+ }
271
+
272
+ // Which variables an npm-running command needs forwarded. Only when the target
273
+ // declares an env file to source them from — otherwise `K="$K"` would expand to
274
+ // empty and quietly do nothing.
275
+ function npmForward(target) {
276
+ return target.npmEnvFile ? ['NPM_TOKEN'] : [];
277
+ }
278
+
279
+ // Parse a `key=value` marker into its parts. Kept pure + exported: the marker is
280
+ // the answer to the trap that the release current when your fix merges usually
281
+ // does NOT contain your fix (ADR 0161 carriers bump on their own cadence).
282
+ function parseMarker(spec) {
283
+ if (!spec) return null;
284
+ const i = String(spec).indexOf('=');
285
+ if (i <= 0 || i === spec.length - 1) return { error: 'expected --verify-marker <path>=<needle>' };
286
+ return { path: spec.slice(0, i), needle: spec.slice(i + 1) };
287
+ }
288
+
289
+ // ---------------------------------------------------------------------------
290
+ // Planning. Pure given its inputs, so the halt matrix tests without a box.
291
+ // ---------------------------------------------------------------------------
292
+
293
+ const HALTS = {
294
+ DIRTY_CHECKOUT: 'the core checkout has uncommitted changes — CORE_VERSION here cannot be trusted to match what was released',
295
+ DIVERGENCE_UNDETERMINED: 'could not determine what is live',
296
+ UNRELEASED_ON_MAIN: 'main carries commits past the last CORE_VERSION bump — no pin can reach them',
297
+ ALREADY_LIVE: 'live is already at the target',
298
+ REGISTRY_MISSING_TARGET: 'the registry does not carry the target version',
299
+ MARKER_ABSENT: 'the target version does not contain the change you named',
300
+ MARKER_UNCHECKED: 'could not inspect the target version — the content proof did not run',
301
+ BOX_TREE_DIRTY: 'the instance repo on the box has uncommitted changes',
302
+ LIVE_AHEAD: 'live is AHEAD of the target',
303
+ };
304
+
305
+ function planGoLive({ div, target, targetVersion, dirtyCheckout, allowUnreleased = false }) {
306
+ const halts = [];
307
+ const notes = [];
308
+
309
+ if (dirtyCheckout) halts.push({ code: 'DIRTY_CHECKOUT', message: HALTS.DIRTY_CHECKOUT, detail: dirtyCheckout });
310
+
311
+ if (!div || div.undetermined) {
312
+ halts.push({ code: 'DIVERGENCE_UNDETERMINED', message: HALTS.DIVERGENCE_UNDETERMINED, detail: div && div.reason });
313
+ return { halts, notes, targetVersion, from: null };
314
+ }
315
+
316
+ const from = div.live && div.live.coreVersion;
317
+
318
+ // UNRELEASED is not the same failure as UNDEPLOYED and must not be conflated:
319
+ // no pin, at any version, can reach a commit no release carries. Deploying
320
+ // anyway is the "textbook-clean go-live that changed nothing" trap.
321
+ if (div.hasUnreleased) {
322
+ const c = div.unreleased && div.unreleased.count;
323
+ const entry = {
324
+ code: 'UNRELEASED_ON_MAIN',
325
+ message: HALTS.UNRELEASED_ON_MAIN,
326
+ detail: `${c} commit(s) on main since the last bump. The publish-on-merge job can also FAIL on a benign push race — check \`gh run list --workflow publish.yml\` for a failure before assuming this is just timing.`,
327
+ };
328
+ if (allowUnreleased) notes.push({ ...entry, downgraded: true }); else halts.push(entry);
329
+ }
330
+
331
+ if (from && targetVersion) {
332
+ const cmp = divergence.cmpSemver(from, targetVersion);
333
+ if (cmp === 0) halts.push({ code: 'ALREADY_LIVE', message: HALTS.ALREADY_LIVE, detail: `live and target are both ${from}` });
334
+ else if (cmp > 0) halts.push({ code: 'LIVE_AHEAD', message: HALTS.LIVE_AHEAD, detail: `live ${from} > target ${targetVersion}` });
335
+ }
336
+
337
+ return { halts, notes, from, targetVersion, target };
338
+ }
339
+
340
+ // ---------------------------------------------------------------------------
341
+ // Rendering
342
+ // ---------------------------------------------------------------------------
343
+
344
+ const BAR = ' ──────────────────────────────────────────────────────────';
345
+
346
+ function renderPlan(p, { targetName, configPath } = {}) {
347
+ const L = [];
348
+ L.push('');
349
+ L.push(` Go-live plan · ${targetName || 'target'}${configPath ? ` · ${configPath}` : ''}`);
350
+ L.push(BAR);
351
+ L.push(` live now ${p.from || '(unknown)'}`);
352
+ L.push(` target ${p.targetVersion || '(unresolved)'}`);
353
+ if (p.marker) L.push(` verify ${p.marker.path} contains "${p.marker.needle}"`);
354
+ if (p.registry) L.push(` registry ${p.registry.ok ? `carries ${p.targetVersion}` : 'MISSING'}`);
355
+ L.push('');
356
+ for (const n of p.notes || []) L.push(` ⚠ ${n.code} — ${n.message}`);
357
+ if (!p.halts.length) {
358
+ L.push(' ✓ no halt conditions — re-run with --apply to move the pin');
359
+ } else {
360
+ L.push(` ✖ ${p.halts.length} halt condition(s) — this go-live will NOT proceed:`);
361
+ for (const h of p.halts) {
362
+ L.push(` ${h.code} — ${h.message}`);
363
+ if (h.detail) L.push(` ${h.detail}`);
364
+ }
365
+ }
366
+ L.push('');
367
+ return L.join('\n');
368
+ }
369
+
370
+ // Pull the lines a human needs out of a failed phase. A report that says "exit 1"
371
+ // and swallows the tool's own words costs a whole extra round-trip to re-run the
372
+ // command by hand — which is the exact cost this script exists to remove.
373
+ function failureExcerpt(raw, max = 12) {
374
+ if (!raw) return [];
375
+ const text = `${raw.stdout || ''}\n${raw.stderr || ''}`;
376
+ const lines = text.split('\n').map((l) => l.trimEnd()).filter(Boolean);
377
+ // Prefer the lines that carry a diagnosis over the trailing noise.
378
+ const signal = lines.filter((l) => /error|fail|✖|✗|denied|not found|404|refus|rollback|↩|!\s/i.test(l));
379
+ return (signal.length ? signal : lines).slice(-max);
380
+ }
381
+
382
+ function renderReport(r) {
383
+ const L = [];
384
+ L.push('');
385
+ L.push(` Go-live · ${r.targetName}`);
386
+ L.push(BAR);
387
+ L.push(` ${r.from} → ${r.to}`);
388
+ for (const step of r.steps || []) L.push(` ${step.ok ? '✓' : '✖'} ${step.name}${step.detail ? ` — ${step.detail}` : ''}`);
389
+
390
+ if (!r.ok) {
391
+ const ex = failureExcerpt(r.raw);
392
+ if (ex.length) {
393
+ L.push('');
394
+ L.push(` what ${r.failedAt ? r.failedAt.toLowerCase() : 'it'} actually said:`);
395
+ for (const l of ex) L.push(` ${l}`);
396
+ }
397
+ if (r.rolledBack) {
398
+ L.push('');
399
+ L.push(' The instance auto-rolled-back and is serving the PREVIOUS core — it is not broken.');
400
+ }
401
+ }
402
+
403
+ L.push('');
404
+ L.push(` live reads back ${r.liveVersion || '(unread)'}`);
405
+ L.push(` pin committed ${r.pinCommitted ? 'yes' : 'NO — the next pull-deploy reset will revert this bump'}`);
406
+ L.push('');
407
+ return L.join('\n');
408
+ }
409
+
410
+ // ---------------------------------------------------------------------------
411
+ // Phases
412
+ // ---------------------------------------------------------------------------
413
+
414
+ // Registry proof + optional content proof, in ONE round-trip. The registry is
415
+ // consulted ON THE BOX because a PRIVATE package 404s without the root-only
416
+ // NPM_TOKEN, and because the box's answer is the one that matters.
417
+ function probeRemote(target, targetVersion, marker, opts = {}) {
418
+ const pkg = target.registryPackage || '@bongos/core';
419
+ const src = target.npmEnvFile ? `set -a; . ${target.npmEnvFile}; set +a` : 'true';
420
+ const script = `
421
+ set -uo pipefail
422
+ ${src}
423
+ echo "##REGISTRY##"
424
+ ${asOwner(target, `npm view ${pkg}@${targetVersion} version`, { forward: npmForward(target) })} 2>&1 | tail -1
425
+ echo "##TREE##"
426
+ ( cd ${target.instanceDir} && ${target.runAs ? `sudo -u ${target.runAs} -H ` : ''}git status --porcelain | head -20 )
427
+ echo "##INSTALLED##"
428
+ ${asOwner(target, `node -e "console.log(require('${target.instanceDir}/node_modules/${pkg}/package.json').version)"`)} 2>&1 | tail -1
429
+ ${marker ? `echo "##MARKER##"
430
+ ${asOwner(target, `sh -c ${shq(markerProbe(pkg, targetVersion, marker))}`, { forward: npmForward(target) })}` : ''}
431
+ echo "##END##"
432
+ `;
433
+ const res = remote(target, script, opts);
434
+ const section = (n) => {
435
+ const m = res.stdout.split(`##${n}##`)[1];
436
+ return m === undefined ? null : m.split('##')[0].trim();
437
+ };
438
+ // `npm view` on a missing version exits non-zero and its last line is a path to a
439
+ // debug log — useless in a report. Anything that is not a version is reported as
440
+ // "not published", which is the fact the reader actually needs.
441
+ const registryRaw = section('REGISTRY');
442
+ const registryVersion = registryRaw && SEMVER_RE.test(registryRaw) ? registryRaw : null;
443
+ return {
444
+ reachable: res.ok || res.stdout.includes('##END##'),
445
+ raw: res,
446
+ registry: { ok: registryVersion === targetVersion, reported: registryVersion, raw: registryRaw },
447
+ boxTreeDirty: (section('TREE') || '') !== '',
448
+ boxTreeDetail: section('TREE') || '',
449
+ installed: section('INSTALLED'),
450
+ // null = not asked · 'PACK_FAILED' = could not check (NOT the same as absent) · a count.
451
+ markerHits: marker ? parseMarkerHits(section('MARKER')) : null,
452
+ markerError: marker ? (String(section('MARKER') || '').replace(/^PACK_FAILED\s*/, '').trim() || null) : null,
453
+ };
454
+ }
455
+
456
+ function applyGoLive(target, targetName, targetVersion, from, opts = {}) {
457
+ const pkg = target.registryPackage || '@bongos/core';
458
+ const steps = [];
459
+ const src = target.npmEnvFile ? `set -a; . ${target.npmEnvFile}; set +a` : 'true';
460
+
461
+ // Phase A — stop the pull-deploy timer, then back up. The timer's `reset --hard`
462
+ // is the hazard: it would revert an uncommitted pin mid-flight.
463
+ const backup = target.backupDb
464
+ ? 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"`)
465
+ : 'echo "(no backupDb configured — skipping)"';
466
+ const phaseA = remote(target, `
467
+ set -euo pipefail
468
+ echo "##TIMER##"; systemctl stop ${target.deployTimer} && systemctl is-active ${target.deployTimer} || true
469
+ echo "##BACKUP##"; ${backup}
470
+ echo "##END##"
471
+ `, opts);
472
+ steps.push({ name: `stop ${target.deployTimer} + backup`, ok: phaseA.ok, detail: phaseA.ok ? null : (phaseA.stderr || '').trim().split('\n').pop() });
473
+ if (!phaseA.ok) return { ok: false, steps, from, to: targetVersion, targetName, failedAt: 'PREPARE', raw: phaseA };
474
+
475
+ // Phase B — the upgrade itself. --commit-pin makes the bump durable against the
476
+ // pull-deploy reset; auto-rollback stays ON (do NOT pass --no-rollback-on-failure).
477
+ const upgrade = [
478
+ `./node_modules/.bin/bongos upgrade`,
479
+ `--to ${targetVersion}`, '--registry',
480
+ `--instance ${target.instanceDir}`,
481
+ `--service ${target.service}`,
482
+ `--health-url ${target.healthUrl}`,
483
+ '--commit-pin', '--force',
484
+ ].join(' ');
485
+ const phaseB = remote(target, `
486
+ set -uo pipefail
487
+ ${src}
488
+ ${asOwner(target, `bash -c "cd ${target.instanceDir} && ${upgrade}"`, { forward: npmForward(target) })}
489
+ `, opts);
490
+ const rolledBack = /rolled back/i.test(phaseB.stdout + phaseB.stderr);
491
+ steps.push({
492
+ name: `upgrade ${from} → ${targetVersion}`,
493
+ ok: phaseB.ok && !rolledBack,
494
+ detail: rolledBack ? 'AUTO-ROLLBACK ran — the health check failed after restart' : (phaseB.ok ? null : `exit ${phaseB.status}`),
495
+ });
496
+ if (!phaseB.ok || rolledBack) {
497
+ // Restart the timer even on failure — leaving it stopped silently disables
498
+ // the instance's pull-deploy until someone notices.
499
+ remote(target, `systemctl start ${target.deployTimer} || true`, opts);
500
+ return { ok: false, steps, from, to: targetVersion, targetName, failedAt: 'UPGRADE', rolledBack, raw: phaseB };
501
+ }
502
+
503
+ // Phase C — restart the timer, force one tick, and read back the BOX-SIDE truth.
504
+ // The apex may sit behind basic_auth and its HTML rides a CDN, so the apex is
505
+ // not evidence; 127.0.0.1 is.
506
+ const phaseC = remote(target, `
507
+ set -uo pipefail
508
+ echo "##TIMERSTART##"; systemctl start ${target.deployTimer}; systemctl is-active ${target.deployTimer}
509
+ echo "##PIN##"; ( cd ${target.instanceDir} && ${target.runAs ? `sudo -u ${target.runAs} -H ` : ''}git status --porcelain -- package.json package-lock.json )
510
+ echo "##HEAD##"; ( cd ${target.instanceDir} && ${target.runAs ? `sudo -u ${target.runAs} -H ` : ''}git log -1 --format='%h %s' )
511
+ ${target.deployService ? `echo "##TICK##"; systemctl start ${target.deployService} || true; journalctl -u ${target.deployService} -n 20 --no-pager | tail -8` : ''}
512
+ echo "##VERSION##"; curl -s --max-time 20 ${target.versionUrl} | head -c 400
513
+ echo ""
514
+ echo "##INSTALLED##"; ${asOwner(target, `node -e "console.log(require('${target.instanceDir}/node_modules/${pkg}/package.json').version)"`)} 2>&1 | tail -1
515
+ echo "##END##"
516
+ `, opts);
517
+ const section = (n) => {
518
+ const m = phaseC.stdout.split(`##${n}##`)[1];
519
+ return m === undefined ? null : m.split('##')[0].trim();
520
+ };
521
+ const pinDirty = (section('PIN') || '') !== '';
522
+ const installed = section('INSTALLED');
523
+ let liveVersion = null;
524
+ try { liveVersion = JSON.parse(section('VERSION') || '{}').coreVersion || null; } catch { liveVersion = null; }
525
+
526
+ steps.push({ name: `restart ${target.deployTimer}`, ok: (section('TIMERSTART') || '').includes('active') });
527
+ 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) });
528
+ steps.push({ name: 'installed package version', ok: installed === targetVersion, detail: installed === targetVersion ? installed : `reports ${installed}, expected ${targetVersion}` });
529
+ steps.push({ name: 'live reads back (box-side)', ok: liveVersion === targetVersion, detail: liveVersion === targetVersion ? liveVersion : `reports ${liveVersion}, expected ${targetVersion}` });
530
+
531
+ const ok = steps.every((s) => s.ok);
532
+ return { ok, steps, from, to: targetVersion, targetName, liveVersion, installed, pinCommitted: !pinDirty, raw: phaseC };
533
+ }
534
+
535
+ // ---------------------------------------------------------------------------
536
+ // CLI
537
+ // ---------------------------------------------------------------------------
538
+
539
+ function usage() {
540
+ console.log(`
541
+ go-live — move an enrolled instance's core pin, as one command.
542
+
543
+ node scripts/gds/go-live.js PLAN only (read-only, the default)
544
+ node scripts/gds/go-live.js --apply move the pin
545
+ node scripts/gds/go-live.js --to <version> target a version explicitly
546
+ node scripts/gds/go-live.js --verify-marker <path>=<needle>
547
+ refuse a target that lacks the change
548
+ node scripts/gds/go-live.js --target <name> pick a target from the config
549
+ node scripts/gds/go-live.js --config <path> default: ${DEFAULT_CONFIG}
550
+ node scripts/gds/go-live.js --allow-unreleased downgrade UNRELEASED_ON_MAIN to a warning
551
+ node scripts/gds/go-live.js --no-fetch skip the origin/main refresh (default: fetch)
552
+ node scripts/gds/go-live.js --json machine-readable
553
+
554
+ Host topology is NOT baked into the core. ${DEFAULT_CONFIG} (host-owned, not published):
555
+
556
+ ${CONFIG_TEMPLATE}
557
+ `);
558
+ }
559
+
560
+ async function main() {
561
+ if (hasFlag('--help') || hasFlag('-h')) { usage(); return 0; }
562
+
563
+ const json = hasFlag('--json');
564
+ const apply = hasFlag('--apply');
565
+ const allowUnreleased = hasFlag('--allow-unreleased');
566
+
567
+ const cfg = loadConfig({ configPath: arg('--config') });
568
+ if (cfg.error) {
569
+ if (json) { console.log(JSON.stringify({ ok: false, halt: cfg.error, path: cfg.path, detail: cfg.detail }, null, 2)); return 1; }
570
+ console.error(`\n go-live: ${cfg.error} — ${cfg.path}${cfg.detail ? `\n ${cfg.detail}` : ''}`);
571
+ if (cfg.error === 'NO_TARGET_CONFIG') {
572
+ console.error(`\n This file is host-owned and deliberately not shipped in the core.\n Create it with your box's topology:\n\n${CONFIG_TEMPLATE}\n`);
573
+ }
574
+ return 1;
575
+ }
576
+
577
+ const sel = selectTarget(cfg.config, arg('--target'));
578
+ if (sel.error) {
579
+ if (json) { console.log(JSON.stringify({ ok: false, halt: sel.error, detail: sel.detail }, null, 2)); return 1; }
580
+ console.error(`\n go-live: ${sel.error} — ${sel.detail}\n`);
581
+ return 1;
582
+ }
583
+ const { target, name: targetName } = sel;
584
+
585
+ const marker = parseMarker(arg('--verify-marker'));
586
+ if (marker && marker.error) { console.error(` go-live: ${marker.error}`); return 2; }
587
+
588
+ // Resolve the target version: an explicit --to, else CORE_VERSION on main.
589
+ let targetVersion = arg('--to');
590
+ if (targetVersion && !SEMVER_RE.test(targetVersion)) { console.error(` go-live: --to "${targetVersion}" is not a version`); return 2; }
591
+
592
+ const fetched = hasFlag('--no-fetch') ? { ok: false, skipped: true } : refreshMain();
593
+ const div = await divergence.check({ base: arg('--url') });
594
+ if (!targetVersion) targetVersion = div && div.onMain;
595
+ if (targetVersion && !SEMVER_RE.test(targetVersion)) {
596
+ console.error(` go-live: could not resolve a target version (read ${JSON.stringify(targetVersion)} from main)`);
597
+ return 1;
598
+ }
599
+
600
+ const dirtyRes = spawnSync('git', ['status', '--porcelain'], { cwd: ROOT, encoding: 'utf8' });
601
+ const dirtyCheckout = dirtyRes.status === 0 && (dirtyRes.stdout || '').trim() ? (dirtyRes.stdout || '').trim().split('\n').slice(0, 5).join(', ') : null;
602
+
603
+ const plan = planGoLive({ div, target, targetVersion, dirtyCheckout, allowUnreleased });
604
+ plan.marker = marker;
605
+ if (!fetched.ok && !fetched.skipped) {
606
+ // Say so rather than silently planning off a ref of unknown age.
607
+ plan.notes.push({ code: 'MAIN_NOT_FETCHED', message: `could not refresh origin/main (${fetched.reason}) — the target below is read from a ref of unknown age` });
608
+ }
609
+
610
+ // Probe the box only once the local picture is worth acting on — an ssh
611
+ // round-trip to confirm a plan that already has a halt is wasted.
612
+ if (!plan.halts.length) {
613
+ const probe = probeRemote(target, targetVersion, marker);
614
+ plan.registry = probe.registry;
615
+ if (!probe.reachable) {
616
+ plan.halts.push({ code: 'BOX_UNREACHABLE', message: `could not reach ${target.ssh}`, detail: (probe.raw.stderr || '').trim().split('\n').pop() });
617
+ } else {
618
+ if (!probe.registry.ok) {
619
+ plan.halts.push({
620
+ code: 'REGISTRY_MISSING_TARGET', message: HALTS.REGISTRY_MISSING_TARGET,
621
+ detail: `npm reports ${probe.registry.reported ? JSON.stringify(probe.registry.reported) : 'not published'} for ${target.registryPackage || '@bongos/core'}@${targetVersion}. A version bumped on main but never published leaves main's code reachable by no pin — check \`gh run list --workflow publish.yml\` for a failed run.`,
622
+ });
623
+ }
624
+ if (probe.boxTreeDirty) {
625
+ plan.halts.push({ code: 'BOX_TREE_DIRTY', message: HALTS.BOX_TREE_DIRTY, detail: probe.boxTreeDetail.split('\n').slice(0, 5).join(', ') });
626
+ }
627
+ if (marker && probe.markerHits === 'PACK_FAILED') {
628
+ // "Could not check" must never render as "absent" — that reads exactly like
629
+ // the real trap and sends you chasing a version that does not exist.
630
+ plan.halts.push({
631
+ code: 'MARKER_UNCHECKED', message: HALTS.MARKER_UNCHECKED,
632
+ detail: `could not download ${target.registryPackage || '@bongos/core'}@${targetVersion} to inspect it${probe.markerError ? `: ${probe.markerError}` : ''}. This is NOT evidence the change is missing — fix the fetch, or drop --verify-marker and verify by hand.`,
633
+ });
634
+ } else if (marker && probe.markerHits === 0) {
635
+ plan.halts.push({
636
+ code: 'MARKER_ABSENT', message: HALTS.MARKER_ABSENT,
637
+ detail: `${targetVersion} does not contain "${marker.needle}" in ${marker.path}. The carrier sitting on main when your fix merged was usually cut BEFORE it (ADR 0161) — try the next version.`,
638
+ });
639
+ }
640
+ }
641
+ }
642
+
643
+ if (!apply || plan.halts.length) {
644
+ if (json) { console.log(JSON.stringify({ ok: !plan.halts.length, mode: apply ? 'halted' : 'plan', targetName, ...plan }, null, 2)); }
645
+ else { console.log(renderPlan(plan, { targetName, configPath: cfg.path })); }
646
+ return plan.halts.length ? 1 : 0;
647
+ }
648
+
649
+ const result = applyGoLive(target, targetName, targetVersion, plan.from);
650
+ if (json) console.log(JSON.stringify(result, null, 2));
651
+ else console.log(renderReport(result));
652
+ return result.ok ? 0 : 1;
653
+ }
654
+
655
+ if (require.main === module) {
656
+ main().then((c) => { process.exitCode = c; }).catch((e) => {
657
+ console.error(`go-live: fatal — ${e && e.message ? e.message : e}`);
658
+ process.exitCode = 1;
659
+ });
660
+ }
661
+
662
+ module.exports = {
663
+ loadConfig, selectTarget, validateTarget, planGoLive, parseMarker, parseMarkerHits,
664
+ renderPlan, renderReport, failureExcerpt, asOwner, isSafeToken, npmForward, refreshMain,
665
+ CONFIG_TEMPLATE, HALTS, SEMVER_RE,
666
+ };
package/src/module-api.js CHANGED
@@ -1041,8 +1041,10 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
1041
1041
  // landed since 1.19.305 with no explicit bump. run 32662000590. (task 1002620)
1042
1042
  // 1.19.307 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1043
1043
  // landed since 1.19.306 with no explicit bump. run 32663876767. (task 1002620)
1044
+ // 1.19.308 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1045
+ // landed since 1.19.307 with no explicit bump. run 32664559221. (task 1002620)
1044
1046
  // ---------------------------------------------------------------------------
1045
- const CORE_VERSION = '1.19.307'; // CI auto-patch carrier (ADR 0161); see changelog above
1047
+ const CORE_VERSION = '1.19.308'; // CI auto-patch carrier (ADR 0161); see changelog above
1046
1048
 
1047
1049
  // A namespaced logger so a module's log lines are attributable + consistent.
1048
1050
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -0,0 +1,362 @@
1
+ // tests/go_live.mjs
2
+ //
3
+ // task 1003189 — go-live is one command.
4
+ //
5
+ // What is worth pinning here is NOT the happy path (that needs a real box) but the
6
+ // REFUSALS. The whole value of this script is that it stops instead of guessing, and
7
+ // every halt below is a trap this pipeline has actually paid for at least once:
8
+ //
9
+ // UNRELEASED_ON_MAIN — the ten-day stale-prod incident (blocker 1000097), and
10
+ // the publish push-race that leaves main's code carried by
11
+ // no release at all.
12
+ // MARKER_ABSENT — "the release current when your fix merges usually does
13
+ // NOT contain your fix" (ADR 0161 carriers bump on their
14
+ // own cadence). Deploying it is a clean go-live that
15
+ // changes nothing, and every downstream check agrees.
16
+ // ALREADY_LIVE / LIVE_AHEAD — a no-op bump reported as success.
17
+ // DIRTY_CHECKOUT — CORE_VERSION read from a dirty tree is not what shipped.
18
+ //
19
+ // The shell-safety tests are the other half. This script composes a command that runs
20
+ // as root over ssh, so a config value carrying `$(...)`, a backtick or a `;` must be
21
+ // REFUSED, not quoted-and-hoped-for (the #681 injection class).
22
+ //
23
+ // Run: node --test tests/go_live.mjs
24
+
25
+ import { test } from 'node:test';
26
+ import assert from 'node:assert/strict';
27
+ import { createRequire } from 'node:module';
28
+ import path from 'node:path';
29
+ import { fileURLToPath } from 'node:url';
30
+
31
+ const require = createRequire(import.meta.url);
32
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
33
+ const G = require('../scripts/gds/go-live.js');
34
+
35
+ const okTarget = {
36
+ ssh: 'my-box',
37
+ instanceDir: '/home/me/inst',
38
+ service: 'inst.service',
39
+ healthUrl: 'http://127.0.0.1:3002/healthz',
40
+ versionUrl: 'http://127.0.0.1:3002/version',
41
+ deployTimer: 'inst-deploy.timer',
42
+ };
43
+
44
+ // A divergence result shaped like deploy-divergence.check() returns.
45
+ const div = (over = {}) => ({
46
+ ok: true, onMain: '1.19.306', live: { coreVersion: '1.19.305' },
47
+ hasUnreleased: false, unreleased: { count: 0 }, ...over,
48
+ });
49
+
50
+ // ---------------------------------------------------------------------------
51
+ // Halt matrix
52
+ // ---------------------------------------------------------------------------
53
+
54
+ test('a clean gap plans with no halts', () => {
55
+ const p = G.planGoLive({ div: div(), target: okTarget, targetVersion: '1.19.306' });
56
+ assert.equal(p.halts.length, 0, JSON.stringify(p.halts));
57
+ assert.equal(p.from, '1.19.305');
58
+ });
59
+
60
+ test('unreleased commits on main HALT the go-live', () => {
61
+ const p = G.planGoLive({
62
+ div: div({ hasUnreleased: true, unreleased: { count: 7 } }),
63
+ target: okTarget, targetVersion: '1.19.306',
64
+ });
65
+ const h = p.halts.find((x) => x.code === 'UNRELEASED_ON_MAIN');
66
+ assert.ok(h, 'expected UNRELEASED_ON_MAIN');
67
+ // The detail must name the push-race, or the reader assumes it is only timing.
68
+ assert.match(h.detail, /publish/i);
69
+ assert.match(h.detail, /7 commit/);
70
+ });
71
+
72
+ test('--allow-unreleased downgrades that halt to a note rather than dropping it', () => {
73
+ const p = G.planGoLive({
74
+ div: div({ hasUnreleased: true, unreleased: { count: 7 } }),
75
+ target: okTarget, targetVersion: '1.19.306', allowUnreleased: true,
76
+ });
77
+ assert.equal(p.halts.find((x) => x.code === 'UNRELEASED_ON_MAIN'), undefined);
78
+ const n = p.notes.find((x) => x.code === 'UNRELEASED_ON_MAIN');
79
+ assert.ok(n && n.downgraded, 'the condition must still be REPORTED, just not fatal');
80
+ });
81
+
82
+ test('a no-op bump is a halt, not a success', () => {
83
+ const p = G.planGoLive({
84
+ div: div({ live: { coreVersion: '1.19.306' } }), target: okTarget, targetVersion: '1.19.306',
85
+ });
86
+ assert.ok(p.halts.find((x) => x.code === 'ALREADY_LIVE'));
87
+ });
88
+
89
+ test('live ahead of the target halts rather than silently downgrading', () => {
90
+ const p = G.planGoLive({
91
+ div: div({ live: { coreVersion: '1.19.310' } }), target: okTarget, targetVersion: '1.19.306',
92
+ });
93
+ assert.ok(p.halts.find((x) => x.code === 'LIVE_AHEAD'));
94
+ });
95
+
96
+ test('a dirty core checkout halts — CORE_VERSION there is not what shipped', () => {
97
+ const p = G.planGoLive({
98
+ div: div(), target: okTarget, targetVersion: '1.19.306', dirtyCheckout: ' M src/module-api.js',
99
+ });
100
+ assert.ok(p.halts.find((x) => x.code === 'DIRTY_CHECKOUT'));
101
+ });
102
+
103
+ test('an undetermined divergence halts and does not fall through to a bump', () => {
104
+ const p = G.planGoLive({
105
+ div: { undetermined: true, reason: 'could not read /version' }, target: okTarget, targetVersion: '1.19.306',
106
+ });
107
+ assert.equal(p.halts.length, 1);
108
+ assert.equal(p.halts[0].code, 'DIVERGENCE_UNDETERMINED');
109
+ assert.equal(p.from, null, 'must not invent a from-version');
110
+ });
111
+
112
+ // ---------------------------------------------------------------------------
113
+ // Shell safety — this builds a command that runs as root over ssh
114
+ // ---------------------------------------------------------------------------
115
+
116
+ test('config values carrying shell metacharacters are REFUSED', () => {
117
+ for (const evil of [
118
+ '$(touch /tmp/pwned)',
119
+ '`id`',
120
+ 'box; rm -rf /',
121
+ 'box && curl evil.example',
122
+ 'box\nrm -rf /',
123
+ 'box|tee /etc/passwd',
124
+ "box'quote",
125
+ ]) {
126
+ const errs = G.validateTarget('t', { ...okTarget, ssh: evil });
127
+ assert.ok(errs.length > 0, `expected refusal for ${JSON.stringify(evil)}`);
128
+ assert.match(errs.join(' '), /unsafe in a shell/);
129
+ }
130
+ });
131
+
132
+ test('env values and keys are validated too', () => {
133
+ assert.ok(G.validateTarget('t', { ...okTarget, env: { PGDATABASE: '$(id)' } }).length > 0);
134
+ assert.ok(G.validateTarget('t', { ...okTarget, env: { 'BAD KEY': 'x' } }).length > 0);
135
+ assert.equal(G.validateTarget('t', { ...okTarget, env: { PGDATABASE: 'mydb' } }).length, 0);
136
+ });
137
+
138
+ test('a typo’d field is reported, never silently ignored', () => {
139
+ // A misspelled healthUrl that is quietly dropped would DISABLE the health gate —
140
+ // exactly the quiet failure this pipeline keeps being bitten by.
141
+ const errs = G.validateTarget('t', { ...okTarget, healthURL: 'http://x/healthz' });
142
+ assert.match(errs.join(' '), /unknown field "healthURL"/);
143
+ });
144
+
145
+ test('missing required fields are named individually', () => {
146
+ const errs = G.validateTarget('t', { ssh: 'box' });
147
+ for (const f of ['instanceDir', 'service', 'healthUrl', 'versionUrl', 'deployTimer']) {
148
+ assert.match(errs.join(' '), new RegExp(`missing required field "${f}"`));
149
+ }
150
+ });
151
+
152
+ test('a valid target passes clean', () => {
153
+ assert.deepEqual(G.validateTarget('t', okTarget), []);
154
+ });
155
+
156
+ test('asOwner composes the documented sudo -u … env … shape', () => {
157
+ const cmd = G.asOwner({ runAs: 'me', env: { PGDATABASE: 'mydb' } }, 'echo hi');
158
+ assert.match(cmd, /^sudo -u me -H env PGDATABASE=mydb HOME=\/home\/me echo hi$/);
159
+ // PGDATABASE is load-bearing: without it migrate targets a stale default and
160
+ // the upgrade auto-rolls-back. It must survive into the command.
161
+ assert.ok(cmd.includes('PGDATABASE=mydb'));
162
+ });
163
+
164
+ test('asOwner with no runAs still carries env', () => {
165
+ assert.equal(G.asOwner({ env: { A: 'b' } }, 'x'), 'env A=b x');
166
+ assert.equal(G.asOwner({}, 'x'), 'x');
167
+ });
168
+
169
+ // ---------------------------------------------------------------------------
170
+ // Config loading
171
+ // ---------------------------------------------------------------------------
172
+
173
+ test('an absent config reports NO_TARGET_CONFIG with a template, not a stack trace', () => {
174
+ const r = G.loadConfig({ configPath: 'config/definitely-not-here.json', exists: () => false });
175
+ assert.equal(r.error, 'NO_TARGET_CONFIG');
176
+ assert.ok(r.template.includes('instanceDir'));
177
+ });
178
+
179
+ test('a malformed config is reported as malformed, not treated as empty', () => {
180
+ const r = G.loadConfig({ configPath: 'x.json', exists: () => true, readFile: () => '{ not json' });
181
+ assert.equal(r.error, 'CONFIG_MALFORMED');
182
+ });
183
+
184
+ test('a config without targets is malformed rather than a silent no-op', () => {
185
+ const r = G.loadConfig({ configPath: 'x.json', exists: () => true, readFile: () => '{"defaultTarget":"web"}' });
186
+ assert.equal(r.error, 'CONFIG_MALFORMED');
187
+ assert.match(r.detail, /targets/);
188
+ });
189
+
190
+ test('the shipped template is itself valid config', () => {
191
+ const r = G.loadConfig({ configPath: 'x.json', exists: () => true, readFile: () => G.CONFIG_TEMPLATE });
192
+ assert.ok(r.ok, `template did not parse: ${r.detail || ''}`);
193
+ const sel = G.selectTarget(r.config, undefined);
194
+ assert.ok(sel.ok, `template target invalid: ${sel.detail || ''}`);
195
+ assert.equal(sel.name, 'web');
196
+ });
197
+
198
+ test('an unknown target names what IS available', () => {
199
+ const r = G.loadConfig({ configPath: 'x.json', exists: () => true, readFile: () => G.CONFIG_TEMPLATE });
200
+ const sel = G.selectTarget(r.config, 'nope');
201
+ assert.equal(sel.error, 'UNKNOWN_TARGET');
202
+ assert.match(sel.detail, /web/);
203
+ });
204
+
205
+ // ---------------------------------------------------------------------------
206
+ // Markers + version parsing
207
+ // ---------------------------------------------------------------------------
208
+
209
+ test('parseMarker splits on the FIRST = so a needle may contain one', () => {
210
+ assert.deepEqual(G.parseMarker('src/a.js=const X = 1'), { path: 'src/a.js', needle: 'const X = 1' });
211
+ });
212
+
213
+ test('a malformed marker is rejected', () => {
214
+ assert.ok(G.parseMarker('noequals').error);
215
+ assert.ok(G.parseMarker('=needle').error);
216
+ assert.ok(G.parseMarker('path=').error);
217
+ assert.equal(G.parseMarker(''), null);
218
+ });
219
+
220
+ test('the version pattern accepts releases and rejects free text', () => {
221
+ for (const v of ['1.19.306', '1.0.0', '2.3.4-rc.1']) assert.ok(G.SEMVER_RE.test(v), v);
222
+ for (const v of ['latest', '1.19', 'v1.19.306', '1.19.306; rm -rf /', '']) assert.ok(!G.SEMVER_RE.test(v), v);
223
+ });
224
+
225
+ // ---------------------------------------------------------------------------
226
+ // Rendering — the report is the product; it must not claim more than it proved
227
+ // ---------------------------------------------------------------------------
228
+
229
+ test('the plan names every halt it found', () => {
230
+ const p = G.planGoLive({
231
+ div: div({ hasUnreleased: true, unreleased: { count: 3 } }),
232
+ target: okTarget, targetVersion: '1.19.306', dirtyCheckout: ' M x',
233
+ });
234
+ const out = G.renderPlan(p, { targetName: 'web' });
235
+ assert.match(out, /UNRELEASED_ON_MAIN/);
236
+ assert.match(out, /DIRTY_CHECKOUT/);
237
+ assert.match(out, /will NOT proceed/);
238
+ });
239
+
240
+ test('a clean plan tells you the next command', () => {
241
+ const out = G.renderPlan(G.planGoLive({ div: div(), target: okTarget, targetVersion: '1.19.306' }), { targetName: 'web' });
242
+ assert.match(out, /--apply/);
243
+ });
244
+
245
+ test('an uncommitted pin is reported as the durability failure it is', () => {
246
+ const out = G.renderReport({
247
+ targetName: 'web', from: '1.19.305', to: '1.19.306', liveVersion: '1.19.306',
248
+ pinCommitted: false, steps: [],
249
+ });
250
+ assert.match(out, /revert this bump/);
251
+ });
252
+
253
+ // ---------------------------------------------------------------------------
254
+ // The content proof must fail CLOSED
255
+ //
256
+ // This is the bug that got through twice. `grep -c` PRINTS 0 and EXITS 1 on no
257
+ // match, so the obvious `|| echo 0` emitted "0\n0" — which Number() turns into
258
+ // NaN, and NaN === 0 is false, so an ABSENT marker sailed through as no-halt.
259
+ // A content proof that fails open is worse than none: it certifies a release
260
+ // that does not contain the change, and every downstream check agrees.
261
+ // ---------------------------------------------------------------------------
262
+
263
+ test('a clean count reads as that count', () => {
264
+ assert.equal(G.parseMarkerHits('3'), 3);
265
+ assert.equal(G.parseMarkerHits('0'), 0);
266
+ assert.equal(G.parseMarkerHits(' 12 \n'), 12);
267
+ });
268
+
269
+ test('a double-printed zero reads as 0 (absent), never NaN', () => {
270
+ const v = G.parseMarkerHits('0\n0');
271
+ assert.equal(v, 0);
272
+ assert.ok(!Number.isNaN(v), 'NaN here is what let an absent marker pass');
273
+ });
274
+
275
+ test('anything that is not a count is UNCHECKED, never a confident 0', () => {
276
+ for (const junk of ['', null, undefined, 'PACK_FAILED', 'PACK_FAILED npm error E404', 'tar: not found', 'abc']) {
277
+ assert.equal(G.parseMarkerHits(junk), 'PACK_FAILED', JSON.stringify(junk));
278
+ }
279
+ });
280
+
281
+ test("'could not check' is a distinct halt from 'absent'", () => {
282
+ // They must never collapse: one says fix your fetch, the other says pick a
283
+ // later release. Conflating them sends you chasing a version that is not there.
284
+ assert.ok(G.HALTS.MARKER_UNCHECKED);
285
+ assert.ok(G.HALTS.MARKER_ABSENT);
286
+ assert.notEqual(G.HALTS.MARKER_UNCHECKED, G.HALTS.MARKER_ABSENT);
287
+ });
288
+
289
+ // ---------------------------------------------------------------------------
290
+ // The NPM_TOKEN forwarding trap
291
+ //
292
+ // `bongos upgrade --registry` needs NPM_TOKEN or npm 404s the PRIVATE core and the
293
+ // upgrade auto-rolls-back — which reads as "the release is broken" when it is only
294
+ // "the token never arrived". Sourcing the root-only env file in the OUTER shell is
295
+ // necessary and NOT sufficient: sudo's env_reset drops it, and an explicit
296
+ // `env K=V` list that omits it drops it too. This cost a real failed go-live.
297
+ // ---------------------------------------------------------------------------
298
+
299
+ test('a forwarded var is passed BY REFERENCE, never by value', () => {
300
+ const cmd = G.asOwner({ runAs: 'lars', env: { PGDATABASE: 'cb' } }, 'npm install', { forward: ['NPM_TOKEN'] });
301
+ assert.match(cmd, /NPM_TOKEN="\$NPM_TOKEN"/);
302
+ // The point of by-reference: the secret's VALUE must never appear in the script text.
303
+ assert.ok(!cmd.includes('npm_'), 'a token value must never be interpolated here');
304
+ });
305
+
306
+ test('forwarding is only wired when the target names an env file to source from', () => {
307
+ // Otherwise K="$K" expands to empty and silently does nothing — a quiet no-op
308
+ // is worse than not trying.
309
+ assert.deepEqual(G.npmForward({ npmEnvFile: '/etc/x/npm.env' }), ['NPM_TOKEN']);
310
+ assert.deepEqual(G.npmForward({}), []);
311
+ });
312
+
313
+ test('an unvetted variable name is never interpolated', () => {
314
+ const cmd = G.asOwner({ runAs: 'me' }, 'x', { forward: ['GOOD', 'bad name', '$(id)', '1BAD'] });
315
+ assert.match(cmd, /GOOD="\$GOOD"/);
316
+ assert.ok(!cmd.includes('bad name') && !cmd.includes('$(id)') && !cmd.includes('1BAD'));
317
+ });
318
+
319
+ // ---------------------------------------------------------------------------
320
+ // A failed run must say what the tool said
321
+ // ---------------------------------------------------------------------------
322
+
323
+ test('the report surfaces the failing tool’s own words', () => {
324
+ const out = G.renderReport({
325
+ ok: false, failedAt: 'UPGRADE', targetName: 'web', from: '1.19.305', to: '1.19.306',
326
+ rolledBack: true, steps: [{ name: 'upgrade', ok: false, detail: 'AUTO-ROLLBACK ran' }],
327
+ raw: { stdout: 'npm error 404 Not Found - GET https://registry.npmjs.org/@bongos%2fcore\nup to date in 2s', stderr: '' },
328
+ });
329
+ assert.match(out, /404 Not Found/, 'the diagnosis must reach the reader');
330
+ assert.match(out, /not broken/, 'a rollback must be distinguished from an outage');
331
+ });
332
+
333
+ test('failureExcerpt prefers diagnostic lines over trailing noise', () => {
334
+ const raw = { stdout: ['ok a', 'ok b', 'npm error code E404', 'up to date in 2s'].join('\n'), stderr: '' };
335
+ const ex = G.failureExcerpt(raw);
336
+ assert.ok(ex.some((l) => l.includes('E404')));
337
+ assert.ok(!ex.some((l) => l === 'ok a'));
338
+ });
339
+
340
+ test('failureExcerpt is safe on an empty or missing result', () => {
341
+ assert.deepEqual(G.failureExcerpt(null), []);
342
+ assert.deepEqual(G.failureExcerpt({ stdout: '', stderr: '' }), []);
343
+ });
344
+
345
+ // ---------------------------------------------------------------------------
346
+ // A stale origin/main ref silently retargets the whole go-live
347
+ // ---------------------------------------------------------------------------
348
+
349
+ test('refreshMain reports a failed fetch instead of throwing', () => {
350
+ const r = G.refreshMain({ run: () => ({ status: 1, stderr: 'fatal: could not read from remote\n' }) });
351
+ assert.equal(r.ok, false);
352
+ assert.match(r.reason, /could not read from remote/);
353
+ });
354
+
355
+ test('refreshMain succeeds quietly', () => {
356
+ assert.deepEqual(G.refreshMain({ run: () => ({ status: 0, stdout: '', stderr: '' }) }), { ok: true });
357
+ });
358
+
359
+ test('a spawn error is handled, not thrown', () => {
360
+ const r = G.refreshMain({ run: () => ({ error: new Error('ENOENT'), status: null }) });
361
+ assert.equal(r.ok, false);
362
+ });