@bongos/core 1.19.71 → 1.19.72
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 +18 -13
- package/docs/recipes/upgrading-the-core.md +4 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/upgrade.js +140 -2
- package/src/module-api.js +3 -1
- package/tests/upgrade.mjs +5 -1
- package/tests/upgrade_persist_pin.mjs +191 -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.72",
|
|
6
|
+
"core_contract": "1.19.72",
|
|
7
|
+
"source_commit": "36150e839bd63b4544b7828ad26137d2b2501a07",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-08-
|
|
9
|
+
"built_at": "2026-08-12T16:31:58.158Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
12
|
"docs_redacted": 294,
|
|
13
13
|
"agent_docs_stubbed": 21,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 1224,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 1539,
|
|
20
|
+
"tree_sha256": "dc8a5fa6b9f2b5d49dec5763477b0793d156abea3894c575d3989591a6b4ad1c",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/blocker-review/SKILL.md",
|
|
@@ -1617,7 +1617,7 @@
|
|
|
1617
1617
|
{
|
|
1618
1618
|
"path": "docs/recipes/upgrading-the-core.md",
|
|
1619
1619
|
"mode": "0000644",
|
|
1620
|
-
"sha256": "
|
|
1620
|
+
"sha256": "732f3b01b7d2e6cc238dd5f48d5ed774f6c2c27ecc737710047758bf85f2d9e5"
|
|
1621
1621
|
},
|
|
1622
1622
|
{
|
|
1623
1623
|
"path": "docs/recipes/work-on-a-task-from-your-dev-box.md",
|
|
@@ -4052,12 +4052,12 @@
|
|
|
4052
4052
|
{
|
|
4053
4053
|
"path": "package-lock.json",
|
|
4054
4054
|
"mode": "0000644",
|
|
4055
|
-
"sha256": "
|
|
4055
|
+
"sha256": "183fa8f7d1c63c5188516529049a52a88cae64c235b8350f9362ea8e6175f886"
|
|
4056
4056
|
},
|
|
4057
4057
|
{
|
|
4058
4058
|
"path": "package.json",
|
|
4059
4059
|
"mode": "0000644",
|
|
4060
|
-
"sha256": "
|
|
4060
|
+
"sha256": "1293840e72b03f675e2bc9c30a42f4885f4d54a56674fd605260a0f5549bd8b5"
|
|
4061
4061
|
},
|
|
4062
4062
|
{
|
|
4063
4063
|
"path": "public-docs/index.html",
|
|
@@ -5317,7 +5317,7 @@
|
|
|
5317
5317
|
{
|
|
5318
5318
|
"path": "scripts/gds/upgrade.js",
|
|
5319
5319
|
"mode": "0000644",
|
|
5320
|
-
"sha256": "
|
|
5320
|
+
"sha256": "b539715de4893f8fa68c53c145ac2f54de39ccc5517f9e5ad17540c487dc24b0"
|
|
5321
5321
|
},
|
|
5322
5322
|
{
|
|
5323
5323
|
"path": "scripts/gds/validate-design.js",
|
|
@@ -5682,7 +5682,7 @@
|
|
|
5682
5682
|
{
|
|
5683
5683
|
"path": "src/module-api.js",
|
|
5684
5684
|
"mode": "0000644",
|
|
5685
|
-
"sha256": "
|
|
5685
|
+
"sha256": "b5c45d792faf401db67d81fa567c437b25e2ba9ef521e2f9e8cb052f474b0098"
|
|
5686
5686
|
},
|
|
5687
5687
|
{
|
|
5688
5688
|
"path": "src/module-loader/loader.js",
|
|
@@ -7662,7 +7662,12 @@
|
|
|
7662
7662
|
{
|
|
7663
7663
|
"path": "tests/upgrade.mjs",
|
|
7664
7664
|
"mode": "0000644",
|
|
7665
|
-
"sha256": "
|
|
7665
|
+
"sha256": "60e55591174f59238486d318fb92dc8fd8251eb73435d690e38ea83a2a5f235e"
|
|
7666
|
+
},
|
|
7667
|
+
{
|
|
7668
|
+
"path": "tests/upgrade_persist_pin.mjs",
|
|
7669
|
+
"mode": "0000644",
|
|
7670
|
+
"sha256": "07fd48b255bbd2370580b62a8f77d7fc981b0dfdefcc5be6973a9c4d6cb49add"
|
|
7666
7671
|
},
|
|
7667
7672
|
{
|
|
7668
7673
|
"path": "tests/verify_task_guard.mjs",
|
|
@@ -52,6 +52,10 @@ The instance runs on the droplet (co-tenant): systemd `emersonian-circles.servic
|
|
|
52
52
|
```
|
|
53
53
|
5. **Verify** — `systemctl is-active`, local + public `/healthz` 200, `/version`, `installed core == <version>`, the `core_upgrades` row.
|
|
54
54
|
6. **Update the repo of record** — run the channel on the laptop clone (`--skip-migrate`, no DB there), commit the pin bump (drop the superseded tarball), `git push origin`.
|
|
55
|
+
|
|
56
|
+
> **The pin MUST reach the repo of record, or the bump silently un-does itself** (task 1002712). A pull-deploy instance resets to `origin/main`; a pin that lives only in the working tree is reverted the next time anything lands, and the lockfile change then trips `npm ci`, which reinstalls the OLD core. Nothing errors. cloudbongos.com lost thirteen releases exactly this way — the `core_upgrades` ledger held two successful `auto-subscription (patch)` rows whose result no longer existed on disk, because deploy.sh short-circuits while `HEAD == origin/main` so the upgrade *looks* fine right up until someone else pushes.
|
|
57
|
+
>
|
|
58
|
+
> `bongos upgrade` now **warns loudly** whenever it leaves the pin uncommitted in a git repo, and `--commit-pin` (optionally `--git-remote <name>`) commits + pushes `package.json` + `package-lock.json` for you. A commit that fails to push is reported as a failure too — deploy.sh resets to the *remote*, so committing locally saves nothing. Verify durability by forcing a deploy tick (`sudo systemctl start <instance>-deploy.service`) and re-reading the pin.
|
|
55
59
|
7. **Align the droplet checkout** — the droplet has no deploy key (`git pull` fails; idea 509), so `git bundle` the new commit on the laptop, `scp`, `git fetch <bundle> && git reset --hard FETCH_HEAD`. `node_modules` is already the new version, so no re-`npm ci`; a final restart makes `/version` report the new commit.
|
|
56
60
|
|
|
57
61
|
## Auto-rollback on failure (task 2149)
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.72",
|
|
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.72",
|
|
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.72",
|
|
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",
|
package/scripts/gds/upgrade.js
CHANGED
|
@@ -124,6 +124,94 @@ function gitTreeClean(instanceDir, run = spawnSync) {
|
|
|
124
124
|
return { ok: dirty === '', dirty };
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
+
// ---- persisting the pin (task 1002712) --------------------------------------
|
|
128
|
+
//
|
|
129
|
+
// THE BUG THIS EXISTS FOR. `bongos upgrade` rewrites package.json +
|
|
130
|
+
// package-lock.json in the instance WORKING TREE and stops there. On a
|
|
131
|
+
// pull-deploy instance that is not durable, and the way it fails is silent:
|
|
132
|
+
//
|
|
133
|
+
// deploy.sh short-circuits while HEAD == origin/main, so a freshly-upgraded
|
|
134
|
+
// (but uncommitted) tree survives — health-green, ledger-recorded, apparently
|
|
135
|
+
// done. Then ANY later push to the instance repo moves origin/main, deploy.sh
|
|
136
|
+
// runs `git reset --hard origin/main`, and the pin reverts to whatever is
|
|
137
|
+
// COMMITTED. The lockfile hash changes with it, which trips deploy.sh's
|
|
138
|
+
// `npm ci`, which reinstalls the OLD core. Nothing errors, at any point.
|
|
139
|
+
//
|
|
140
|
+
// cloudbongos.com sat on 1.19.56 for thirteen releases exactly this way: the
|
|
141
|
+
// core_upgrades ledger holds two successful `auto-subscription (patch)` rows
|
|
142
|
+
// (1.19.56→1.19.60, 1.19.60→1.19.62) whose result no longer existed.
|
|
143
|
+
//
|
|
144
|
+
// The pin is REPO state, so the repo of record is the right place for it —
|
|
145
|
+
// deploy.sh treating origin/main as authoritative is correct, not the bug.
|
|
146
|
+
// Hence: --commit-pin lands it, and its absence WARNS rather than staying quiet.
|
|
147
|
+
const PIN_FILES = ['package.json', 'package-lock.json'];
|
|
148
|
+
|
|
149
|
+
function gitCurrentBranch(instanceDir, run = spawnSync) {
|
|
150
|
+
const r = run('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: instanceDir, encoding: 'utf8' });
|
|
151
|
+
if (!r || r.error || r.status !== 0) return null;
|
|
152
|
+
const b = (r.stdout || '').trim();
|
|
153
|
+
return b && b !== 'HEAD' ? b : null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Which of the pin files git currently reports as modified.
|
|
157
|
+
function dirtyPinFiles(instanceDir, run = spawnSync) {
|
|
158
|
+
const r = run('git', ['status', '--porcelain', '--', ...PIN_FILES], { cwd: instanceDir, encoding: 'utf8' });
|
|
159
|
+
if (!r || r.error || r.status !== 0) return null; // not a git repo — nothing to persist
|
|
160
|
+
return (r.stdout || '').split('\n').map((l) => l.slice(3).trim()).filter(Boolean);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function persistPin({ instanceDir, fromVersion, toVersion, commitPin, remote = 'origin', dryRun = false },
|
|
164
|
+
run = spawnSync, log = console.log, err = console.error) {
|
|
165
|
+
const dirty = dirtyPinFiles(instanceDir, run);
|
|
166
|
+
if (dirty === null) return { applicable: false, reason: 'not a git repo' };
|
|
167
|
+
if (!dirty.length) return { applicable: false, reason: 'pin files already match the repo' };
|
|
168
|
+
|
|
169
|
+
const branch = gitCurrentBranch(instanceDir, run);
|
|
170
|
+
|
|
171
|
+
if (!commitPin) {
|
|
172
|
+
// LOUD — this is the whole point. A silent uncommitted pin is the bug.
|
|
173
|
+
err(` ! PIN NOT COMMITTED — ${dirty.join(', ')} changed in the working tree only.`);
|
|
174
|
+
err(' On a pull-deploy instance the next push to the repo of record makes deploy.sh');
|
|
175
|
+
err(` 'git reset --hard ${remote}/${branch || 'main'}', which REVERTS this pin and lets npm ci`);
|
|
176
|
+
err(` reinstall ${fromVersion || 'the previous core'}. Re-run with --commit-pin, or commit + push:`);
|
|
177
|
+
// Single-quote the interpolated values. Nothing here executes this string — it is
|
|
178
|
+
// printed for a human to paste — but an unquoted branch or remote carrying shell
|
|
179
|
+
// metacharacters would misbehave once pasted, so emit it paste-safe.
|
|
180
|
+
const q = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`;
|
|
181
|
+
const at = `git -C ${q(instanceDir)}`;
|
|
182
|
+
err(` ${at} add ${PIN_FILES.map(q).join(' ')} && ${at} commit -m ${q(`pin @bongos/core ${toVersion}`)} && ${at} push ${q(remote)} ${q(`HEAD:${branch || 'main'}`)}`);
|
|
183
|
+
return { applicable: true, committed: false, pushed: false, dirty, warned: true };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (dryRun) {
|
|
187
|
+
log(` [dry-run] would commit + push ${dirty.join(', ')} to ${remote}/${branch || 'main'}`);
|
|
188
|
+
return { applicable: true, committed: false, pushed: false, dirty, dryRun: true };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const message = `pin @bongos/core ${toVersion}${fromVersion ? ` (was ${fromVersion})` : ''}`;
|
|
192
|
+
const add = run('git', ['add', ...PIN_FILES], { cwd: instanceDir, encoding: 'utf8' });
|
|
193
|
+
if (!add || add.error || add.status !== 0) {
|
|
194
|
+
err(' ! could not stage the pin files — commit + push them by hand or the next deploy reverts this bump.');
|
|
195
|
+
return { applicable: true, committed: false, pushed: false, dirty, error: 'git add failed' };
|
|
196
|
+
}
|
|
197
|
+
const commit = run('git', ['commit', '-m', message], { cwd: instanceDir, encoding: 'utf8' });
|
|
198
|
+
if (!commit || commit.error || commit.status !== 0) {
|
|
199
|
+
err(' ! could not commit the pin — commit + push it by hand or the next deploy reverts this bump.');
|
|
200
|
+
return { applicable: true, committed: false, pushed: false, dirty, error: 'git commit failed' };
|
|
201
|
+
}
|
|
202
|
+
log(` ✓ committed the pin (${dirty.join(', ')})`);
|
|
203
|
+
|
|
204
|
+
const push = run('git', ['push', remote, `HEAD:${branch || 'main'}`], { cwd: instanceDir, encoding: 'utf8' });
|
|
205
|
+
if (!push || push.error || push.status !== 0) {
|
|
206
|
+
// Committed but not pushed is still doomed: deploy.sh resets to the REMOTE.
|
|
207
|
+
err(` ! commit landed locally but the push to ${remote} FAILED — this bump is still not durable.`);
|
|
208
|
+
err(` Push it: git -C ${instanceDir} push ${remote} HEAD:${branch || 'main'}`);
|
|
209
|
+
return { applicable: true, committed: true, pushed: false, dirty, error: 'git push failed' };
|
|
210
|
+
}
|
|
211
|
+
log(` ✓ pushed the pin to ${remote}/${branch || 'main'} — a pull-deploy reset can no longer revert it`);
|
|
212
|
+
return { applicable: true, committed: true, pushed: true, dirty };
|
|
213
|
+
}
|
|
214
|
+
|
|
127
215
|
// Copy the source .tgz (+ its side-car manifest, if present) into the consumer's vendor/ dir under the
|
|
128
216
|
// canonical name. Returns the vendor-relative tarball path to pin.
|
|
129
217
|
function vendorTarball({ instanceDir, fromTgz, targetVersion }, fsImpl = fs) {
|
|
@@ -203,6 +291,34 @@ function npmInstall(instanceDir, run = spawnSync) {
|
|
|
203
291
|
return !r || r.error ? { ok: false, reason: r && r.error ? r.error.message : 'spawn failed' } : { ok: r.status === 0, code: r.status };
|
|
204
292
|
}
|
|
205
293
|
|
|
294
|
+
// migrate.sh reaches Postgres over the local socket as the CURRENT OS USER (peer
|
|
295
|
+
// auth) — so running an upgrade under `sudo` connects as `root`, which usually has
|
|
296
|
+
// no Postgres role. The failure surfaces mid-run as a raw
|
|
297
|
+
// `FATAL: role "root" does not exist`, AFTER the pin has already moved, which then
|
|
298
|
+
// triggers the whole auto-rollback dance for what is really an operator mistake.
|
|
299
|
+
// This names it up front instead (task 1002712).
|
|
300
|
+
function preflightDbIdentity({ instanceDir, db }, run = spawnSync) {
|
|
301
|
+
const r = run('psql', ['-tAc', 'select 1'], {
|
|
302
|
+
cwd: instanceDir, encoding: 'utf8',
|
|
303
|
+
env: { ...process.env, ...(db ? { PGDATABASE: db } : {}) },
|
|
304
|
+
});
|
|
305
|
+
if (!r || r.error) return { ok: true, skipped: 'psql not on PATH — leaving it to migrate' };
|
|
306
|
+
if (r.status === 0) return { ok: true };
|
|
307
|
+
|
|
308
|
+
const out = `${r.stderr || ''}${r.stdout || ''}`;
|
|
309
|
+
const m = out.match(/role "([^"]+)" does not exist/i);
|
|
310
|
+
if (!m) return { ok: true, skipped: 'psql failed for another reason — leaving it to migrate' };
|
|
311
|
+
return {
|
|
312
|
+
ok: false,
|
|
313
|
+
role: m[1],
|
|
314
|
+
reason: `Postgres has no role "${m[1]}" — migrate would fail after the pin moved.\n`
|
|
315
|
+
+ ` migrate.sh connects over the local socket as the CURRENT OS USER. Running the\n`
|
|
316
|
+
+ ` upgrade under sudo connects as root, which is almost never a Postgres role.\n`
|
|
317
|
+
+ ` Re-run as the user that owns the instance (e.g. sudo -u <owner> …), passing the\n`
|
|
318
|
+
+ ` npm token through if the core comes from the registry.`,
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
|
|
206
322
|
function runMigrate(instanceDir, run = spawnSync) {
|
|
207
323
|
const r = run('npm', ['run', 'migrate'], { cwd: instanceDir, stdio: 'inherit' });
|
|
208
324
|
return !r || r.error ? { ok: false, reason: r && r.error ? r.error.message : 'spawn failed' } : { ok: r.status === 0, code: r.status };
|
|
@@ -628,6 +744,15 @@ async function runUpgrade(opts, deps = {}) {
|
|
|
628
744
|
// when someone is reading this output (ADR 0166).
|
|
629
745
|
(deps.reportModulePreflight || reportModulePreflight)(mods, targetVersion, { log, err });
|
|
630
746
|
if (!mods.ok) return { ok: false, error: `module pre-check failed: ${mods.blocking.map((b) => b.key).join(', ')} incompatible with core ${targetVersion}` };
|
|
747
|
+
// Fail on the DB identity BEFORE anything moves — a wrong OS user is an operator
|
|
748
|
+
// mistake, and finding it at migrate means unwinding a pin that never should have
|
|
749
|
+
// been written (task 1002712).
|
|
750
|
+
if (!opts.skipMigrate) {
|
|
751
|
+
const dbId = (deps.preflightDbIdentity || preflightDbIdentity)({ instanceDir, db: process.env.PGDATABASE }, run);
|
|
752
|
+
if (!dbId.ok) return { ok: false, error: dbId.reason };
|
|
753
|
+
if (dbId.skipped) log(` • db identity pre-check: ${dbId.skipped}`);
|
|
754
|
+
else log(' ✓ pre-flight: database reachable as the current user');
|
|
755
|
+
}
|
|
631
756
|
|
|
632
757
|
// 1b. mandatory post-restart health check (audit H9 / task 1002222, finding F5). A bump that
|
|
633
758
|
// brings the new core live via a managed restart (--service / --restart-cmd) MUST confirm it
|
|
@@ -794,9 +919,20 @@ async function runUpgrade(opts, deps = {}) {
|
|
|
794
919
|
else if (ledger.skipped) log(` • ledger skipped (${ledger.reason})`);
|
|
795
920
|
else err(` ! ledger insert failed (${ledger.reason}) — record it manually`);
|
|
796
921
|
|
|
922
|
+
// Make the pin durable (task 1002712). Runs LAST — only a verified-healthy
|
|
923
|
+
// upgrade is worth persisting, and a rolled-back one returned long before here.
|
|
924
|
+
const pin = persistPin({
|
|
925
|
+
instanceDir,
|
|
926
|
+
fromVersion,
|
|
927
|
+
toVersion: targetVersion,
|
|
928
|
+
commitPin: opts.commitPin,
|
|
929
|
+
remote: opts.gitRemote || 'origin',
|
|
930
|
+
dryRun: opts.dryRun,
|
|
931
|
+
}, run, log, err);
|
|
932
|
+
|
|
797
933
|
const ok = versionOk && healthOk !== false;
|
|
798
934
|
log(ok ? `\n✓ upgrade complete — core ${fromVersion || '(none)'} → ${targetVersion}` : `\n! upgrade finished with warnings — review above.`);
|
|
799
|
-
return { ok, fromVersion, toVersion: targetVersion, versionOk, healthOk, ledger: ledger.ok };
|
|
935
|
+
return { ok, fromVersion, toVersion: targetVersion, versionOk, healthOk, ledger: ledger.ok, pin };
|
|
800
936
|
}
|
|
801
937
|
|
|
802
938
|
function parseArgs(argv) {
|
|
@@ -811,6 +947,8 @@ function parseArgs(argv) {
|
|
|
811
947
|
note: arg('--note', argv),
|
|
812
948
|
pinPath: arg('--pin-path', argv),
|
|
813
949
|
registry: hasFlag('--registry', argv), // ADR 0134: pull @bongos/core@<--to> from the npm registry instead of vendoring
|
|
950
|
+
commitPin: hasFlag('--commit-pin', argv), // task 1002712: commit + push package.json/-lock so a pull-deploy reset cannot revert the bump
|
|
951
|
+
gitRemote: arg('--git-remote', argv),
|
|
814
952
|
dryRun: hasFlag('--dry-run', argv),
|
|
815
953
|
force: hasFlag('--force', argv),
|
|
816
954
|
skipMigrate: hasFlag('--skip-migrate', argv),
|
|
@@ -867,7 +1005,7 @@ async function main(argv = process.argv.slice(2)) {
|
|
|
867
1005
|
module.exports = {
|
|
868
1006
|
runUpgrade, parseArgs, main, rollback,
|
|
869
1007
|
versionFromTgzPath, versionFromDep, readInstalledCoreVersion, readPinnedCoreVersion, writePin, writeRegistryPin, restorePin,
|
|
870
|
-
vendorTarball, gitTreeClean, preflightModules, reportModulePreflight, npmInstall, runMigrate, regenerateApiArtifacts, regenerateNavDocs, NAV_WHOLE_FILE_GENERATORS, missingDocAssets, healDocAssets, DOC_ASSET_GENERATORS, restartService, recordLedger, pollHealth,
|
|
1008
|
+
vendorTarball, gitTreeClean, persistPin, dirtyPinFiles, gitCurrentBranch, PIN_FILES, preflightDbIdentity, preflightModules, reportModulePreflight, npmInstall, runMigrate, regenerateApiArtifacts, regenerateNavDocs, NAV_WHOLE_FILE_GENERATORS, missingDocAssets, healDocAssets, DOC_ASSET_GENERATORS, restartService, recordLedger, pollHealth,
|
|
871
1009
|
readManifest, resolveReferenceManifest, verifyInstalledPin, // task 1002216 (audit H8) — integrity-pin verification
|
|
872
1010
|
CORE_PKG, ARTIFACT,
|
|
873
1011
|
};
|
package/src/module-api.js
CHANGED
|
@@ -569,8 +569,10 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
569
569
|
// landed since 1.19.69 with no explicit bump. run 31612248859. (task 1002620)
|
|
570
570
|
// 1.19.71 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
571
571
|
// landed since 1.19.70 with no explicit bump. run 31613220077. (task 1002620)
|
|
572
|
+
// 1.19.72 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
573
|
+
// landed since 1.19.71 with no explicit bump. run 31617976225. (task 1002620)
|
|
572
574
|
// ---------------------------------------------------------------------------
|
|
573
|
-
const CORE_VERSION = '1.19.
|
|
575
|
+
const CORE_VERSION = '1.19.72'; // CI auto-patch carrier (ADR 0161); see changelog above
|
|
574
576
|
|
|
575
577
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
576
578
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
package/tests/upgrade.mjs
CHANGED
|
@@ -181,7 +181,11 @@ t('full: pre-flight → pin → install → migrate → materialize → restart
|
|
|
181
181
|
// steps ran, in the expected order
|
|
182
182
|
assert.deepEqual(
|
|
183
183
|
calls.filter((c) => c.startsWith('npm') || c.startsWith('git') || c.startsWith('systemctl')),
|
|
184
|
-
|
|
184
|
+
// The trailing `git status` is persistPin's read-only look at whether the new
|
|
185
|
+
// pin is committed (task 1002712). It runs LAST — after verify — and without
|
|
186
|
+
// --commit-pin it only warns, so no git add/commit/push appears here.
|
|
187
|
+
['git status --porcelain', 'npm install --no-audit --no-fund', 'npm run migrate', 'systemctl restart emersonian.service',
|
|
188
|
+
'git status --porcelain -- package.json package-lock.json']
|
|
185
189
|
);
|
|
186
190
|
// materialize pointed at the freshly-installed core
|
|
187
191
|
assert.ok(materialized && materialized.coreRoot.endsWith(join('node_modules', '@bongos', 'core')));
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
// tests/upgrade_persist_pin.mjs
|
|
2
|
+
//
|
|
3
|
+
// Covers persistPin (task 1002712) — the step that makes a core bump DURABLE.
|
|
4
|
+
//
|
|
5
|
+
// The bug: `bongos upgrade` rewrote package.json + package-lock.json in the
|
|
6
|
+
// working tree and stopped. deploy.sh short-circuits while HEAD == origin/main,
|
|
7
|
+
// so the upgraded-but-uncommitted tree survived and looked done — health-green,
|
|
8
|
+
// ledger-recorded. Then any later push moved origin/main, deploy.sh ran
|
|
9
|
+
// `git reset --hard origin/main`, the pin reverted, the lockfile hash changed,
|
|
10
|
+
// and `npm ci` reinstalled the OLD core. Silent at every step.
|
|
11
|
+
//
|
|
12
|
+
// cloudbongos.com lost thirteen releases this way: two successful
|
|
13
|
+
// `auto-subscription (patch)` rows in core_upgrades whose result no longer
|
|
14
|
+
// existed on disk.
|
|
15
|
+
//
|
|
16
|
+
// `run` is injected throughout, so nothing here touches a real repo, remote, or
|
|
17
|
+
// network — the assertions are on the git argv persistPin CHOOSES.
|
|
18
|
+
//
|
|
19
|
+
// Run: node --test tests/upgrade_persist_pin.mjs
|
|
20
|
+
|
|
21
|
+
import { test } from 'node:test';
|
|
22
|
+
import assert from 'node:assert/strict';
|
|
23
|
+
import { createRequire } from 'node:module';
|
|
24
|
+
|
|
25
|
+
const require = createRequire(import.meta.url);
|
|
26
|
+
const { persistPin, dirtyPinFiles, preflightDbIdentity, PIN_FILES } = require('../scripts/gds/upgrade.js');
|
|
27
|
+
|
|
28
|
+
// A fake `run` driven by a table of [matcher, result]. Records every invocation.
|
|
29
|
+
function fakeRun(handlers = {}) {
|
|
30
|
+
const calls = [];
|
|
31
|
+
const fn = (cmd, args) => {
|
|
32
|
+
calls.push([cmd, ...args].join(' '));
|
|
33
|
+
const sub = args[0];
|
|
34
|
+
const h = handlers[sub];
|
|
35
|
+
if (typeof h === 'function') return h(args);
|
|
36
|
+
return h || { status: 0, stdout: '' };
|
|
37
|
+
};
|
|
38
|
+
fn.calls = calls;
|
|
39
|
+
return fn;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const DIRTY = { status: 0, stdout: ' M package.json\n M package-lock.json\n' };
|
|
43
|
+
const CLEAN = { status: 0, stdout: '' };
|
|
44
|
+
const NOT_A_REPO = { status: 128, stdout: '', error: null };
|
|
45
|
+
const ON_MAIN = { status: 0, stdout: 'main\n' };
|
|
46
|
+
|
|
47
|
+
const base = { instanceDir: '/srv/inst', fromVersion: '1.19.56', toVersion: '1.19.69' };
|
|
48
|
+
const quiet = () => {};
|
|
49
|
+
|
|
50
|
+
test('a non-git instance is simply out of scope', () => {
|
|
51
|
+
const run = fakeRun({ status: NOT_A_REPO });
|
|
52
|
+
const r = persistPin({ ...base, commitPin: true }, run, quiet, quiet);
|
|
53
|
+
assert.equal(r.applicable, false);
|
|
54
|
+
assert.ok(!run.calls.some((c) => c.includes('commit')), 'must not try to commit');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('an already-matching pin is a no-op', () => {
|
|
58
|
+
const run = fakeRun({ status: CLEAN });
|
|
59
|
+
const r = persistPin({ ...base, commitPin: true }, run, quiet, quiet);
|
|
60
|
+
assert.equal(r.applicable, false);
|
|
61
|
+
assert.ok(!run.calls.some((c) => c.includes('commit')));
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('without --commit-pin it WARNS and names the revert mechanism', () => {
|
|
65
|
+
// The silence is the bug, so the warning is the deliverable.
|
|
66
|
+
const warnings = [];
|
|
67
|
+
const run = fakeRun({ status: DIRTY, 'rev-parse': ON_MAIN });
|
|
68
|
+
const r = persistPin({ ...base, commitPin: false }, run, quiet, (m) => warnings.push(m));
|
|
69
|
+
|
|
70
|
+
assert.equal(r.warned, true);
|
|
71
|
+
assert.equal(r.committed, false);
|
|
72
|
+
assert.ok(!run.calls.some((c) => c.includes('git commit')), 'must NOT commit without the flag');
|
|
73
|
+
|
|
74
|
+
const text = warnings.join('\n');
|
|
75
|
+
assert.match(text, /PIN NOT COMMITTED/);
|
|
76
|
+
assert.match(text, /reset --hard/, 'names the mechanism that reverts it');
|
|
77
|
+
assert.match(text, /npm ci/, 'and that npm ci then reinstalls the old core');
|
|
78
|
+
assert.match(text, /1\.19\.56/, 'names the version it would fall back to');
|
|
79
|
+
assert.match(text, /--commit-pin/, 'and the fix');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('with --commit-pin it stages, commits, and pushes to the tracked branch', () => {
|
|
83
|
+
const run = fakeRun({ status: DIRTY, 'rev-parse': ON_MAIN });
|
|
84
|
+
const r = persistPin({ ...base, commitPin: true }, run, quiet, quiet);
|
|
85
|
+
|
|
86
|
+
assert.deepEqual(r, { applicable: true, committed: true, pushed: true, dirty: PIN_FILES });
|
|
87
|
+
assert.ok(run.calls.some((c) => c === `git add ${PIN_FILES.join(' ')}`), 'stages exactly the pin files');
|
|
88
|
+
assert.ok(run.calls.some((c) => /^git commit -m pin @bongos\/core 1\.19\.69 \(was 1\.19\.56\)$/.test(c)),
|
|
89
|
+
`commit message names both versions; got ${JSON.stringify(run.calls)}`);
|
|
90
|
+
assert.ok(run.calls.some((c) => c === 'git push origin HEAD:main'), 'pushes to the branch it is on');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('a detached HEAD still pushes somewhere sane', () => {
|
|
94
|
+
const run = fakeRun({ status: DIRTY, 'rev-parse': { status: 0, stdout: 'HEAD\n' } });
|
|
95
|
+
persistPin({ ...base, commitPin: true }, run, quiet, quiet);
|
|
96
|
+
assert.ok(run.calls.some((c) => c === 'git push origin HEAD:main'), 'falls back to main');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('--git-remote is honoured', () => {
|
|
100
|
+
const run = fakeRun({ status: DIRTY, 'rev-parse': ON_MAIN });
|
|
101
|
+
persistPin({ ...base, commitPin: true, remote: 'deploy' }, run, quiet, quiet);
|
|
102
|
+
assert.ok(run.calls.some((c) => c === 'git push deploy HEAD:main'));
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('a FAILED PUSH is loud — committed-but-unpushed is still doomed', () => {
|
|
106
|
+
// deploy.sh resets to the REMOTE, so a local commit alone saves nothing. This
|
|
107
|
+
// must never read as success.
|
|
108
|
+
const warnings = [];
|
|
109
|
+
const run = fakeRun({ status: DIRTY, 'rev-parse': ON_MAIN, push: { status: 1, stdout: '' } });
|
|
110
|
+
const r = persistPin({ ...base, commitPin: true }, run, quiet, (m) => warnings.push(m));
|
|
111
|
+
|
|
112
|
+
assert.equal(r.committed, true);
|
|
113
|
+
assert.equal(r.pushed, false);
|
|
114
|
+
assert.equal(r.error, 'git push failed');
|
|
115
|
+
assert.match(warnings.join('\n'), /still not durable/i);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('a failed commit does not claim to have pushed', () => {
|
|
119
|
+
const run = fakeRun({ status: DIRTY, 'rev-parse': ON_MAIN, commit: { status: 1, stdout: '' } });
|
|
120
|
+
const r = persistPin({ ...base, commitPin: true }, run, quiet, quiet);
|
|
121
|
+
assert.equal(r.committed, false);
|
|
122
|
+
assert.equal(r.pushed, false);
|
|
123
|
+
assert.ok(!run.calls.some((c) => c.startsWith('git push')), 'must not push an uncommitted pin');
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test('--dry-run describes the commit without making one', () => {
|
|
127
|
+
const run = fakeRun({ status: DIRTY, 'rev-parse': ON_MAIN });
|
|
128
|
+
const r = persistPin({ ...base, commitPin: true, dryRun: true }, run, quiet, quiet);
|
|
129
|
+
assert.equal(r.dryRun, true);
|
|
130
|
+
assert.equal(r.committed, false);
|
|
131
|
+
assert.ok(!run.calls.some((c) => c.includes('git commit') || c.includes('git push')));
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test('the copy-pasteable fix line is shell-quoted', () => {
|
|
135
|
+
// Nothing executes this string — it is printed for a human to paste — but a
|
|
136
|
+
// branch or remote carrying shell metacharacters must not misbehave when it is.
|
|
137
|
+
const warnings = [];
|
|
138
|
+
const run = fakeRun({ status: DIRTY, 'rev-parse': { status: 0, stdout: "main; rm -rf ~\n" } });
|
|
139
|
+
persistPin({ ...base, commitPin: false }, run, quiet, (m) => warnings.push(m));
|
|
140
|
+
const cmd = warnings.find((w) => w.includes('git -C'));
|
|
141
|
+
assert.ok(cmd, 'a paste-ready command is offered');
|
|
142
|
+
// The payload may appear — what matters is that it is INSIDE single quotes, so a
|
|
143
|
+
// paste runs one git command rather than two.
|
|
144
|
+
assert.match(cmd, /push '[^']*' 'HEAD:main; rm -rf ~'/, 'quoted as a single argument');
|
|
145
|
+
// Balanced quoting is the real property: an odd count would mean a value broke out.
|
|
146
|
+
assert.equal((cmd.match(/'/g) || []).length % 2, 0, 'every quote is closed');
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// ── preflightDbIdentity (task 1002712) ──────────────────────────────────────
|
|
150
|
+
// migrate.sh uses peer auth, so `sudo bongos upgrade` connects as root, which is
|
|
151
|
+
// almost never a Postgres role. That surfaced as a raw FATAL mid-run, AFTER the
|
|
152
|
+
// pin had moved, triggering a full auto-rollback for an operator mistake.
|
|
153
|
+
|
|
154
|
+
test('a missing Postgres role is caught BEFORE anything moves', () => {
|
|
155
|
+
const run = () => ({ status: 2, stderr: 'psql: error: FATAL: role "root" does not exist\n', stdout: '' });
|
|
156
|
+
const r = preflightDbIdentity({ instanceDir: '/srv/inst', db: 'cloudbongos' }, run);
|
|
157
|
+
assert.equal(r.ok, false);
|
|
158
|
+
assert.equal(r.role, 'root');
|
|
159
|
+
assert.match(r.reason, /current OS user/i, 'explains peer auth');
|
|
160
|
+
assert.match(r.reason, /sudo/, 'and names sudo as the likely cause');
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test('a reachable database passes quietly', () => {
|
|
164
|
+
const r = preflightDbIdentity({ instanceDir: '/srv/inst' }, () => ({ status: 0, stdout: '1\n' }));
|
|
165
|
+
assert.equal(r.ok, true);
|
|
166
|
+
assert.equal(r.skipped, undefined);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test('an unrelated psql failure is NOT turned into a refusal', () => {
|
|
170
|
+
// Only the identity problem is diagnosable here; everything else stays migrate's
|
|
171
|
+
// business, so a pre-check quirk can never block an otherwise fine upgrade.
|
|
172
|
+
const run = () => ({ status: 2, stderr: 'could not connect to server: No such file or directory\n', stdout: '' });
|
|
173
|
+
const r = preflightDbIdentity({ instanceDir: '/srv/inst' }, run);
|
|
174
|
+
assert.equal(r.ok, true);
|
|
175
|
+
assert.ok(r.skipped, 'defers rather than refusing');
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test('no psql on PATH defers instead of refusing', () => {
|
|
179
|
+
const r = preflightDbIdentity({ instanceDir: '/srv/inst' }, () => ({ error: new Error('ENOENT') }));
|
|
180
|
+
assert.equal(r.ok, true);
|
|
181
|
+
assert.ok(r.skipped);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test('dirtyPinFiles only ever reports the pin files', () => {
|
|
185
|
+
// It scopes `git status` to PIN_FILES, so unrelated instance edits (a config
|
|
186
|
+
// tweak, a doc) can never be swept into the pin commit.
|
|
187
|
+
const run = fakeRun({ status: DIRTY });
|
|
188
|
+
dirtyPinFiles('/srv/inst', run);
|
|
189
|
+
const statusCall = run.calls.find((c) => c.startsWith('git status'));
|
|
190
|
+
assert.match(statusCall, /--\s+package\.json package-lock\.json$/, 'pathspec-limited');
|
|
191
|
+
});
|