@bongos/core 1.21.60 → 1.21.62
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 +59 -39
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +4 -0
- package/clients/bongos-client/index.cjs +4 -0
- package/clients/bongos-client/index.d.ts +5 -0
- package/clients/bongos-client/index.mjs +4 -0
- package/docs/adr/0366-hall-planning-is-forms-and-a-goal-made-there-carries-its-own-spec-task.md +85 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +58 -3
- package/docs/api-reference.md +7 -1
- package/docs/copy-inventory.md +10 -3
- package/docs/copy-registry.json +67 -4
- package/docs/module-api-changelog.md +4 -0
- package/docs/onboarding/diagrams/assertions.json +1 -1
- package/docs/page-readings.json +222 -215
- package/modules/hall-ui/public/project-settings.css +5 -0
- package/modules/hall-ui/public/project-settings.html +12 -0
- package/modules/hall-ui/public/project-settings.js +20 -0
- package/modules/hall-ui/records/project-danger-zone.md +10 -0
- package/modules/lifecycle/merge-lock.js +53 -9
- package/modules/lifecycle/ship-preflight.js +40 -22
- package/modules/public-landing/public/projects.html +30 -3
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +16 -0
- package/scripts/gds/ship-grade.js +13 -6
- package/scripts/gds/ship-merge-gate.js +26 -0
- package/scripts/gds/ship-merge.js +12 -3
- package/scripts/gds/ship-state.js +42 -5
- package/src/bongos/routes/core-update.js +33 -0
- package/src/module-api.js +1 -1
- package/tests/merge_lock.mjs +58 -0
- package/tests/project_manage_link.mjs +111 -0
- package/tests/ship_merge_gate.mjs +63 -0
- package/tests/ship_preflight.mjs +46 -1
- package/tests/ship_vendored_instance.mjs +259 -0
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.62'; // 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');
|
package/tests/merge_lock.mjs
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import assert from 'node:assert/strict';
|
|
11
11
|
import { createRequire } from 'node:module';
|
|
12
|
+
import { execSync } from 'node:child_process';
|
|
12
13
|
import fs from 'node:fs';
|
|
13
14
|
import os from 'node:os';
|
|
14
15
|
import path from 'node:path';
|
|
@@ -292,6 +293,63 @@ test('recoverWorktree: clean reclaim → no churn (no abort, no reset)', () => {
|
|
|
292
293
|
}
|
|
293
294
|
});
|
|
294
295
|
|
|
296
|
+
// ---- linked worktrees (task 1004584) ----
|
|
297
|
+
// When `main` is checked out in a LINKED worktree, <root>/.git is a FILE, so the
|
|
298
|
+
// old <root>/.git/otb-automerge.lock was ENOENT on every acquire. The lock now
|
|
299
|
+
// lives in the common git dir — the same file from every worktree.
|
|
300
|
+
|
|
301
|
+
function realRepoWithLinkedWorktree() {
|
|
302
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'otb-mergelock-linked-'));
|
|
303
|
+
const run = (cmd) => execSync(cmd, { cwd: dir, stdio: 'ignore' });
|
|
304
|
+
run('git init -q -b trunk');
|
|
305
|
+
run('git config user.email t@t.t');
|
|
306
|
+
run('git config user.name t');
|
|
307
|
+
fs.writeFileSync(path.join(dir, 'a.txt'), 'x\n');
|
|
308
|
+
run('git add a.txt');
|
|
309
|
+
run('git commit -q -m init');
|
|
310
|
+
const linked = path.join(dir, 'wt-main');
|
|
311
|
+
run(`git worktree add -q -b main "${linked}"`);
|
|
312
|
+
return { dir, linked };
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
test('lockPath: from a linked worktree, resolves to the COMMON git dir (same file as the primary)', () => {
|
|
316
|
+
const { dir, linked } = realRepoWithLinkedWorktree();
|
|
317
|
+
try {
|
|
318
|
+
assert.ok(fs.statSync(path.join(linked, '.git')).isFile(), 'precondition: .git is a gitdir pointer file');
|
|
319
|
+
assert.equal(
|
|
320
|
+
fs.realpathSync.native(path.dirname(lock.lockPath(linked))),
|
|
321
|
+
fs.realpathSync.native(path.join(dir, '.git')),
|
|
322
|
+
);
|
|
323
|
+
assert.equal(path.basename(lock.lockPath(linked)), 'otb-automerge.lock');
|
|
324
|
+
} finally {
|
|
325
|
+
cleanup(dir);
|
|
326
|
+
}
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
test('acquire/release: work from a linked main worktree, and the primary sees the hold', () => {
|
|
330
|
+
const { dir, linked } = realRepoWithLinkedWorktree();
|
|
331
|
+
try {
|
|
332
|
+
assert.equal(lock.acquire(linked, { waitMs: 0 }), true);
|
|
333
|
+
assert.equal(lock.readLock(dir).pid, process.pid, 'the primary must see the hold the linked worktree took');
|
|
334
|
+
assert.equal(lock.acquire(dir, { waitMs: 0, sleep: () => {} }), false, 'a second holder from the primary must be refused');
|
|
335
|
+
lock.release(linked);
|
|
336
|
+
assert.equal(lock.readLock(dir), null);
|
|
337
|
+
} finally {
|
|
338
|
+
cleanup(dir);
|
|
339
|
+
}
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
test('indexLockPath: a linked worktree keeps its index.lock in its OWN git dir', () => {
|
|
343
|
+
const { dir, linked } = realRepoWithLinkedWorktree();
|
|
344
|
+
try {
|
|
345
|
+
const p = lock.indexLockPath(linked);
|
|
346
|
+
assert.equal(path.basename(p), 'index.lock');
|
|
347
|
+
assert.match(p.replace(/\\/g, '/'), /\.git\/worktrees\/wt-main\/index\.lock$/);
|
|
348
|
+
} finally {
|
|
349
|
+
cleanup(dir);
|
|
350
|
+
}
|
|
351
|
+
});
|
|
352
|
+
|
|
295
353
|
// ---- runner ----
|
|
296
354
|
|
|
297
355
|
let pass = 0;
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// tests/project_manage_link.mjs — a hosted project's owner reaches Take-offline and
|
|
2
|
+
// Delete from their own hall (task 1004570). These EXECUTE: the link builder against
|
|
3
|
+
// real inputs, and real HTTP requests through the assembled router with only
|
|
4
|
+
// requireBuilder replaced, so the Archon gate that refuses a non-owner is the real one.
|
|
5
|
+
//
|
|
6
|
+
// Run: node --test tests/project_manage_link.mjs
|
|
7
|
+
|
|
8
|
+
import assert from 'node:assert/strict';
|
|
9
|
+
import { test } from 'node:test';
|
|
10
|
+
import fs from 'node:fs';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import { createRequire } from 'node:module';
|
|
13
|
+
import { fileURLToPath } from 'node:url';
|
|
14
|
+
|
|
15
|
+
process.env.NODE_ENV = 'test';
|
|
16
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
17
|
+
const require_ = createRequire(import.meta.url);
|
|
18
|
+
const read = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8');
|
|
19
|
+
const { hubManageLinks } = require_(path.join(ROOT, 'src/bongos/routes/core-update.js'));
|
|
20
|
+
|
|
21
|
+
const HUB = { origin: 'https://hub.example.com/', clientId: 'mercury' };
|
|
22
|
+
|
|
23
|
+
test('the link opens THIS project on the hub, at the two cards', () => {
|
|
24
|
+
assert.deepEqual(hubManageLinks(HUB), {
|
|
25
|
+
hub_host: 'hub.example.com',
|
|
26
|
+
manage_url: 'https://hub.example.com/projects?view=manage&project=mercury',
|
|
27
|
+
offline_url: 'https://hub.example.com/projects?view=manage&project=mercury#mDelete',
|
|
28
|
+
disconnect_url: 'https://hub.example.com/projects?view=manage&project=mercury#mDisconnect',
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('no hub (self-hosted, or the hub itself) means no link, never a wrong one', () => {
|
|
33
|
+
assert.equal(hubManageLinks(null), null);
|
|
34
|
+
assert.equal(hubManageLinks({ origin: 'https://hub.example.com' }), null, 'no client id');
|
|
35
|
+
assert.equal(hubManageLinks({ origin: 'javascript:alert(1)', clientId: 'x' }), null, 'not an http origin');
|
|
36
|
+
assert.equal(hubManageLinks({ origin: 'https://hub.example.com/"><script>', clientId: 'x' }), null);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// One server per test: requireBuilder is swapped for a stub that seats the rank the
|
|
40
|
+
// request names, and the route module is re-required so it captures the stub.
|
|
41
|
+
async function withServer(idp, fn) {
|
|
42
|
+
const r2 = createRequire(import.meta.url);
|
|
43
|
+
const authPath = r2.resolve(path.join(ROOT, 'src/bongos/auth.js'));
|
|
44
|
+
const routePath = r2.resolve(path.join(ROOT, 'src/bongos/routes/core-update.js'));
|
|
45
|
+
const realAuth = r2(authPath);
|
|
46
|
+
const saved = realAuth.requireBuilder;
|
|
47
|
+
realAuth.requireBuilder = (req, _res, next) => { req.builder = { id: 7, rank: req.get('x-rank') }; next(); };
|
|
48
|
+
delete r2.cache[routePath];
|
|
49
|
+
const express = r2('express');
|
|
50
|
+
const { attachFail } = r2(path.join(ROOT, 'src/bongos/middleware/error-envelope.js'));
|
|
51
|
+
const app = express();
|
|
52
|
+
app.use(attachFail);
|
|
53
|
+
app.use(r2(routePath)({ doorInputs: () => ({ provisioning: false, idp }) }));
|
|
54
|
+
const server = app.listen(0);
|
|
55
|
+
await new Promise((res) => server.once('listening', res));
|
|
56
|
+
const get = (rank) => fetch(`http://127.0.0.1:${server.address().port}/project/manage-link`, { headers: { 'x-rank': rank } });
|
|
57
|
+
try { await fn(get); } finally {
|
|
58
|
+
server.close();
|
|
59
|
+
realAuth.requireBuilder = saved;
|
|
60
|
+
delete r2.cache[routePath];
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
test('C1: the owner (the hosted project\'s Archon) gets the link', async () => {
|
|
65
|
+
await withServer(HUB, async (get) => {
|
|
66
|
+
const res = await get('archon');
|
|
67
|
+
assert.equal(res.status, 200);
|
|
68
|
+
assert.equal((await res.json()).link.offline_url, 'https://hub.example.com/projects?view=manage&project=mercury#mDelete');
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('C2: a builder of the same project below Archon is refused by the server', async () => {
|
|
73
|
+
await withServer(HUB, async (get) => {
|
|
74
|
+
for (const rank of ['metic', 'thetes', 'xenos']) {
|
|
75
|
+
const res = await get(rank);
|
|
76
|
+
assert.equal(res.status, 403, `${rank} is refused`);
|
|
77
|
+
assert.equal(JSON.stringify(await res.json()).includes('hub.example.com'), false, `${rank} learns nothing`);
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('C3: a self-hosted instance answers no link', async () => {
|
|
83
|
+
await withServer(null, async (get) => {
|
|
84
|
+
const res = await get('archon');
|
|
85
|
+
assert.equal(res.status, 200);
|
|
86
|
+
assert.equal((await res.json()).link, null);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('the hall section is hidden in the markup and shown only for a real link', () => {
|
|
91
|
+
const html = read('modules/hall-ui/public/project-settings.html');
|
|
92
|
+
assert.match(html, /<section class="scroll" id="ps-danger"[^>]*\bhidden>/);
|
|
93
|
+
const js = read('modules/hall-ui/public/project-settings.js');
|
|
94
|
+
assert.match(js, /if \(!link \|\| !link\.offline_url \|\| !link\.disconnect_url\) return;/);
|
|
95
|
+
assert.match(js, /\/project\/manage-link/);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('the hub resolves the slug against the visitor\'s OWN projects only', () => {
|
|
99
|
+
const page = read('modules/public-landing/public/projects.html');
|
|
100
|
+
assert.match(page, /ownedInstances\.filter\(function \(i\) \{ return i && i\.slug === slug; \}\)\[0\]/);
|
|
101
|
+
assert.match(page, /\.then\(openHandedProject\)/);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('the hall\'s two sentences say what the hub\'s own folds promise', () => {
|
|
105
|
+
const hall = read('modules/hall-ui/public/project-settings.html');
|
|
106
|
+
const hub = read('modules/public-landing/public/projects.html');
|
|
107
|
+
for (const claim of ['repository on GitHub', 'cannot be undone', 'bring it back', 'Take it offline', 'Disconnect from Bongos entirely']) {
|
|
108
|
+
assert.ok(hall.includes(claim), `the hall says "${claim}"`);
|
|
109
|
+
assert.ok(hub.includes(claim), `the hub says "${claim}" too`);
|
|
110
|
+
}
|
|
111
|
+
});
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
import assert from 'node:assert/strict';
|
|
18
18
|
import { test } from 'node:test';
|
|
19
19
|
import { createRequire } from 'node:module';
|
|
20
|
+
import { execSync } from 'node:child_process';
|
|
20
21
|
import fs from 'node:fs';
|
|
21
22
|
import os from 'node:os';
|
|
22
23
|
import path from 'node:path';
|
|
@@ -170,3 +171,65 @@ test('an unmeasurable base blocks rather than waving the ship through', () => {
|
|
|
170
171
|
// it could not measure, and null blocks.
|
|
171
172
|
assert.equal(gate.mergeGateVerdict({ mergeClean: false, baseClean: null }).block, true);
|
|
172
173
|
});
|
|
174
|
+
|
|
175
|
+
// ---- gateApplies (task 1004584) ----
|
|
176
|
+
// A VENDORED instance (core under node_modules/@bongos/core) carries no
|
|
177
|
+
// scripts/gds/fitness.js of its own, so both sides read 'unmeasurable' and every
|
|
178
|
+
// vendored land was blocked. The gate stands down ONLY when the base lacked the
|
|
179
|
+
// file too — a branch that deletes fitness.js from the core is still measured.
|
|
180
|
+
function gitRepo(withFitness) {
|
|
181
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'merge-gate-applies-'));
|
|
182
|
+
const run = (cmd) => execSync(cmd, { cwd: dir, stdio: 'ignore' });
|
|
183
|
+
run('git init -q -b main');
|
|
184
|
+
run('git config user.email t@t.t');
|
|
185
|
+
run('git config user.name t');
|
|
186
|
+
fs.writeFileSync(path.join(dir, 'README.md'), 'x\n');
|
|
187
|
+
if (withFitness) {
|
|
188
|
+
fs.mkdirSync(path.join(dir, 'scripts', 'gds'), { recursive: true });
|
|
189
|
+
fs.writeFileSync(path.join(dir, 'scripts', 'gds', 'fitness.js'), 'process.exit(0);\n');
|
|
190
|
+
}
|
|
191
|
+
run('git add -A');
|
|
192
|
+
run('git commit -q -m base');
|
|
193
|
+
const baseSha = execSync('git rev-parse HEAD', { cwd: dir, encoding: 'utf8' }).trim();
|
|
194
|
+
return { dir, baseSha };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
test('gateApplies: a repo that carries fitness.js is always gated', () => {
|
|
198
|
+
const { dir, baseSha } = gitRepo(true);
|
|
199
|
+
try {
|
|
200
|
+
assert.equal(gate.gateApplies(dir, baseSha), true);
|
|
201
|
+
} finally {
|
|
202
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test('gateApplies: a vendored instance (no fitness.js on either side) stands the gate down', () => {
|
|
207
|
+
const { dir, baseSha } = gitRepo(false);
|
|
208
|
+
try {
|
|
209
|
+
assert.equal(gate.gateApplies(dir, baseSha), false);
|
|
210
|
+
const verdict = gate.gateMergeResult({ repoRoot: dir, baseSha, log: () => {} });
|
|
211
|
+
assert.deepEqual(verdict, { block: false, reason: 'not-applicable' });
|
|
212
|
+
} finally {
|
|
213
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test('gateApplies: a merge that DELETES fitness.js from a gated base is still measured (and blocked)', () => {
|
|
218
|
+
const { dir, baseSha } = gitRepo(true);
|
|
219
|
+
try {
|
|
220
|
+
fs.rmSync(path.join(dir, 'scripts', 'gds', 'fitness.js'));
|
|
221
|
+
execSync('git commit -q -am "drop the gate"', { cwd: dir, stdio: 'ignore' });
|
|
222
|
+
assert.equal(gate.gateApplies(dir, baseSha), true, 'the base had the gate, so its removal must not wave the merge through');
|
|
223
|
+
} finally {
|
|
224
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test('gateApplies: with no base sha it cannot prove the gate was never there → gated', () => {
|
|
229
|
+
const { dir } = gitRepo(false);
|
|
230
|
+
try {
|
|
231
|
+
assert.equal(gate.gateApplies(dir, null), true);
|
|
232
|
+
} finally {
|
|
233
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
234
|
+
}
|
|
235
|
+
});
|
package/tests/ship_preflight.mjs
CHANGED
|
@@ -13,7 +13,7 @@ import path from 'node:path';
|
|
|
13
13
|
|
|
14
14
|
const require = createRequire(import.meta.url);
|
|
15
15
|
const {
|
|
16
|
-
parsePorcelain, trackedDirty, classifyEmptyShip, cleanupMacFinderDupRefs, findMainWorktree, closeoutPlan,
|
|
16
|
+
parsePorcelain, trackedDirty, classifyEmptyShip, cleanupMacFinderDupRefs, findMainWorktree, findPrimaryWorktree, closeoutPlan,
|
|
17
17
|
untrackedPaths, untrackedLeftBehind, untrackedWarningLines,
|
|
18
18
|
} = require('../modules/lifecycle/ship-preflight.js');
|
|
19
19
|
// task 1001436: the preflight step itself + the ship-input resolver it is handed.
|
|
@@ -318,6 +318,51 @@ test('findMainWorktree: returns null outside any git repo', () => {
|
|
|
318
318
|
}
|
|
319
319
|
});
|
|
320
320
|
|
|
321
|
+
// ---- findPrimaryWorktree (task 1004584) ----
|
|
322
|
+
// The merge target when NO worktree has main: the primary checkout, whatever it
|
|
323
|
+
// is on — so ship can say "check out main there" instead of falling back to the
|
|
324
|
+
// core package dir (a vendored instance's node_modules/@bongos/core).
|
|
325
|
+
|
|
326
|
+
test('findPrimaryWorktree: from a linked worktree, returns the primary checkout even when it is off main', () => {
|
|
327
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'otb-primarywt-'));
|
|
328
|
+
try {
|
|
329
|
+
execSync('git init -q -b trunk', { cwd: tmp });
|
|
330
|
+
execSync('git config user.email t@t.t && git config user.name t', { cwd: tmp });
|
|
331
|
+
fs.writeFileSync(path.join(tmp, 'a.txt'), 'x');
|
|
332
|
+
execSync('git add a.txt && git commit -q -m init', { cwd: tmp });
|
|
333
|
+
execSync('git branch feature', { cwd: tmp });
|
|
334
|
+
const wt = path.join(tmp, 'linked');
|
|
335
|
+
execSync(`git worktree add -q "${wt}" feature`, { cwd: tmp });
|
|
336
|
+
|
|
337
|
+
assert.equal(findMainWorktree(wt), null, 'precondition: no worktree is on main');
|
|
338
|
+
const found = findPrimaryWorktree(wt);
|
|
339
|
+
assert.ok(found, 'expected a path');
|
|
340
|
+
assert.equal(fs.realpathSync.native(found), fs.realpathSync.native(tmp));
|
|
341
|
+
} finally {
|
|
342
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
343
|
+
}
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
test('findPrimaryWorktree: a BARE primary is not a checkout to merge in → null', () => {
|
|
347
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'otb-primarywt-bare-'));
|
|
348
|
+
try {
|
|
349
|
+
const bare = path.join(tmp, 'repo.git');
|
|
350
|
+
execSync(`git init -q --bare -b main "${bare}"`, { cwd: tmp });
|
|
351
|
+
assert.equal(findPrimaryWorktree(bare), null);
|
|
352
|
+
} finally {
|
|
353
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
354
|
+
}
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
test('findPrimaryWorktree: returns null outside any git repo', () => {
|
|
358
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'otb-primarywt-nogit-'));
|
|
359
|
+
try {
|
|
360
|
+
assert.equal(findPrimaryWorktree(tmp), null);
|
|
361
|
+
} finally {
|
|
362
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
363
|
+
}
|
|
364
|
+
});
|
|
365
|
+
|
|
321
366
|
// ---- closeoutPlan (task 932) ----
|
|
322
367
|
|
|
323
368
|
test('closeoutPlan: clean tree → clean:true, no leftover', () => {
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
// tests/ship_vendored_instance.mjs
|
|
2
|
+
//
|
|
3
|
+
// task 1004584 — `bongos ship` could not land a task in a VENDORED instance (the
|
|
4
|
+
// core installed as node_modules/@bongos/core). Reported from mercury on core
|
|
5
|
+
// 1.21.20, Windows, laptop deploy mode. One REPO_ROOT meant both "the instance
|
|
6
|
+
// repo" (merge target, lock) and "where the core's scripts live" (smoke), and in a
|
|
7
|
+
// vendored instance those are two different folders:
|
|
8
|
+
//
|
|
9
|
+
// 1. no worktree had `main` checked out → REPO_ROOT fell back to the package
|
|
10
|
+
// dir → the merge lock opened node_modules/@bongos/core/.git/… → ENOENT;
|
|
11
|
+
// 2. `main` checked out in a LINKED worktree → <root>/.git is a FILE → the same
|
|
12
|
+
// ENOENT on the lock;
|
|
13
|
+
// 3. `main` in the primary checkout → the merge succeeded, then the post-merge
|
|
14
|
+
// smoke ran `node scripts/gds/smoke-gds.js` cwd-relative to the instance →
|
|
15
|
+
// MODULE_NOT_FOUND → ship reset main and the task stuck at `confirmed`.
|
|
16
|
+
//
|
|
17
|
+
// This builds the real layout and runs ship's real local merge leg in it: a bare
|
|
18
|
+
// origin, an instance clone whose node_modules/@bongos/core holds a COPY of the
|
|
19
|
+
// core files ship loads, a per-task linked worktree on the task branch, and a
|
|
20
|
+
// child process that requires the vendored copy of ship-merge.js and calls
|
|
21
|
+
// mergeLocally from the feature worktree — the shape `bongos ship` runs in.
|
|
22
|
+
//
|
|
23
|
+
// What is stubbed, and why: smoke-gds.js (the real one probes a live server — the
|
|
24
|
+
// stub only proves it was found at the CORE path and run), and cli-lib's
|
|
25
|
+
// cliClient (the land-proof POST). The deploy mode is pinned to laptop by env so
|
|
26
|
+
// no gh probe runs. Everything else — git, the lock, the fitness gate, the push
|
|
27
|
+
// to origin — is the real code against real repos.
|
|
28
|
+
//
|
|
29
|
+
// Run: node tests/ship_vendored_instance.mjs
|
|
30
|
+
|
|
31
|
+
import { strict as assert } from 'node:assert';
|
|
32
|
+
import { createRequire } from 'node:module';
|
|
33
|
+
import { execFileSync, spawnSync } from 'node:child_process';
|
|
34
|
+
import fs from 'node:fs';
|
|
35
|
+
import os from 'node:os';
|
|
36
|
+
import path from 'node:path';
|
|
37
|
+
import { fileURLToPath } from 'node:url';
|
|
38
|
+
|
|
39
|
+
const require = createRequire(import.meta.url);
|
|
40
|
+
const REPO = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
41
|
+
const TASK = 4242;
|
|
42
|
+
const BRANCH = `task-${TASK}`;
|
|
43
|
+
|
|
44
|
+
// The core as an install would carry it: every tracked file under the paths the
|
|
45
|
+
// package ships code from (media skipped — nothing on the merge leg reads it).
|
|
46
|
+
// Read from the working tree, so the test exercises the code being changed.
|
|
47
|
+
// Built ONCE into a template and copied into each fixture.
|
|
48
|
+
const CORE_FILES = execFileSync('git', ['ls-files', '-z', 'src', 'scripts', 'modules', 'config', 'package.json', '.claude/sounds'], {
|
|
49
|
+
cwd: REPO, encoding: 'utf8',
|
|
50
|
+
}).split('\0').filter((f) => f && !/\.(png|jpe?g|webp|gif|svg|mp3|wav|ogg)$/i.test(f) && fs.existsSync(path.join(REPO, f)));
|
|
51
|
+
const CORE_TEMPLATE = fs.mkdtempSync(path.join(os.tmpdir(), 'bongos-vendored-core-'));
|
|
52
|
+
for (const rel of CORE_FILES) {
|
|
53
|
+
const dst = path.join(CORE_TEMPLATE, rel);
|
|
54
|
+
fs.mkdirSync(path.dirname(dst), { recursive: true });
|
|
55
|
+
fs.copyFileSync(path.join(REPO, rel), dst);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// A git env that ignores the developer's own config (hooks, signing, drivers).
|
|
59
|
+
const emptyCfg = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'bongos-vendored-cfg-')), 'gitconfig');
|
|
60
|
+
fs.writeFileSync(emptyCfg, '');
|
|
61
|
+
const GIT_ENV = {
|
|
62
|
+
...process.env,
|
|
63
|
+
GIT_CONFIG_NOSYSTEM: '1',
|
|
64
|
+
GIT_CONFIG_GLOBAL: emptyCfg,
|
|
65
|
+
GIT_AUTHOR_NAME: 'Vendored Test', GIT_AUTHOR_EMAIL: 'vendored@example.test',
|
|
66
|
+
GIT_COMMITTER_NAME: 'Vendored Test', GIT_COMMITTER_EMAIL: 'vendored@example.test',
|
|
67
|
+
};
|
|
68
|
+
function git(cwd, ...args) {
|
|
69
|
+
return execFileSync('git', args, { cwd, env: GIT_ENV, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function writeFile(p, body) {
|
|
73
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
74
|
+
fs.writeFileSync(p, body);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Build one fixture. `mainAt`: 'primary' | 'linked' | 'none'.
|
|
78
|
+
function buildFixture(mainAt) {
|
|
79
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), `bongos-vendored-${mainAt}-`));
|
|
80
|
+
const origin = path.join(root, 'origin.git');
|
|
81
|
+
const inst = path.join(root, 'instance');
|
|
82
|
+
git(root, 'init', '--bare', '-b', 'main', origin);
|
|
83
|
+
git(root, 'clone', '-q', origin, inst);
|
|
84
|
+
git(inst, 'checkout', '-q', '-b', 'main');
|
|
85
|
+
writeFile(path.join(inst, '.gitignore'), 'node_modules/\n');
|
|
86
|
+
writeFile(path.join(inst, 'README.md'), '# a vendored instance\n');
|
|
87
|
+
git(inst, 'add', '.');
|
|
88
|
+
git(inst, 'commit', '-q', '-m', 'instance: initial');
|
|
89
|
+
git(inst, 'push', '-q', '-u', 'origin', 'main');
|
|
90
|
+
|
|
91
|
+
// The vendored core, where `npm install` would put it.
|
|
92
|
+
const core = path.join(inst, 'node_modules', '@bongos', 'core');
|
|
93
|
+
fs.cpSync(CORE_TEMPLATE, core, { recursive: true });
|
|
94
|
+
// The smoke stub records WHERE it was run from — the bug was it not being found.
|
|
95
|
+
writeFile(path.join(core, 'scripts', 'gds', 'smoke-gds.js'),
|
|
96
|
+
"require('fs').writeFileSync(process.env.SMOKE_MARKER, JSON.stringify({ script: __filename, cwd: process.cwd() }));\n");
|
|
97
|
+
|
|
98
|
+
// The task's own worktree + branch, carrying one commit.
|
|
99
|
+
const feature = path.join(root, 'wt-task');
|
|
100
|
+
git(inst, 'worktree', 'add', '-q', '-b', BRANCH, feature, 'main');
|
|
101
|
+
writeFile(path.join(feature, 'feature.txt'), 'the work\n');
|
|
102
|
+
git(feature, 'add', 'feature.txt');
|
|
103
|
+
git(feature, 'commit', '-q', '-m', 'the task work');
|
|
104
|
+
|
|
105
|
+
// Where `main` lives.
|
|
106
|
+
let mainDir = inst;
|
|
107
|
+
if (mainAt !== 'primary') {
|
|
108
|
+
git(inst, 'checkout', '-q', '-b', 'scratch'); // the primary checkout leaves main
|
|
109
|
+
if (mainAt === 'linked') {
|
|
110
|
+
mainDir = path.join(root, 'wt-main');
|
|
111
|
+
git(inst, 'worktree', 'add', '-q', mainDir, 'main');
|
|
112
|
+
} else {
|
|
113
|
+
mainDir = null;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return { root, origin, inst, core, feature, mainDir };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Run ship's local merge leg from the vendored copy, in the feature worktree.
|
|
120
|
+
function runMergeLeg(fx) {
|
|
121
|
+
const marker = path.join(fx.root, 'smoke-ran.json');
|
|
122
|
+
const runner = path.join(fx.root, 'run-merge.js');
|
|
123
|
+
fs.writeFileSync(runner, `
|
|
124
|
+
const path = require('path');
|
|
125
|
+
const core = ${JSON.stringify(fx.core)};
|
|
126
|
+
const cli = require(path.join(core, 'scripts', 'gds', 'cli-lib.js'));
|
|
127
|
+
cli.cliClient = async () => ({ tasks: { postTasksIdAttestGate: async () => ({ ok: true, status: 200, data: { tip_recorded: true } }) } });
|
|
128
|
+
const state = require(path.join(core, 'scripts', 'gds', 'ship-state.js'));
|
|
129
|
+
const { mergeLocally } = require(path.join(core, 'scripts', 'gds', 'ship-merge.js'));
|
|
130
|
+
mergeLocally(${TASK}, 'vendored land').then(
|
|
131
|
+
(o) => { console.log('ROOTS ' + JSON.stringify({ core: state.CORE_ROOT, instance: state.INSTANCE_ROOT, main: state.MAIN_CHECKED_OUT })); console.log('RESULT ' + JSON.stringify(o)); },
|
|
132
|
+
(e) => { console.log('THREW ' + JSON.stringify({ code: e.code, message: e.message })); process.exitCode = 1; },
|
|
133
|
+
);
|
|
134
|
+
`);
|
|
135
|
+
const env = { ...GIT_ENV, SMOKE_MARKER: marker, BONGOS_DEPLOY_MODE: 'laptop'};
|
|
136
|
+
for (const k of ['BONGOS_DEPLOY_SSH_TARGET', 'OTB_DEPLOY_SSH_TARGET', 'BONGOS_DEPLOY_HEALTHZ_URL', 'OTB_DEPLOY_HEALTHZ_URL']) delete env[k];
|
|
137
|
+
const r = spawnSync(process.execPath, [runner], { cwd: fx.feature, env, encoding: 'utf8', timeout: 120000 });
|
|
138
|
+
const out = `${r.stdout || ''}\n${r.stderr || ''}`;
|
|
139
|
+
const line = (tag) => {
|
|
140
|
+
const l = out.split('\n').find((s) => s.startsWith(`${tag} `));
|
|
141
|
+
return l ? JSON.parse(l.slice(tag.length + 1)) : null;
|
|
142
|
+
};
|
|
143
|
+
return { out, result: line('RESULT'), roots: line('ROOTS'), threw: line('THREW'), marker };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// The two failure signatures from the report. (Not a blanket ENOENT match: ship's
|
|
147
|
+
// best-effort doc regenerators also run on this leg and grumble about the core's
|
|
148
|
+
// docs/ folder in a vendored layout — non-fatal by design, and not this bug.)
|
|
149
|
+
const LOCK_ENOENT = /ENOENT[^\n]*otb-automerge\.lock/;
|
|
150
|
+
const SMOKE_MISSING = /Cannot find module[^\n]*smoke-gds/;
|
|
151
|
+
const same = (a, b) => path.resolve(a).toLowerCase() === path.resolve(b).toLowerCase();
|
|
152
|
+
const commonLock = (fx) => path.join(fx.inst, '.git', 'otb-automerge.lock');
|
|
153
|
+
|
|
154
|
+
let passed = 0;
|
|
155
|
+
let failed = 0;
|
|
156
|
+
const fixtures = [];
|
|
157
|
+
async function t(name, fn) {
|
|
158
|
+
try { await fn(); passed++; console.log(` PASS ${name}`); }
|
|
159
|
+
catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function assertLanded(fx, run) {
|
|
163
|
+
assert.equal(run.threw, null, `the merge leg threw: ${JSON.stringify(run.threw)}\n${run.out}`);
|
|
164
|
+
assert.ok(run.result, `no result line:\n${run.out}`);
|
|
165
|
+
assert.equal(run.result.ok, true, `expected a land, got ${JSON.stringify(run.result)}\n${run.out}`);
|
|
166
|
+
assert.ok(!LOCK_ENOENT.test(run.out), `the merge lock hit ENOENT:\n${run.out}`);
|
|
167
|
+
assert.ok(!SMOKE_MISSING.test(run.out), `smoke-gds.js was not found:\n${run.out}`);
|
|
168
|
+
// the smoke ran — from the CORE package, with the instance checkout as cwd
|
|
169
|
+
const smoke = JSON.parse(fs.readFileSync(run.marker, 'utf8'));
|
|
170
|
+
assert.ok(same(path.dirname(smoke.script), path.join(fx.core, 'scripts', 'gds')), `smoke ran from ${smoke.script}`);
|
|
171
|
+
assert.ok(same(smoke.cwd, fx.mainDir), `smoke cwd was ${smoke.cwd}, expected the main checkout ${fx.mainDir}`);
|
|
172
|
+
// the work is on origin/main, and the lock is gone
|
|
173
|
+
git(fx.inst, 'fetch', '-q', 'origin');
|
|
174
|
+
const files = git(fx.inst, 'ls-tree', '--name-only', 'origin/main');
|
|
175
|
+
assert.ok(files.split('\n').includes('feature.txt'), `origin/main lacks the task's file:\n${files}`);
|
|
176
|
+
assert.ok(!fs.existsSync(commonLock(fx)), 'the merge lock was left behind');
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
console.log('ship merge leg in a vendored instance (task 1004584)');
|
|
180
|
+
|
|
181
|
+
// ---- resolveRoots, pure: git answers injected, no repo needed ----
|
|
182
|
+
const { resolveRoots } = require(path.join(REPO, 'scripts', 'gds', 'ship-state.js'));
|
|
183
|
+
const VENDORED_DIR = path.join('/inst', 'node_modules', '@bongos', 'core', 'scripts', 'gds');
|
|
184
|
+
const VENDORED_CORE = path.resolve(VENDORED_DIR, '..', '..'); // drive-qualified on Windows, as resolveRoots returns it
|
|
185
|
+
|
|
186
|
+
await t('resolveRoots: CORE_ROOT is the package dir, INSTANCE_ROOT the worktree with main', () => {
|
|
187
|
+
const r = resolveRoots({ dirname: VENDORED_DIR, cwd: '/wt-task', findMain: () => '/wt-main', findPrimary: () => '/inst' });
|
|
188
|
+
assert.deepEqual(r, { coreRoot: VENDORED_CORE, instanceRoot: '/wt-main', mainCheckedOut: true });
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
await t('resolveRoots: asks from the script dir first, then the process cwd', () => {
|
|
192
|
+
const asked = [];
|
|
193
|
+
const r = resolveRoots({
|
|
194
|
+
dirname: VENDORED_DIR, cwd: '/wt-task',
|
|
195
|
+
findMain: (d) => { asked.push(d); return d === '/wt-task' ? '/inst' : null; },
|
|
196
|
+
findPrimary: () => { throw new Error('primary must not be consulted when main was found'); },
|
|
197
|
+
});
|
|
198
|
+
assert.deepEqual(asked, [VENDORED_DIR, '/wt-task']);
|
|
199
|
+
assert.equal(r.instanceRoot, '/inst');
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
await t('resolveRoots: no main anywhere → the PRIMARY checkout, flagged, never the package dir', () => {
|
|
203
|
+
const r = resolveRoots({ dirname: VENDORED_DIR, cwd: '/wt-task', findMain: () => null, findPrimary: () => '/inst' });
|
|
204
|
+
assert.deepEqual(r, { coreRoot: VENDORED_CORE, instanceRoot: '/inst', mainCheckedOut: false });
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
await t('resolveRoots: git cannot answer at all → the package dir (the core repo itself, pre-git)', () => {
|
|
208
|
+
const r = resolveRoots({ dirname: VENDORED_DIR, cwd: '/x', findMain: () => null, findPrimary: () => null });
|
|
209
|
+
assert.deepEqual(r, { coreRoot: VENDORED_CORE, instanceRoot: VENDORED_CORE, mainCheckedOut: false });
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
// ---- the real merge leg, in a real vendored layout ----
|
|
213
|
+
|
|
214
|
+
await t('main in the PRIMARY checkout: lands, smoke found under the core package', () => {
|
|
215
|
+
const fx = buildFixture('primary'); fixtures.push(fx);
|
|
216
|
+
const run = runMergeLeg(fx);
|
|
217
|
+
assert.ok(same(run.roots.core, fx.core), `CORE_ROOT ${run.roots.core}`);
|
|
218
|
+
assert.ok(same(run.roots.instance, fx.inst), `INSTANCE_ROOT ${run.roots.instance}`);
|
|
219
|
+
assertLanded(fx, run);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
await t('main in a LINKED worktree: lands, lock lives in the common git dir', () => {
|
|
223
|
+
const fx = buildFixture('linked'); fixtures.push(fx);
|
|
224
|
+
const run = runMergeLeg(fx);
|
|
225
|
+
assert.ok(same(run.roots.instance, fx.mainDir), `INSTANCE_ROOT ${run.roots.instance}, expected ${fx.mainDir}`);
|
|
226
|
+
assert.equal(run.roots.main, true);
|
|
227
|
+
assertLanded(fx, run);
|
|
228
|
+
// and the lock path ship used is the one the merge-lock CLI uses from anywhere
|
|
229
|
+
const lock = require(path.join(fx.core, 'modules', 'lifecycle', 'merge-lock.js'));
|
|
230
|
+
assert.ok(same(lock.lockPath(fx.mainDir), commonLock(fx)), `lockPath(linked main) = ${lock.lockPath(fx.mainDir)}`);
|
|
231
|
+
assert.ok(same(lock.lockPath(fx.inst), commonLock(fx)));
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
await t('NO worktree has main: a clear bail naming the fix, never ENOENT, nothing pushed', () => {
|
|
235
|
+
const fx = buildFixture('none'); fixtures.push(fx);
|
|
236
|
+
const run = runMergeLeg(fx);
|
|
237
|
+
assert.equal(run.threw, null, `the merge leg threw: ${JSON.stringify(run.threw)}\n${run.out}`);
|
|
238
|
+
assert.ok(!LOCK_ENOENT.test(run.out), `the merge lock hit ENOENT:\n${run.out}`);
|
|
239
|
+
assert.equal(run.roots.main, false);
|
|
240
|
+
assert.ok(same(run.roots.instance, fx.inst), `fell back to ${run.roots.instance}, not the primary checkout`);
|
|
241
|
+
assert.ok(!same(run.roots.instance, fx.core), 'fell back to the core package dir');
|
|
242
|
+
assert.equal(run.result.ok, false);
|
|
243
|
+
assert.match(run.result.reason, /no worktree of this repo has main checked out/);
|
|
244
|
+
assert.match(run.result.nextStep, /checkout main/);
|
|
245
|
+
assert.ok(!fs.existsSync(run.marker), 'smoke ran although nothing was merged');
|
|
246
|
+
const files = git(fx.inst, 'ls-tree', '--name-only', 'origin/main');
|
|
247
|
+
assert.ok(!files.split('\n').includes('feature.txt'), 'origin/main changed on a bail');
|
|
248
|
+
assert.ok(!fs.existsSync(commonLock(fx)), 'the merge lock was left behind');
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
for (const fx of fixtures) {
|
|
252
|
+
try { fs.rmSync(fx.root, { recursive: true, force: true }); } catch (_) { /* best-effort */ }
|
|
253
|
+
}
|
|
254
|
+
for (const dir of [path.dirname(emptyCfg), CORE_TEMPLATE]) {
|
|
255
|
+
try { fs.rmSync(dir, { recursive: true, force: true }); } catch (_) { /* best-effort */ }
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
console.log(`\n${passed} passed, ${failed} failed`);
|
|
259
|
+
process.exit(failed ? 1 : 0);
|