@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.
@@ -0,0 +1,147 @@
1
+ // tests/pin_replay.mjs — a core move whose pin push is refused as "behind" replays the pin
2
+ // commit onto GitHub's newer history instead of leaving GitHub on the old core (task 1004733).
3
+ //
4
+ // The normal case for a project worked from a laptop: the owner has pushed commits the box
5
+ // does not have, so the box's pin commit cannot fast-forward GitHub. Before this task the move
6
+ // finished `done` with a `repo_not_updated:behind` note and GitHub kept naming the old core, so
7
+ // the next deploy from GitHub could quietly roll the core back. What these pin, against REAL
8
+ // git repositories (a bare "GitHub", the box's checkout, the owner's laptop clone):
9
+ // * origin ahead → only the pin commit is replayed on top of it and pushed; the owner's
10
+ // commits stay, nothing is forced, and the live box's checkout is not touched
11
+ // * the owner changed the lockfile too → reported in plain words, GitHub left as it was
12
+ // * origin already up to date → the ordinary push, no replay at all
13
+ //
14
+ // Run: node tests/pin_replay.mjs
15
+
16
+ import { strict as assert } from 'node:assert';
17
+ import { spawnSync } from 'node:child_process';
18
+ import { mkdtempSync, writeFileSync, readFileSync, rmSync } from 'node:fs';
19
+ import { tmpdir } from 'node:os';
20
+ import { join } from 'node:path';
21
+ import { pushUpgradePin } from '../scripts/gds/provision-core-upgrade.js';
22
+
23
+ let passed = 0, failed = 0;
24
+ const t = async (name, fn) => {
25
+ try { await fn(); passed += 1; console.log(` PASS ${name}`); }
26
+ catch (e) { failed += 1; console.log(` FAIL ${name}\n ${e.message}`); }
27
+ };
28
+
29
+ // Forward slashes: the commands run through bash, which would eat a Windows backslash.
30
+ const fwd = (p) => p.replace(/\\/g, '/');
31
+ const ID = '-c user.name=tester -c user.email=tester@example.invalid';
32
+
33
+ // The control plane's executor, for real: a shell command, the allowFail result shape
34
+ // provision-config.js softFailResult returns (output kept, softFailed set).
35
+ function realExec(ran) {
36
+ return (cmd, opts = {}) => {
37
+ ran.push(cmd);
38
+ const r = spawnSync('bash', ['-c', cmd], { encoding: 'utf8', env: { ...process.env, ...(opts.env || {}) } });
39
+ if (r.status === 0) return { ok: true, stdout: r.stdout || '', stderr: r.stderr || '' };
40
+ if (!opts.allowFail) throw new Error(`command failed: ${cmd}\n${r.stderr}`);
41
+ return { ok: true, stdout: r.stdout || '', stderr: r.stderr || '', softFailed: true };
42
+ };
43
+ }
44
+ const sh = (cmd) => {
45
+ const r = spawnSync('bash', ['-c', cmd], { encoding: 'utf8' });
46
+ if (r.status !== 0) throw new Error(`setup failed: ${cmd}\n${r.stderr}`);
47
+ return String(r.stdout || '').trim();
48
+ };
49
+
50
+ const pkg = (core, extra = {}) => `${JSON.stringify({ name: 'site', dependencies: { '@bongos/core': core, ...extra } }, null, 2)}\n`;
51
+ const lock = (core, extra = '') => `${JSON.stringify({ name: 'site', lockfileVersion: 3, packages: { 'node_modules/@bongos/core': { version: core } }, note: extra }, null, 2)}\n`;
52
+
53
+ // A bare "GitHub", the box's checkout (holding an unpushed pin commit, as upgrade.js
54
+ // --commit-pin --pin-no-push leaves it), and the owner's laptop clone.
55
+ function world({ ownerChanges = null } = {}) {
56
+ const root = fwd(mkdtempSync(join(tmpdir(), 'pin-replay-')));
57
+ const origin = `${root}/origin.git`, box = `${root}/box`, laptop = `${root}/laptop`, scratch = `${root}/scratch`;
58
+ sh(`git init -q --bare ${origin} && git -C ${origin} symbolic-ref HEAD refs/heads/main`);
59
+ sh(`git init -q ${laptop} && git -C ${laptop} checkout -q -b main`);
60
+ writeFileSync(`${laptop}/package.json`, pkg('1.0.0'));
61
+ writeFileSync(`${laptop}/package-lock.json`, lock('1.0.0'));
62
+ writeFileSync(`${laptop}/index.html`, '<p>v1</p>\n');
63
+ sh(`git -C ${laptop} add -A && git -C ${laptop} ${ID} commit -q -m init && git -C ${laptop} remote add origin ${origin} && git -C ${laptop} push -q origin main`);
64
+ sh(`git clone -q ${origin} ${box}`);
65
+ // The move's pin commit, on the box.
66
+ writeFileSync(`${box}/package.json`, pkg('1.1.0'));
67
+ writeFileSync(`${box}/package-lock.json`, lock('1.1.0'));
68
+ sh(`git -C ${box} add package.json package-lock.json && git -C ${box} ${ID} commit -q -m "pin @bongos/core 1.1.0 (was 1.0.0)"`);
69
+ if (ownerChanges) {
70
+ ownerChanges(laptop);
71
+ sh(`git -C ${laptop} add -A && git -C ${laptop} ${ID} commit -q -m "owner work" && git -C ${laptop} push -q origin main`);
72
+ }
73
+ return { root, origin, box, laptop, scratch, done: () => { try { rmSync(root, { recursive: true, force: true }); } catch { /* best effort */ } } };
74
+ }
75
+
76
+ const inst = { id: 9, slug: 'acme-site', hosting_shape: 'cloud-host', status: 'active', owner_builder_id: 5, target_ref: 'acme/site' };
77
+ function run(w) {
78
+ const ran = [], logs = [];
79
+ const deps = {
80
+ controlExec: realExec(ran), log: (l) => logs.push(String(l)),
81
+ getGithubToken: async () => null, // no owner token: the box's own remote pushes
82
+ fsImpl: { existsSync: () => false }, // and no write key on this hub
83
+ pinKeyDir: `${w.root}/keys`, tmpDir: w.scratch,
84
+ };
85
+ sh(`mkdir -p ${w.scratch}`);
86
+ return pushUpgradePin(inst, deps, { instanceDir: w.box }).then((out) => ({ out, ran, logs }));
87
+ }
88
+ const at = (dir, ref) => sh(`git -C ${dir} rev-parse ${ref}`);
89
+ const show = (dir, ref, file) => sh(`git -C ${dir} show ${ref}:${file}`);
90
+
91
+ console.log('origin AHEAD of the box: the pin commit is replayed on top and pushed');
92
+
93
+ await t('the owner\'s commits stay, the pin lands on top of them, and nothing is forced', async () => {
94
+ const w = world({ ownerChanges: (d) => writeFileSync(`${d}/index.html`, '<p>v2 from the laptop</p>\n') });
95
+ try {
96
+ const ownerTip = at(w.origin, 'main');
97
+ const boxHead = at(w.box, 'HEAD');
98
+ const { out, ran } = await run(w);
99
+ assert.equal(out.pushed, true, `pushed — got ${JSON.stringify(out)}`);
100
+ assert.equal(out.replayed, true, 'by replaying the pin, not by the first push');
101
+ assert.equal(out.via, 'origin', 'over the same credential that was refused as behind');
102
+ const tip = at(w.origin, 'main');
103
+ assert.equal(at(w.origin, 'main^'), ownerTip, 'the replayed pin sits directly on the owner\'s newest commit');
104
+ assert.match(show(w.origin, 'main', 'package.json'), /"@bongos\/core": "1\.1\.0"/, 'GitHub now names the new core');
105
+ assert.match(show(w.origin, 'main', 'package-lock.json'), /"version": "1\.1\.0"/);
106
+ assert.match(show(w.origin, 'main', 'index.html'), /v2 from the laptop/, 'the owner\'s work is still there');
107
+ assert.equal(sh(`git -C ${w.origin} log -1 --format=%s main`), 'pin @bongos/core 1.1.0 (was 1.0.0)', 'the pin commit\'s own message');
108
+ assert.notEqual(tip, boxHead, 'a new commit, not the box\'s');
109
+ assert.equal(at(w.box, 'HEAD'), boxHead, 'the live box\'s checkout was NOT moved');
110
+ assert.match(readFileSync(`${w.box}/index.html`, 'utf8'), /v1/, 'nor its files changed under the running site');
111
+ assert.equal(sh(`git -C ${w.box} worktree list`).split('\n').length, 1, 'the scratch copy was cleaned up');
112
+ assert.ok(!ran.some((c) => /--force|\s\+\S|reset --hard|rebase/.test(c)), `never forced: ${ran.join('\n')}`);
113
+ } finally { w.done(); }
114
+ });
115
+
116
+ await t('a conflicting LOCKFILE is reported in plain words, and GitHub is left exactly as it was', async () => {
117
+ const w = world({ ownerChanges: (d) => writeFileSync(`${d}/package-lock.json`, lock('1.0.1', 'the owner reinstalled')) });
118
+ try {
119
+ const ownerTip = at(w.origin, 'main');
120
+ const boxHead = at(w.box, 'HEAD');
121
+ const { out, ran } = await run(w);
122
+ assert.equal(out.pushed, false);
123
+ assert.equal(out.code, 'conflict');
124
+ assert.match(out.note, /^repo_not_updated:conflict: /, 'the note the web tier reads');
125
+ assert.match(out.note, /package-lock\.json/, 'it names the file');
126
+ assert.equal(at(w.origin, 'main'), ownerTip, 'nothing was pushed over the owner\'s commit');
127
+ assert.equal(at(w.box, 'HEAD'), boxHead, 'the box is untouched');
128
+ assert.equal(sh(`git -C ${w.box} status --porcelain`), '', 'and its tree is clean');
129
+ assert.equal(sh(`git -C ${w.box} worktree list`).split('\n').length, 1, 'the scratch copy was cleaned up');
130
+ assert.ok(!ran.some((c) => /--force|\s\+\S|reset --hard/.test(c)), 'never forced');
131
+ } finally { w.done(); }
132
+ });
133
+
134
+ await t('origin UP TO DATE: the ordinary push, no fetch and no replay', async () => {
135
+ const w = world();
136
+ try {
137
+ const boxHead = at(w.box, 'HEAD');
138
+ const { out, ran } = await run(w);
139
+ assert.equal(out.pushed, true);
140
+ assert.ok(!out.replayed, 'nothing to replay');
141
+ assert.equal(at(w.origin, 'main'), boxHead, 'GitHub fast-forwarded to the box\'s own pin commit');
142
+ assert.ok(!ran.some((c) => / fetch | worktree | cherry-pick /.test(c)), `no replay machinery ran: ${ran.join('\n')}`);
143
+ } finally { w.done(); }
144
+ });
145
+
146
+ console.log(`\n${passed} passed, ${failed} failed`);
147
+ if (failed) process.exit(1);
@@ -262,6 +262,32 @@ await t('GitHub AHEAD of the box (rejected non-fast-forward) on the write key: r
262
262
  assert.ok(!call(r, /x-access-token@/) && !call(r, / push origin /));
263
263
  });
