@bongos/core 1.21.115 → 1.21.117
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 +42 -27
- package/docs/adr/0350-the-hub-holds-a-per-project-write-deploy-key-so-a-hosted-upgrade-reaches-github.md +34 -0
- package/docs/copy-inventory.md +50 -50
- package/docs/copy-registry.json +56 -56
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-readings.json +1 -1
- package/modules/hall-ui/public/deploy.js +2 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +16 -0
- package/scripts/gds/fitness-lib.js +35 -0
- package/scripts/gds/fitness.js +2 -2
- package/scripts/gds/provision-core-upgrade.js +26 -7
- package/scripts/gds/provision-pin-replay.js +126 -0
- package/scripts/gds/upgrade-migrate.js +62 -2
- package/scripts/gds/upgrade-outcome.js +4 -0
- package/scripts/gds/upgrade.js +6 -6
- package/src/module-api.js +1 -1
- package/tests/core_upgrade_runner.mjs +15 -0
- package/tests/fitness_installed_core.mjs +127 -0
- package/tests/instance_db_migrated_on_code_move.mjs +2 -2
- package/tests/pin_replay.mjs +147 -0
- package/tests/pin_write_key.mjs +26 -0
- package/tests/upgrade_migrate.mjs +81 -0
|
@@ -36,6 +36,7 @@ const { spawnSync } = require('child_process');
|
|
|
36
36
|
const { CONFIG, MANIFEST_VERIFY_INTERVAL_MS, MANIFEST_VERIFY_TRIES, REPO_ROOT } = require('./provision-config.js');
|
|
37
37
|
const { appUser, dbName, instanceUser, parseTargetRef, resolveOwnerGithubToken, selfInstanceRoot, standaloneRoot } = require('./provision-repo.js');
|
|
38
38
|
const { ensurePinWriteKey, hasOwnAccount, pinKeyPath, pinSshCommand } = require('./provision-pin-key.js');
|
|
39
|
+
const { replayPinCommit } = require('./provision-pin-replay.js'); // GitHub ahead → pin on top (task 1004733)
|
|
39
40
|
const { isValidSlug } = require('../../modules/provisioning/provisioning.js');
|
|
40
41
|
const { RELEASE_NOTES_FILE, RELEASE_NOTES_MAX_BYTES, parseChangeRecords, notesBetween } = require('./release-notes.js');
|
|
41
42
|
const outcome = require('./upgrade-outcome.js'); // how far a failed move got, as a class (task 1004446)
|
|
@@ -411,9 +412,13 @@ async function coreUpgradeInstance(inst, deps, intent) {
|
|
|
411
412
|
// parseRepoNote in modules/provisioning/core-upgrade.js (re-stated there because a
|
|
412
413
|
// module may not require a script; tests/core_upgrade_runner.mjs round-trips the two).
|
|
413
414
|
//
|
|
414
|
-
// NOTHING IS FORCED
|
|
415
|
-
//
|
|
416
|
-
//
|
|
415
|
+
// NOTHING IS FORCED, AND THE LIVE BOX IS NEVER REBASED. When GitHub has commits the box
|
|
416
|
+
// does not, moving the box onto them would change what it runs with no restart and no
|
|
417
|
+
// health check. Since task 1004733 the pin commit ALONE is replayed onto GitHub's history in
|
|
418
|
+
// a scratch worktree and pushed as an ordinary fast-forward (provision-pin-replay.js), so
|
|
419
|
+
// GitHub names the new core and the box stays exactly what the move proved. A conflict in
|
|
420
|
+
// the pin files is reported ("conflict"), never resolved here. The box still does not pick
|
|
421
|
+
// up the owner's other commits — that would be a deploy, not a pin push (ADR 0350 amendment).
|
|
417
422
|
//
|
|
418
423
|
// WHAT MAKES IT DURABLE (task 1004291, ADR 0350). The owner's token is kept for an hour
|
|
419
424
|
// after they sign in with repository access (GITHUB_TOKEN_TTL_MS in src/bongos/db.js — one
|
|
@@ -431,6 +436,7 @@ const REPO_NOTES = Object.freeze({
|
|
|
431
436
|
needs_account: 'The hub could not set up lasting write access to the project\'s GitHub repository: this project needs its own account on the server first. Once it has one, reconnecting GitHub with repository access sets up lasting access.',
|
|
432
437
|
refused: 'GitHub turned down the owner\'s GitHub access — it may have expired, or lack permission to write to the repository.',
|
|
433
438
|
behind: 'GitHub has changes the server does not have, so the new version could not be added on top of them. An operator needs to bring the two back in line; the site itself is running normally.',
|
|
439
|
+
conflict: 'GitHub has its own changes to the same version files (package.json or package-lock.json), so the new version was not added on top of them and nothing there was overwritten. An operator needs to choose between the two; the site itself is running normally.',
|
|
434
440
|
failed: 'The hub could not save the new version to GitHub.',
|
|
435
441
|
});
|
|
436
442
|
// A branch name that is safe to interpolate. Narrower than git's own ref grammar on
|
|
@@ -498,6 +504,14 @@ async function pushUpgradePin(inst, deps, { instanceDir }) {
|
|
|
498
504
|
const at = `${repo.owner}/${repo.name}@${branch}`;
|
|
499
505
|
const hardened = `git -C ${instanceDir} -c core.hooksPath=/dev/null`;
|
|
500
506
|
const env = { GIT_TERMINAL_PROMPT: '0' }; // a refused credential fails; it never waits at a prompt
|
|
507
|
+
// GitHub is ahead: replay the pin commit alone on top, over the credential that was just
|
|
508
|
+
// refused as behind, and push again — never forced (task 1004733, provision-pin-replay.js).
|
|
509
|
+
const replayed = (via, git, remote) => {
|
|
510
|
+
const rr = replayPinCommit({ exec, instanceDir, branch, git, remote, env, slug: inst.slug, tmpDir: deps.tmpDir, pushFault, gitLine, log });
|
|
511
|
+
if (!rr.ok) return miss(rr.code, rr.extra);
|
|
512
|
+
if (rr.already) log(` ✓ ${at} already names this version — nothing to add`);
|
|
513
|
+
return { pushed: true, via, branch, ...(rr.replayed ? { replayed: true } : { already: true }) };
|
|
514
|
+
};
|
|
501
515
|
|
|
502
516
|
const token = await resolveOwnerGithubToken(inst, deps, { tag: '[core-upgrade]' });
|
|
503
517
|
|
|
@@ -515,8 +529,9 @@ async function pushUpgradePin(inst, deps, { instanceDir }) {
|
|
|
515
529
|
return { pushed: true, via: 'write-key', branch };
|
|
516
530
|
}
|
|
517
531
|
const fault = pushFault(r);
|
|
518
|
-
// The key WORKED and the history stopped it: no other credential changes that
|
|
519
|
-
|
|
532
|
+
// The key WORKED and the history stopped it: no other credential changes that, so the
|
|
533
|
+
// pin is replayed on top of GitHub's history over this same key (task 1004733).
|
|
534
|
+
if (fault === 'behind') return replayed('write-key', `${hardened} -c ${pinSshCommand(keyPath)}`, `git@github.com:${repo.owner}/${repo.name}.git`);
|
|
520
535
|
log(` • ${inst.slug}: the write key did not push (${fault}) — trying the next credential`);
|
|
521
536
|
}
|
|
522
537
|
|
|
@@ -525,8 +540,11 @@ async function pushUpgradePin(inst, deps, { instanceDir }) {
|
|
|
525
540
|
let r = null;
|
|
526
541
|
try {
|
|
527
542
|
writeFile(credFile, `https://x-access-token:${token}@github.com\n`, { mode: 0o600 });
|
|
528
|
-
|
|
529
|
-
|
|
543
|
+
const viaToken = `${hardened} -c credential.helper= -c credential.helper='store --file=${credFile}'`;
|
|
544
|
+
const tokenRemote = `https://x-access-token@github.com/${repo.owner}/${repo.name}.git`;
|
|
545
|
+
r = exec(`${viaToken} push ${tokenRemote} HEAD:${branch}`, { allowFail: true, env });
|
|
546
|
+
// Behind: replayed while the credential file still exists (the finally removes it).
|
|
547
|
+
if (failedRun(r) && pushFault(r) === 'behind') return replayed('owner-token', viaToken, tokenRemote);
|
|
530
548
|
} catch (e) {
|
|
531
549
|
return miss('failed', `(could not stage the owner's credential: ${String((e && e.message) || e).slice(0, 160)})`);
|
|
532
550
|
} finally {
|
|
@@ -547,6 +565,7 @@ async function pushUpgradePin(inst, deps, { instanceDir }) {
|
|
|
547
565
|
return { pushed: true, via: 'origin', branch };
|
|
548
566
|
}
|
|
549
567
|
const fault = pushFault(r);
|
|
568
|
+
if (fault === 'behind') return replayed('origin', hardened, 'origin');
|
|
550
569
|
// A refusal here is not the owner's access being turned down — there was none to turn
|
|
551
570
|
// down. It is the read-only key, which is the no-access case.
|
|
552
571
|
// ...unless the project has no account of its own, in which case no key CAN be minted
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// provision-pin-replay.js — when GitHub is AHEAD of a hosted project's box, put the core move's
|
|
3
|
+
// pin commit on top of GitHub's history instead of leaving GitHub on the old core (task 1004733,
|
|
4
|
+
// ADR 0350 amendment).
|
|
5
|
+
//
|
|
6
|
+
// WHY. pushUpgradePin pushes the pin commit the move made on the box. For a project the owner
|
|
7
|
+
// works from a laptop, GitHub almost always has commits the box does not, so that push is
|
|
8
|
+
// refused as non-fast-forward ("behind"). Before this file the move then finished `done` with a
|
|
9
|
+
// repo_not_updated note: the box ran the new core, GitHub still named the old one, and the next
|
|
10
|
+
// deploy from GitHub could quietly roll the core back.
|
|
11
|
+
//
|
|
12
|
+
// WHAT IT DOES. Fetch the branch over the SAME credential that was refused, replay ONLY the pin
|
|
13
|
+
// commit (package.json + package-lock.json, and any generated file upgrade.js committed with it)
|
|
14
|
+
// on top of what was fetched, and push that — an ordinary fast-forward, never forced.
|
|
15
|
+
//
|
|
16
|
+
// THE LIVE BOX IS NOT TOUCHED. The replay happens in a scratch worktree (mode 0700, outside the
|
|
17
|
+
// checkout, removed whatever happened). The box's HEAD, files and running site stay exactly what
|
|
18
|
+
// the move's health check proved, which is why the old "nothing is rebased" rule (changing what
|
|
19
|
+
// the box runs with no restart and no health check) still holds.
|
|
20
|
+
//
|
|
21
|
+
// A REAL CONFLICT STOPS. If the owner changed the same lines of those files, choosing between the
|
|
22
|
+
// two is a person's call: the cherry-pick is aborted, nothing is pushed, and the note names the
|
|
23
|
+
// files ("conflict"). Anything else that goes wrong reports, never retries in a loop.
|
|
24
|
+
//
|
|
25
|
+
// OUT OF SCOPE, ON PURPOSE: the box picking up the owner's OTHER commits. Nothing pulls them onto
|
|
26
|
+
// a hosted box after provisioning (standalonePullCmd runs at provision time only, provision.js);
|
|
27
|
+
// merging them into a live checkout here would deploy the owner's code with no install, migrate,
|
|
28
|
+
// restart or health check — a deploy, not a pin push. ADR 0350's amendment records that ruling.
|
|
29
|
+
//
|
|
30
|
+
// NEVER THROWS: the caller turns a throw into a broken instance, and this instance is running.
|
|
31
|
+
|
|
32
|
+
const crypto = require('node:crypto');
|
|
33
|
+
const os = require('node:os');
|
|
34
|
+
|
|
35
|
+
// The subject upgrade.js persistPin gives the pin commit. Anything else at HEAD is not ours to
|
|
36
|
+
// replay onto someone's history.
|
|
37
|
+
const PIN_SUBJECT_RE = /^pin @bongos\/core /;
|
|
38
|
+
const PIN_FILES = ['package.json', 'package-lock.json'];
|
|
39
|
+
const SHA_RE = /^[0-9a-f]{40,64}$/;
|
|
40
|
+
// A scratch path the shell may see unquoted (the tmp dir itself is the runner's, but checked).
|
|
41
|
+
const SAFE_PATH_RE = /^[A-Za-z0-9_./:~-]+$/;
|
|
42
|
+
|
|
43
|
+
const failedRun = (r) => !r || r.softFailed === true || r.ok === false;
|
|
44
|
+
const out = (r) => String((r && r.stdout) || '').trim();
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Replay the box's pin commit onto the branch GitHub has, and push it.
|
|
48
|
+
*
|
|
49
|
+
* @param {object} o
|
|
50
|
+
* exec the CONTROL PLANE executor (the one pushUpgradePin pushed with)
|
|
51
|
+
* instanceDir the project's checkout on the hub
|
|
52
|
+
* branch the branch pushUpgradePin pushed (already BRANCH_RE-checked)
|
|
53
|
+
* git the credentialed, hardened git prefix that was refused as behind
|
|
54
|
+
* remote the remote that same push named
|
|
55
|
+
* env that push's env (GIT_TERMINAL_PROMPT=0)
|
|
56
|
+
* slug the project's (validated) slug, for the scratch path's name
|
|
57
|
+
* tmpDir where the scratch worktree goes (default os.tmpdir())
|
|
58
|
+
* pushFault, gitLine pushUpgradePin's own readers of a git refusal
|
|
59
|
+
* @returns {{ ok:true, replayed?:true, already?:true } | { ok:false, code:string, extra:string }}
|
|
60
|
+
*/
|
|
61
|
+
function replayPinCommit(o) {
|
|
62
|
+
try { return replay(o); } catch (e) {
|
|
63
|
+
return { ok: false, code: 'failed', extra: `(the pin could not be replayed: ${String((e && e.message) || e).slice(0, 160)})` };
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function replay({ exec, instanceDir, branch, git, remote, env, slug, tmpDir, pushFault, gitLine, log = () => {} }) {
|
|
68
|
+
const plain = `git -C ${instanceDir} -c core.hooksPath=/dev/null`;
|
|
69
|
+
const run = (cmd, extraEnv) => exec(cmd, { allowFail: true, env: { ...env, ...(extraEnv || {}) } });
|
|
70
|
+
|
|
71
|
+
// 1. HEAD must be the move's own pin commit, with one parent, or there is nothing of ours to replay.
|
|
72
|
+
const head = run(`${plain} log -1 --format=%H%n%P%n%cn%n%ce%n%s HEAD`);
|
|
73
|
+
const [sha, parents, cn, ce, subject] = out(head).split('\n');
|
|
74
|
+
if (failedRun(head) || !SHA_RE.test(sha || '') || String(parents || '').split(' ').length !== 1 || !PIN_SUBJECT_RE.test(subject || '')) {
|
|
75
|
+
return { ok: false, code: 'behind', extra: '(the newest change on the server is not the version change, so it was not added on top)' };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// 2. What GitHub has now, over the same credential. Only FETCH_HEAD is written for it.
|
|
79
|
+
const fetched = run(`${git} fetch --no-tags ${remote} ${branch}`);
|
|
80
|
+
if (failedRun(fetched)) return { ok: false, code: 'behind', extra: gitLine(fetched) };
|
|
81
|
+
const upR = run(`${plain} rev-parse FETCH_HEAD`);
|
|
82
|
+
const up = out(upR);
|
|
83
|
+
if (failedRun(upR) || !SHA_RE.test(up)) return { ok: false, code: 'behind', extra: '(could not read what GitHub has)' };
|
|
84
|
+
|
|
85
|
+
// Already pinned the same way upstream (the owner bumped it by hand): nothing to add.
|
|
86
|
+
const same = run(`${plain} diff --quiet ${up} ${sha} -- ${PIN_FILES.join(' ')}`);
|
|
87
|
+
if (!failedRun(same)) return { ok: true, already: true };
|
|
88
|
+
|
|
89
|
+
// 3. Replay in a private scratch worktree, so the live checkout never moves.
|
|
90
|
+
const base = String(tmpDir || os.tmpdir()).replace(/\\/g, '/').replace(/\/+$/, '');
|
|
91
|
+
const wt = `${base}/bongos-pin-replay-${slug}-${process.pid}-${crypto.randomBytes(6).toString('hex')}`;
|
|
92
|
+
if (!SAFE_PATH_RE.test(wt)) return { ok: false, code: 'failed', extra: '(no safe scratch folder to replay the version change in)' };
|
|
93
|
+
try {
|
|
94
|
+
const made = run(`umask 077 && mkdir ${wt}`);
|
|
95
|
+
const added = failedRun(made) ? made : run(`${plain} worktree add --detach ${wt} ${up}`);
|
|
96
|
+
if (failedRun(added)) return { ok: false, code: 'failed', extra: gitLine(added) };
|
|
97
|
+
const at = `git -C ${wt} -c core.hooksPath=/dev/null`;
|
|
98
|
+
// The pin commit keeps its author and message; its committer stays who committed it on the
|
|
99
|
+
// box (env, not argv — a name is never shell-interpolated).
|
|
100
|
+
const who = cn && ce ? { GIT_COMMITTER_NAME: cn, GIT_COMMITTER_EMAIL: ce } : {};
|
|
101
|
+
const picked = run(`${at} cherry-pick ${sha}`, who);
|
|
102
|
+
if (failedRun(picked)) {
|
|
103
|
+
const both = out(run(`${at} diff --name-only --diff-filter=U`)).split('\n').filter(Boolean);
|
|
104
|
+
run(`${at} cherry-pick --abort`);
|
|
105
|
+
if (both.length) return { ok: false, code: 'conflict', extra: `(changed on both sides: ${both.join(', ').slice(0, 200)})` };
|
|
106
|
+
return { ok: false, code: 'failed', extra: gitLine(picked) };
|
|
107
|
+
}
|
|
108
|
+
const nextR = run(`${at} rev-parse HEAD`);
|
|
109
|
+
const next = out(nextR);
|
|
110
|
+
if (failedRun(nextR) || !SHA_RE.test(next)) return { ok: false, code: 'failed', extra: '(the replayed version change could not be read back)' };
|
|
111
|
+
|
|
112
|
+
// 4. A plain fast-forward of GitHub's branch to the replayed commit. A second refusal
|
|
113
|
+
// (someone pushed in between) is reported, not chased.
|
|
114
|
+
const pushed = run(`${git} push ${remote} ${next}:refs/heads/${branch}`);
|
|
115
|
+
if (failedRun(pushed)) return { ok: false, code: pushFault(pushed), extra: gitLine(pushed) };
|
|
116
|
+
log(` ✓ GitHub had newer commits: the pin was added on top of them (${next.slice(0, 12)})`);
|
|
117
|
+
return { ok: true, replayed: true };
|
|
118
|
+
} finally {
|
|
119
|
+
// Not --force: the abort above leaves it clean, and the rm + prune catch anything left.
|
|
120
|
+
run(`${plain} worktree remove ${wt}`);
|
|
121
|
+
run(`rm -rf ${wt}`);
|
|
122
|
+
run(`${plain} worktree prune`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
module.exports = { replayPinCommit };
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
|
|
28
28
|
const fs = require('node:fs');
|
|
29
29
|
const path = require('node:path');
|
|
30
|
-
const { spawnSync } = require('node:child_process');
|
|
30
|
+
const { spawn, spawnSync } = require('node:child_process');
|
|
31
31
|
|
|
32
32
|
// Relative to the instance root, POSIX on purpose: it is an argument to bash, and it
|
|
33
33
|
// must stay byte-identical to the scaffold's script (tests/upgrade_migrate.mjs pins it
|
|
@@ -161,4 +161,64 @@ function preflightDbIdentity({ instanceDir, db, account = null }, run = spawnSyn
|
|
|
161
161
|
};
|
|
162
162
|
}
|
|
163
163
|
|
|
164
|
-
|
|
164
|
+
// ── what a FAILED migrate says, so nobody needs SSH to read it (task 1004734) ────────────
|
|
165
|
+
//
|
|
166
|
+
// Seen 2026-10-14: toast's move 1.21.20 → 1.21.115 rolled back and the hub kept exactly
|
|
167
|
+
// "migrate failed (exit 3)". Exit 3 is psql under ON_ERROR_STOP: ONE statement failed on
|
|
168
|
+
// that project's real data. Which file, and which error, reached only the box's journal,
|
|
169
|
+
// because runMigrate spawned with stdio 'inherit'. migrate.sh prints `apply <version>`
|
|
170
|
+
// before each file, and psql -f prefixes its ERROR with the file too, so the output
|
|
171
|
+
// already names both; it just never came back.
|
|
172
|
+
//
|
|
173
|
+
// teeSpawn: the child's stdout/stderr are piped and WRITTEN THROUGH as they arrive — the
|
|
174
|
+
// journal still reads live, exactly as under 'inherit' — while the last TEE_TAIL chars are
|
|
175
|
+
// kept for migrateFailureDetail. Async, because a sync spawn cannot write through.
|
|
176
|
+
const TEE_TAIL = 64 * 1024;
|
|
177
|
+
function teeSpawn(cmd, args, opts = {}, { spawnImpl = spawn, out = process.stdout, err = process.stderr } = {}) {
|
|
178
|
+
return new Promise((resolve) => {
|
|
179
|
+
let output = '';
|
|
180
|
+
const keep = (c) => { output = (output + c).slice(-TEE_TAIL); };
|
|
181
|
+
let child;
|
|
182
|
+
try { child = spawnImpl(cmd, args, { ...opts, stdio: ['inherit', 'pipe', 'pipe'] }); } catch (e) { resolve({ error: e }); return; }
|
|
183
|
+
for (const [pipe, sink] of [[child.stdout, out], [child.stderr, err]]) {
|
|
184
|
+
pipe.setEncoding('utf8');
|
|
185
|
+
pipe.on('data', (c) => { sink.write(c); keep(c); });
|
|
186
|
+
}
|
|
187
|
+
child.on('error', (e) => resolve({ error: e }));
|
|
188
|
+
child.on('close', (status) => resolve({ status, output }));
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// The failing migration and psql's ERROR line, as " at <file>: ERROR: …" — or '' when the
|
|
193
|
+
// output names neither. Secret-scrubbed (the line is stored on the hub and shown to the
|
|
194
|
+
// owner) and capped. The LAST `apply` line is the file it stopped in: migrate.sh stops at
|
|
195
|
+
// the first failure. PURE.
|
|
196
|
+
const DETAIL_MAX = 300;
|
|
197
|
+
function migrateFailureDetail(text) {
|
|
198
|
+
const lines = String(text || '').split(/\r?\n/);
|
|
199
|
+
const last = (re) => { for (let i = lines.length - 1; i >= 0; i--) { const m = re.exec(lines[i]); if (m) return m; } return null; };
|
|
200
|
+
const err = last(/\b((?:ERROR|FATAL):\s+.*)$/);
|
|
201
|
+
const file = (last(/^apply\s+(\S+)\s*$/) || (err && /^psql:(?:.*[\\/])?([^\\/:]+?)(?:\.sql)?:\d+:/.exec(err.input)) || [])[1] || '';
|
|
202
|
+
let msg = err ? err[1].trim() : '';
|
|
203
|
+
if (msg) {
|
|
204
|
+
// No scrubber, no error text: a credential in a psql line must never reach the hub.
|
|
205
|
+
try { msg = require('../../modules/security/secret-scrub.js').scrubSecrets(msg); } catch { msg = ''; }
|
|
206
|
+
}
|
|
207
|
+
const cap = (s, n) => (s.length > n ? `${s.slice(0, n - 1)}…` : s);
|
|
208
|
+
return (file ? ` at ${cap(file, 80)}` : '') + (msg ? `: ${cap(msg, DETAIL_MAX)}` : '');
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** runMigrate's spawn result → { ok, code, detail } | { ok:false, reason }. PURE. */
|
|
212
|
+
function migrateResult(r) {
|
|
213
|
+
if (!r || r.error) return { ok: false, reason: r && r.error ? r.error.message : 'spawn failed' };
|
|
214
|
+
if (r.status === 0) return { ok: true, code: 0 };
|
|
215
|
+
// r.output from teeSpawn; stdout/stderr from a spawnSync-shaped run (tests, any injected run)
|
|
216
|
+
return { ok: false, code: r.status, detail: migrateFailureDetail([r.output, r.stdout, r.stderr].filter(Boolean).join('\n')) };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** The sentence runUpgrade fails with; its "migrate failed" prefix is what upgrade-outcome.js keys on. PURE. */
|
|
220
|
+
function migrateError(mig) {
|
|
221
|
+
return `migrate failed (${mig.reason || `exit ${mig.code}`})${mig.detail || ''}`;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
module.exports = { migratePlan, preflightDbIdentity, asAccount, ACCOUNT_RE, CORE_MIGRATE_SCRIPT, teeSpawn, migrateFailureDetail, migrateResult, migrateError };
|
|
@@ -63,6 +63,9 @@ const REASON_CLASS = Object.freeze({
|
|
|
63
63
|
npm_install: 'transient',
|
|
64
64
|
integrity_pin: 'unknown',
|
|
65
65
|
migrate: 'unknown',
|
|
66
|
+
// a migration's CHECK constraint is broken by rows the project already has (task 1004734): a
|
|
67
|
+
// retry fails identically and no remedy may rewrite the owner's data, so a person decides.
|
|
68
|
+
migrate_check_violation: 'unknown',
|
|
66
69
|
restart: 'unknown',
|
|
67
70
|
health: 'unknown',
|
|
68
71
|
// the runner's one automatic fix did not clear it, or itself failed (task 1004448)
|
|
@@ -86,6 +89,7 @@ const UPGRADE_REASONS = [
|
|
|
86
89
|
[/^(--registry and --from|--from tarball not found|no core tarball at)/, 'core_source'],
|
|
87
90
|
[/^npm install failed/, 'npm_install'],
|
|
88
91
|
[/^integrity pin/, 'integrity_pin'],
|
|
92
|
+
[/^migrate failed .*\bERROR: .*check constraint/, 'migrate_check_violation'], // upgrade-migrate.js migrateError (task 1004734)
|
|
89
93
|
[/^migrate failed/, 'migrate'],
|
|
90
94
|
[/^restart failed/, 'restart'],
|
|
91
95
|
[/^(health check failed after restart|served core version did not change)/, 'health'],
|
package/scripts/gds/upgrade.js
CHANGED
|
@@ -33,7 +33,7 @@ const { parseSemver, compareSemver, isStable, listAvailableVersions } = require(
|
|
|
33
33
|
const { checkArtistGate } = require('./upgrade-artist-gate');
|
|
34
34
|
// The database side of a move: which command migrates, as which account, and whether that
|
|
35
35
|
// account can reach the database (tasks 1004274, 1002712, 1004512).
|
|
36
|
-
const { preflightDbIdentity, asAccount, ACCOUNT_RE } = require('./upgrade-migrate');
|
|
36
|
+
const { preflightDbIdentity, asAccount, ACCOUNT_RE, teeSpawn, migrateResult, migrateError } = require('./upgrade-migrate');
|
|
37
37
|
|
|
38
38
|
const CORE_PKG = '@bongos/core';
|
|
39
39
|
const ARTIFACT = 'bongos-core';
|
|
@@ -519,13 +519,13 @@ function npmInstall(instanceDir, run = spawnSync) {
|
|
|
519
519
|
return !r || r.error ? { ok: false, reason: r && r.error ? r.error.message : 'spawn failed' } : { ok: r.status === 0, code: r.status };
|
|
520
520
|
}
|
|
521
521
|
|
|
522
|
-
function runMigrate(instanceDir, run = spawnSync, account = null) {
|
|
522
|
+
async function runMigrate(instanceDir, run = spawnSync, account = null) {
|
|
523
523
|
const { cmd, args, env } = require('./upgrade-migrate').migratePlan(instanceDir); // its own migrate script, else the core's migrate.sh (task 1004274)
|
|
524
524
|
// `account`: the project's own unix+Postgres identity when the move itself is running as
|
|
525
525
|
// the account that owns the CHECKOUT instead (--migrate-as; upgrade-migrate.js, task 1004512).
|
|
526
526
|
const q = asAccount(account, cmd, args, env ? { INIT_CWD: instanceDir } : {});
|
|
527
|
-
|
|
528
|
-
return
|
|
527
|
+
// TEED, not inherited: still streams live, and a failure names its migration + psql's ERROR line (upgrade-migrate.js, task 1004734)
|
|
528
|
+
return migrateResult(await (run === spawnSync ? teeSpawn : run)(q.cmd, q.args, { cwd: instanceDir, ...(env ? { env } : {}) }));
|
|
529
529
|
}
|
|
530
530
|
|
|
531
531
|
// WHICH database will migrate target? (task 1002755)
|
|
@@ -1224,9 +1224,9 @@ async function runUpgrade(opts, deps = {}) {
|
|
|
1224
1224
|
|
|
1225
1225
|
// 4. migrate (additive core_* migrations).
|
|
1226
1226
|
if (!opts.skipMigrate) {
|
|
1227
|
-
const mig = runMigrate(instanceDir, run, opts.migrateAs || null);
|
|
1227
|
+
const mig = await runMigrate(instanceDir, run, opts.migrateAs || null);
|
|
1228
1228
|
if (!mig.ok) {
|
|
1229
|
-
const error =
|
|
1229
|
+
const error = migrateError(mig); // names the failing migration + its ERROR line (task 1004734)
|
|
1230
1230
|
if (rollbackOnFailure && snapshot) {
|
|
1231
1231
|
const rb = await doRollback('migrate failed');
|
|
1232
1232
|
return { ok: false, error, fromVersion, toVersion: targetVersion, rolledBack: true, rollback: rb };
|
package/src/module-api.js
CHANGED
|
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
75
75
|
// MAJOR (see allowBoxScope below): passes the request through untouched.
|
|
76
76
|
function deprecatedNoopMiddleware(_req, _res, next) { next(); }
|
|
77
77
|
|
|
78
|
-
const CORE_VERSION = '1.21.
|
|
78
|
+
const CORE_VERSION = '1.21.117'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
79
79
|
|
|
80
80
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
81
81
|
// Usage: const log = api.logger('discord'); log.info('mounted');
|
|
@@ -1551,6 +1551,21 @@ await t('a real rollback says rolled back', async () => {
|
|
|
1551
1551
|
assert.match(r.error, /failed and was rolled back/);
|
|
1552
1552
|
});
|
|
1553
1553
|
|
|
1554
|
+
await t('a failed migrate reaches the stored failure with its migration and psql\'s ERROR line (task 1004734)', async () => {
|
|
1555
|
+
// upgrade.js's REAL migrate result → its real error sentence → its real outcome line, in the
|
|
1556
|
+
// frame its main() writes to stderr (pinned by "upgrade.js ends a failed run…" below).
|
|
1557
|
+
const run = () => ({ status: 3, stdout: 'apply core_998_ok\napply core_999_x\n',
|
|
1558
|
+
stderr: 'psql:migrations/core_999_x.sql:7: ERROR: check constraint "tasks_kind_check" of relation "tasks" is violated by some row\n' });
|
|
1559
|
+
const mig = await U.runMigrate(fs.mkdtempSync(path.join(require('node:os').tmpdir(), 'mig-')), run);
|
|
1560
|
+
const res = { ok: false, error: require('../scripts/gds/upgrade-migrate.js').migrateError(mig), rolledBack: true, rollback: {} };
|
|
1561
|
+
const stderr = `\n✖ ${res.error}\n${require('../scripts/gds/upgrade-outcome.js').outcomeLine(res)}`;
|
|
1562
|
+
const r = await R.coreUpgradeInstance(inst(), upgradeThrows(stderr)(makeDeps()), intent());
|
|
1563
|
+
assert.deepEqual(r.failure, { class: 'unknown', reason: 'migrate_check_violation' });
|
|
1564
|
+
assert.match(r.error, /failed and was rolled back/);
|
|
1565
|
+
assert.match(r.error, /core_999_x/, 'the stored sentence names the migration');
|
|
1566
|
+
assert.match(r.error, /ERROR: +check constraint "tasks_kind_check" of relation "tasks" is violated by some row/, 'and psql\'s own error');
|
|
1567
|
+
});
|
|
1568
|
+
|
|
1554
1569
|
await t('a FAILED rollback is its own outcome, and terminal — never retried unattended', async () => {
|
|
1555
1570
|
const d = upgradeThrows('\n✖ health check failed after restart\nBONGOS_UPGRADE_OUTCOME {"stage":"rollback_failed","reason":"rollback_failed"}\n')(makeDeps());
|
|
1556
1571
|
const r = await R.coreUpgradeInstance(inst(), d, intent());
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// tests/fitness_installed_core.mjs — fitness.js in a project that INSTALLS the core
|
|
2
|
+
// (task 1004737).
|
|
3
|
+
//
|
|
4
|
+
// There the core sits at <project>/node_modules/@bongos/core: not a git checkout of its
|
|
5
|
+
// own, just an ignored folder inside the project's repo. Every fitness check guards the
|
|
6
|
+
// core's own source and enumerates it with git, so each scan found nothing and either
|
|
7
|
+
// threw ("docs-entropy: scan defect — enumerated 0 tracked markdown files") or reported
|
|
8
|
+
// a scan defect — `bongos exec scripts/gds/fitness.js` crashed on toast, on 1.21.20 and
|
|
9
|
+
// again on 1.21.115. The core repo's CI already ran this gate before publishing, so in an
|
|
10
|
+
// installed core it steps aside, and says so.
|
|
11
|
+
import assert from 'node:assert/strict';
|
|
12
|
+
import { test } from 'node:test';
|
|
13
|
+
import { createRequire } from 'node:module';
|
|
14
|
+
import { execFileSync } from 'node:child_process';
|
|
15
|
+
import fs from 'node:fs';
|
|
16
|
+
import os from 'node:os';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
import { fileURLToPath } from 'node:url';
|
|
19
|
+
|
|
20
|
+
const require = createRequire(import.meta.url);
|
|
21
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
22
|
+
const lib = require(path.join(ROOT, 'scripts', 'gds', 'fitness-lib.js'));
|
|
23
|
+
|
|
24
|
+
const git = (cwd, ...args) => execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
25
|
+
|
|
26
|
+
function tempDir(t) {
|
|
27
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'fitness-installed-'));
|
|
28
|
+
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
|
|
29
|
+
return dir;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
test('isInstalledCore: yes only for a core under node_modules that is not its own checkout', (t) => {
|
|
33
|
+
const base = tempDir(t);
|
|
34
|
+
const repo = path.join(base, 'project');
|
|
35
|
+
const nested = path.join(repo, 'node_modules', '@bongos', 'core');
|
|
36
|
+
const vendored = path.join(repo, 'vendor', 'core');
|
|
37
|
+
const looseInstalled = path.join(base, 'bare', 'node_modules', '@bongos', 'core');
|
|
38
|
+
const loose = path.join(base, 'loose');
|
|
39
|
+
for (const d of [nested, vendored, looseInstalled, loose]) fs.mkdirSync(d, { recursive: true });
|
|
40
|
+
git(repo, 'init', '-q');
|
|
41
|
+
assert.equal(lib.isInstalledCore(nested), true, 'an installed package inside a project repo is installed');
|
|
42
|
+
assert.equal(lib.isInstalledCore(looseInstalled), true, 'so is one under node_modules with no repo around it');
|
|
43
|
+
assert.equal(lib.isInstalledCore(ROOT), false, 'this checkout (main or a worktree) is not — the gate keeps running here');
|
|
44
|
+
assert.equal(lib.isInstalledCore(repo), false, 'nor is the root of any checkout');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// The grader's finding on the first cut: deciding "installed" from a git FAILURE let any git
|
|
48
|
+
// trouble in the core's own checkout (git off PATH, no .git, a misresolved root) skip the whole
|
|
49
|
+
// gate — rank guards and the core/host boundary included — and go green. Outside node_modules
|
|
50
|
+
// the answer is now no regardless of git, so those trees run every check and fail closed.
|
|
51
|
+
test('isInstalledCore: a git failure or a nested folder OUTSIDE node_modules never reads as installed (fail closed)', (t) => {
|
|
52
|
+
const base = tempDir(t);
|
|
53
|
+
const repo = path.join(base, 'project');
|
|
54
|
+
const vendored = path.join(repo, 'vendor', 'core');
|
|
55
|
+
const loose = path.join(base, 'loose');
|
|
56
|
+
fs.mkdirSync(vendored, { recursive: true });
|
|
57
|
+
fs.mkdirSync(loose);
|
|
58
|
+
git(repo, 'init', '-q');
|
|
59
|
+
assert.equal(lib.isInstalledCore(loose), false, 'no repo at all (git fails) outside node_modules: run the checks');
|
|
60
|
+
assert.equal(lib.isInstalledCore(vendored), false, 'a folder inside another repo but not under node_modules: run the checks');
|
|
61
|
+
assert.equal(lib.isInstalledCore(path.join(base, 'does-not-exist')), false, 'a misresolved root: run the checks');
|
|
62
|
+
const savedPath = process.env.PATH;
|
|
63
|
+
process.env.PATH = '';
|
|
64
|
+
try {
|
|
65
|
+
assert.equal(lib.isInstalledCore(ROOT), false, 'git missing from PATH in the core checkout: run the checks');
|
|
66
|
+
} finally {
|
|
67
|
+
process.env.PATH = savedPath;
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('runChecks: in the core\'s own checkout every check runs, and a throw still crashes (fail closed)', () => {
|
|
72
|
+
const ran = [];
|
|
73
|
+
const out = lib.runChecks([() => { ran.push(1); return { hardFail: false }; }, () => { ran.push(2); return { hardFail: true }; }], { installed: false });
|
|
74
|
+
assert.deepEqual(ran, [1, 2]);
|
|
75
|
+
assert.equal(out.length, 2);
|
|
76
|
+
assert.throws(() => lib.runChecks([() => { throw new Error('scan defect'); }], { installed: false }), /scan defect/);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('runChecks: in an installed core no check runs, and one non-failing result says why', () => {
|
|
80
|
+
let ran = 0;
|
|
81
|
+
const out = lib.runChecks([() => { ran++; throw new Error('scan defect'); }, () => { ran++; return { hardFail: true }; }], { installed: true });
|
|
82
|
+
assert.equal(ran, 0, 'no check is run against a tree with no core source in it');
|
|
83
|
+
assert.equal(out.length, 1);
|
|
84
|
+
assert.equal(out[0].hardFail, false);
|
|
85
|
+
assert.match(out[0].name, /skipped/);
|
|
86
|
+
assert.match(out[0].name, /installed package/);
|
|
87
|
+
assert.ok(out[0].warnings.length > 0, 'the skip surfaces as a warning, not a silent pass');
|
|
88
|
+
assert.match(out[0].note, /2 checks/, 'and counts what it did not run');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('end to end: fitness.js run from <project>/node_modules/@bongos/core exits 0 with the skip, instead of crashing', (t) => {
|
|
92
|
+
const project = tempDir(t);
|
|
93
|
+
const core = path.join(project, 'node_modules', '@bongos', 'core');
|
|
94
|
+
// The working tree as the package would ship it (tracked + new files), copied — not
|
|
95
|
+
// linked — so git inside it answers for the PROJECT, exactly as on toast.
|
|
96
|
+
const files = git(ROOT, 'ls-files', '-z', '--cached', '--others', '--exclude-standard').split('\0').filter(Boolean);
|
|
97
|
+
for (const f of files) {
|
|
98
|
+
const src = path.join(ROOT, f);
|
|
99
|
+
let st;
|
|
100
|
+
try { st = fs.lstatSync(src); } catch { continue; } // deleted in the working tree
|
|
101
|
+
if (!st.isFile()) continue;
|
|
102
|
+
fs.mkdirSync(path.dirname(path.join(core, f)), { recursive: true });
|
|
103
|
+
fs.copyFileSync(src, path.join(core, f));
|
|
104
|
+
}
|
|
105
|
+
fs.writeFileSync(path.join(project, '.gitignore'), 'node_modules\n');
|
|
106
|
+
fs.writeFileSync(path.join(project, 'CLAUDE.md'), '# project\n');
|
|
107
|
+
git(project, 'init', '-q');
|
|
108
|
+
git(project, 'add', '-A');
|
|
109
|
+
git(project, '-c', 'user.email=t@example.com', '-c', 'user.name=t', 'commit', '-qm', 'init');
|
|
110
|
+
|
|
111
|
+
let out = '';
|
|
112
|
+
let status = 0;
|
|
113
|
+
try {
|
|
114
|
+
out = execFileSync(process.execPath, [path.join(core, 'scripts', 'gds', 'fitness.js')], {
|
|
115
|
+
cwd: project,
|
|
116
|
+
encoding: 'utf8',
|
|
117
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
118
|
+
env: { ...process.env, NODE_PATH: path.join(ROOT, 'node_modules') },
|
|
119
|
+
});
|
|
120
|
+
} catch (e) {
|
|
121
|
+
status = e.status;
|
|
122
|
+
out = `${e.stdout || ''}${e.stderr || ''}`;
|
|
123
|
+
}
|
|
124
|
+
assert.equal(status, 0, `fitness must not fail an installed core's project:\n${out.slice(0, 2000)}`);
|
|
125
|
+
assert.match(out, /skipped: the core here is an installed package/);
|
|
126
|
+
assert.doesNotMatch(out, /scan defect/);
|
|
127
|
+
});
|
|
@@ -32,7 +32,7 @@ const tenant = { slug: 'charter', db_name: 'charter_db', hosting_shape: 'co-tena
|
|
|
32
32
|
|
|
33
33
|
test('bongos upgrade migrates before it restarts, and only --skip-migrate opts out', () => {
|
|
34
34
|
const src = fs.readFileSync(path.join(ROOT, 'scripts/gds/upgrade.js'), 'utf8');
|
|
35
|
-
const migrate = src.indexOf('const mig = runMigrate(instanceDir, run, opts.migrateAs || null);');
|
|
35
|
+
const migrate = src.indexOf('const mig = await runMigrate(instanceDir, run, opts.migrateAs || null);'); // async since task 1004734 (teed output)
|
|
36
36
|
const restart = src.lastIndexOf('restartService({ service: opts.service, restartCmd: opts.restartCmd }');
|
|
37
37
|
assert.ok(migrate > 0, 'the upgrade flow runs migrate');
|
|
38
38
|
assert.ok(restart > migrate, 'the restart that makes new code live comes AFTER migrate');
|
|
@@ -47,7 +47,7 @@ test('bongos upgrade migrates before it restarts, and only --skip-migrate opts o
|
|
|
47
47
|
// provisioned since task 1003369, and the wrong one cannot connect at all: the project's
|
|
48
48
|
// database is owned by its own Postgres login role with CONNECT revoked from PUBLIC.
|
|
49
49
|
// Asserting the CALL SITE, not just the helper — the helper was never the gap.
|
|
50
|
-
assert.match(src, /const mig = runMigrate\(instanceDir, run, opts\.migrateAs \|\| null\);/,
|
|
50
|
+
assert.match(src, /const mig = await runMigrate\(instanceDir, run, opts\.migrateAs \|\| null\);/,
|
|
51
51
|
'the migrate step is handed --migrate-as, not left on whoever the move happens to be');
|
|
52
52
|
assert.equal(`bash ${CORE_MIGRATE_SCRIPT}`, INSTANCE_MIGRATE_SCRIPT, 'migrate is the instance scaffold script, i.e. the core migrate.sh');
|
|
53
53
|
});
|