@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 +21 -11
- package/docs/recipes/core-release-pipeline.md +32 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/go-live.js +666 -0
- package/src/module-api.js +3 -1
- package/tests/go_live.mjs +362 -0
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.
|
|
6
|
-
"core_contract": "1.19.
|
|
7
|
-
"source_commit": "
|
|
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:
|
|
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":
|
|
14
|
+
"functional_verbatim": 1420,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
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": "
|
|
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": "
|
|
4580
|
+
"sha256": "4293f77940579b7c64fa5bc09f5a132c27efb5e4d21ee37a29ee3cf08d60ad88"
|
|
4581
4581
|
},
|
|
4582
4582
|
{
|
|
4583
4583
|
"path": "package.json",
|
|
4584
4584
|
"mode": "0000644",
|
|
4585
|
-
"sha256": "
|
|
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": "
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
+
});
|