264
264
 
265
+ await t('GitHub AHEAD on the write key: the pin is replayed and pushed over THAT key, never forced (task 1004733)', async () => {
266
+ const SHA = 'a'.repeat(40), UP = 'b'.repeat(40), NEXT = 'c'.repeat(40);
267
+ const r = pushRig();
268
+ let keyPushes = 0;
269
+ const inner = r.deps.exec;
270
+ r.deps.exec = (cmd, opts) => {
271
+ if (/core\.sshCommand=.* push /.test(cmd) && keyPushes++ === 0) { r.ran.push({ cmd, env: opts && opts.env }); return refused(' ! [rejected] HEAD -> main (fetch first)'); }
272
+ if (/log -1 --format=/.test(cmd)) { r.ran.push({ cmd }); return { ok: true, stdout: `${SHA}\n${'d'.repeat(40)}\nbox\nbox@x.invalid\npin @bongos/core 1.2.0 (was 1.1.0)\n` }; }
273
+ if (/rev-parse FETCH_HEAD/.test(cmd)) { r.ran.push({ cmd }); return { ok: true, stdout: `${UP}\n` }; }
274
+ if (/diff --quiet /.test(cmd)) { r.ran.push({ cmd }); return refused(''); }
275
+ if (/rev-parse HEAD/.test(cmd)) { r.ran.push({ cmd }); return { ok: true, stdout: `${NEXT}\n` }; }
276
+ return inner(cmd, opts);
277
+ };
278
+ const out = await push(r);
279
+ assert.equal(out.pushed, true);
280
+ assert.equal(out.via, 'write-key');
281
+ assert.equal(out.replayed, true);
282
+ const fetch = call(r, / fetch /);
283
+ assert.ok(fetch && fetch.cmd.includes('core.sshCommand=') && fetch.cmd.includes('git@github.com:acme/site.git main'), `fetched over the key: ${fetch && fetch.cmd}`);
284
+ const again = r.ran.filter((c) => /core\.sshCommand=.* push /.test(c.cmd)).pop();
285
+ assert.ok(again.cmd.includes(`push git@github.com:acme/site.git ${NEXT}:refs/heads/main`), `the replayed commit, over the key: ${again.cmd}`);
286
+ assert.ok(call(r, new RegExp(`cherry-pick ${SHA}`)), 'only the pin commit is replayed');
287
+ assert.ok(!r.ran.some((c) => /--force|\s\+\S|reset --hard|rebase/.test(c.cmd)), 'never forced');
288
+ assert.ok(!call(r, /x-access-token@/) && !call(r, / push origin /), 'no other credential');
289
+ });
290
+
265
291
  await t('NO KEY yet but a FRESH TOKEN: the key is minted first (reconnecting is enough), then used', async () => {
266
292
  const r = pushRig({ hasKey: false });
267
293
  const out = await push(r);
@@ -23,6 +23,9 @@ import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, realpathSync, syml
23
23
  import { tmpdir } from 'node:os';
24
24
  import path from 'node:path';
25
25
  import { fileURLToPath } from 'node:url';
26
+ import { EventEmitter } from 'node:events';
27
+ // ESM named imports: knip credits a CJS export only when a test imports it this way.
28
+ import { migrateError, migrateFailureDetail, teeSpawn } from '../scripts/gds/upgrade-migrate.js';
26
29
 
27
30
  const require = createRequire(import.meta.url);
28
31
  const { migratePlan, CORE_MIGRATE_SCRIPT } = require('../scripts/gds/upgrade-migrate.js');
@@ -193,3 +196,81 @@ test('the real fallback command resolves the project as its instance root, despi
193
196
  const blind = rootsOf(execFileSync('bash', [CORE_MIGRATE_SCRIPT, '--print-roots'], { cwd: dir, env: base, encoding: 'utf8' }));
194
197
  assert.notEqual(blind.INSTANCE_ROOT, dir, 'without INIT_CWD pinned, a stray value decides which project is migrated');
195
198
  });
199
+
200
+ // ── a failed migrate names its migration and psql's ERROR line (task 1004734) ──────────
201
+ // Seen 2026-10-14: toast's move 1.21.20 → 1.21.115 rolled back and the hub kept only
202
+ // "migrate failed (exit 3)". Which file and which error were in the box's journal alone.
203
+ const FAKE_DSN = ['postgres://app', 'Sup3rS3cretPassw0rd@db.internal/prod'].join(':');
204
+ const MIGRATE_OUT = 'skip core_001_base (already applied)\napply core_998_ok\napply core_999_x\n';
205
+ const PSQL_ERR = `psql:node_modules/@bongos/core/migrations/core_999_x.sql:7: ERROR: check constraint "tasks_kind_check" of relation "tasks" is violated by some row ${FAKE_DSN}\n`;
206
+
207
+ function failingMigrateRun(calls, opts = {}) {
208
+ const base = recordingRun(calls, opts);
209
+ return (cmd, args = [], o = {}) => {
210
+ if ((cmd === 'bash' && args[0] === CORE_MIGRATE_SCRIPT) || (cmd === 'npm' && args[0] === 'run' && args[1] === 'migrate')) {
211
+ calls.push({ line: `${cmd} ${args.join(' ')}`.trim(), opts: o });
212
+ return { status: 3, stdout: MIGRATE_OUT, stderr: PSQL_ERR };
213
+ }
214
+ return base(cmd, args, o);
215
+ };
216
+ }
217
+
218
+ test('runMigrate: a failing migration comes back with its file name and psql\'s ERROR line', async () => {
219
+ const mig = await u.runMigrate(adoptedProject(), failingMigrateRun([]));
220
+ assert.equal(mig.ok, false);
221
+ assert.equal(mig.code, 3);
222
+ const error = migrateError(mig);
223
+ assert.match(error, /^migrate failed \(exit 3\)/, 'the prefix upgrade-outcome.js keys on is unchanged');
224
+ assert.match(error, /core_999_x/, 'names the migration that was being applied when it stopped');
225
+ assert.doesNotMatch(error, /core_998_ok/, 'and not an earlier one that applied cleanly');
226
+ assert.match(error, /ERROR: +check constraint "tasks_kind_check" of relation "tasks" is violated by some row/);
227
+ assert.doesNotMatch(error, /Sup3rS3cretPassw0rd/, 'a credential in the error never reaches the hub');
228
+ });
229
+
230
+ test('migrateFailureDetail scrubs secrets and caps the length', () => {
231
+ const d = migrateFailureDetail(`apply core_999_x\nERROR: bad value ${FAKE_DSN} ${'x'.repeat(5000)}\n`);
232
+ assert.doesNotMatch(d, /Sup3rS3cretPassw0rd/);
233
+ assert.ok(d.length <= 400, `capped (${d.length})`);
234
+ assert.match(d, /core_999_x/);
235
+ assert.equal(migrateFailureDetail('npm error code 1\n'), '', 'nothing recognisable adds nothing');
236
+ assert.match(migrateFailureDetail('psql:/x/migrations/core_5_y.sql:3: ERROR: boom\n'), /core_5_y.*ERROR: +boom/, 'psql\'s own file prefix stands in for a missing apply line');
237
+ });
238
+
239
+ test('a full upgrade whose migrate fails reports the migration, the error, and a CHECK-violation class', async () => {
240
+ const dir = adoptedProject({ installed: '1.19.312' });
241
+ const res = await u.runUpgrade(
242
+ { to: '1.19.1029', registry: true, instance: dir, service: 'mercury', healthCheck: false },
243
+ {
244
+ log: () => {}, err: () => {},
245
+ run: failingMigrateRun([], { dir, installTo: '1.19.1029' }),
246
+ materialize: () => ({ skills: 0 }), regenerateApiArtifacts: () => ({ ok: true, ran: [] }),
247
+ databaseUrl: '', pgDatabase: '',
248
+ },
249
+ );
250
+ assert.equal(res.ok, false);
251
+ assert.match(res.error, /^migrate failed \(exit 3\) at core_999_x: ERROR: +check constraint "tasks_kind_check"/);
252
+ const outcome = require('../scripts/gds/upgrade-outcome.js');
253
+ assert.equal(outcome.upgradeReason(res), 'migrate_check_violation');
254
+ assert.equal(outcome.classifyFailure('migrate_check_violation').class, 'unknown', 'existing rows break the new rule: a person decides, never an unattended retry');
255
+ });
256
+
257
+ test('teeSpawn streams the child\'s output live AND keeps it for the failure report', async () => {
258
+ const pipe = () => Object.assign(new EventEmitter(), { setEncoding() {} });
259
+ let child;
260
+ const spawnImpl = (cmd, args, opts) => {
261
+ child = Object.assign(new EventEmitter(), { stdout: pipe(), stderr: pipe(), opts });
262
+ return child;
263
+ };
264
+ const out = [], err = [];
265
+ const p = teeSpawn('bash', ['m.sh'], { cwd: '/x' }, { spawnImpl, out: { write: (c) => out.push(c) }, err: { write: (c) => err.push(c) } });
266
+ child.stdout.emit('data', 'apply core_999_x\n');
267
+ assert.deepEqual(out, ['apply core_999_x\n'], 'written through as it arrives, not after the child exits');
268
+ child.stderr.emit('data', 'ERROR: boom\n');
269
+ assert.deepEqual(err, ['ERROR: boom\n']);
270
+ child.emit('close', 3);
271
+ const r = await p;
272
+ assert.equal(r.status, 3);
273
+ assert.match(r.output, /apply {2}core_999_x[\s\S]*ERROR: {2}boom/);
274
+ assert.deepEqual(child.opts.stdio, ['inherit', 'pipe', 'pipe']);
275
+ assert.equal(child.opts.cwd, '/x');
276
+ });