@bongos/core 1.19.306 → 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 +39 -19
- package/.claude/skills/builder-ship/SKILL.md +21 -9
- package/.claude/skills/fix-task/SKILL.md +1 -1
- package/docs/recipes/claude-design-loop.md +1 -1
- package/docs/recipes/core-release-pipeline.md +32 -0
- package/docs/recipes/figma-design-loop.md +1 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/cli-lib.js +53 -0
- package/scripts/gds/go-live.js +666 -0
- package/scripts/gds/ship-honesty.js +150 -0
- package/scripts/gds/ship.js +23 -32
- package/src/module-api.js +5 -1
- package/tests/go_live.mjs +362 -0
- package/tests/route_rank_check.mjs +117 -9
- package/tests/ship_cannot_lie.mjs +168 -0
- package/tests/ship_smoke_gate.mjs +9 -3
|
@@ -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
|
+
};
|