@bongos/core 1.19.1032 → 1.19.1033
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 +159 -54
- package/.claude/skills/grade-audit/SKILL.md +1 -1
- package/.claude/skills/grader-health/SKILL.md +1 -1
- package/docs/adr/0175-the-board-room-full-idea-ratification.md +3 -2
- package/docs/adr/0178-the-copy-desk-flag-and-queue.md +3 -3
- package/docs/adr/0188-patrol-runs-on-ci-not-the-server.md +2 -2
- package/docs/adr/0189-baseline-floors-and-the-freeze-detector.md +1 -1
- package/docs/adr/0233-a-copy-proposal-is-a-task-carrying-a-patch.md +1 -1
- package/docs/adr/0241-the-artist-gate-is-a-per-project-deploy-gate-that-reads-a-state.md +11 -3
- package/docs/adr/README.md +3 -3
- package/docs/architecture.md +1 -1
- package/docs/file-map.md +4 -3
- package/docs/module-api-changelog.md +2 -0
- package/docs/recipes/autobongos-windows-host.md +3 -1
- package/docs/recipes/packaging-the-core.md +2 -0
- package/docs/recipes/redteam-patrol.md +2 -2
- package/docs/recipes/upgrading-the-core.md +1 -1
- package/modules/autonomy/gauge.js +57 -9
- package/modules/hall-ui/records/builders-roster.md +10 -0
- package/modules/hall-ui/records/catalog.md +10 -0
- package/modules/hall-ui/records/collaboration.md +14 -0
- package/modules/hall-ui/records/goal-dependency-forest.md +11 -0
- package/modules/hall-ui/records/oversight-family.md +19 -0
- package/modules/hall-ui/records/panel-family.md +19 -0
- package/modules/hall-ui/records/reading-rooms.md +12 -0
- package/modules/hall-ui/records/record-family.md +13 -0
- package/modules/hall-ui/records/reference-room.md +9 -0
- package/modules/hall-ui/records/roster-and-ledger.md +9 -0
- package/modules/hall-ui/records/sky.md +23 -0
- package/modules/lifecycle/dependency-advisory.js +2 -1
- package/modules/lifecycle/ship-preflight.js +91 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +7411 -0
- package/scripts/gds/address-park-sweep.sh +3 -1
- package/scripts/gds/autobongos-run.js +81 -1
- package/scripts/gds/baseline-staleness.js +1 -1
- package/scripts/gds/db-backup-nightly.sh +9 -4
- package/scripts/gds/db-backup-offsite.sh +4 -2
- package/scripts/gds/package-core.js +72 -1
- package/scripts/gds/provision-core-upgrade.js +128 -77
- package/scripts/gds/redteam-patrol.js +4 -4
- package/scripts/gds/release-notes.js +271 -0
- package/scripts/gds/seed-board-room-tasks.js +1 -1
- package/scripts/gds/ship-flow.js +5 -3
- package/scripts/gds/ship-land.js +86 -7
- package/scripts/gds/ship-preflight-steps.js +56 -3
- package/scripts/gds/stray-unit-sweep.sh +3 -1
- package/scripts/gds/upgrade-migrate.js +67 -0
- package/scripts/gds/upgrade.js +6 -4
- package/src/module-api.js +1 -1
- package/tests/adr_0162_citation.mjs +184 -0
- package/tests/autobongos_loop.mjs +114 -0
- package/tests/autonomy_gauge.mjs +56 -0
- package/tests/core_upgrade_runner.mjs +89 -1
- package/tests/db_backup_nightly.mjs +21 -3
- package/tests/db_backup_offsite.mjs +81 -0
- package/tests/hall_records.mjs +85 -0
- package/tests/helpers.mjs +25 -0
- package/tests/instance_db_migrated_on_code_move.mjs +8 -1
- package/tests/plain_cards.mjs +1 -1
- package/tests/redteam_patrol.mjs +1 -1
- package/tests/release_notes.mjs +327 -0
- package/tests/shell_stat_portability.mjs +49 -0
- package/tests/ship_land_conflict.mjs +181 -0
- package/tests/ship_preflight.mjs +160 -3
- package/tests/sweep_app_account.mjs +15 -2
- package/tests/upgrade_migrate.mjs +195 -0
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// tests/ship_land_conflict.mjs — a CONFLICTING PR is named while the land waits (task 1003499).
|
|
2
|
+
//
|
|
3
|
+
// WHY. GitHub's auto-merge waits for a clean merge as well as green checks, so a PR
|
|
4
|
+
// whose branch conflicts with main sits open under a fully green rollup and never
|
|
5
|
+
// merges. PR #497 did that for ~25 minutes while the ship's poll printed nothing and
|
|
6
|
+
// then signed off with "auto-merge is armed ... NOT a failure" and a card saying
|
|
7
|
+
// "Nothing to do" — so the completion card read "landing automatically" over a land
|
|
8
|
+
// that could not happen. These tests drive the REAL poll loops (pollCiDeploy, the
|
|
9
|
+
// gh path; pollServerPublish, the server path) with a scripted PR, and pin both
|
|
10
|
+
// halves: the conflict is said once, when it appears, and a wait that ends
|
|
11
|
+
// conflicting is a bail with the fix named, never the pending banner.
|
|
12
|
+
//
|
|
13
|
+
// Run: node tests/ship_land_conflict.mjs
|
|
14
|
+
|
|
15
|
+
import { strict as assert } from 'node:assert';
|
|
16
|
+
import { createRequire } from 'node:module';
|
|
17
|
+
|
|
18
|
+
process.env.BONGOS_SURFACE = 'agent'; // the plain cards print on the agent surface only
|
|
19
|
+
|
|
20
|
+
const require = createRequire(import.meta.url);
|
|
21
|
+
const land = require('../scripts/gds/ship-land.js');
|
|
22
|
+
const { CARDS } = require('../scripts/gds/plain-cards.js');
|
|
23
|
+
|
|
24
|
+
let passed = 0;
|
|
25
|
+
let failed = 0;
|
|
26
|
+
async function test(name, fn) {
|
|
27
|
+
try { await fn(); passed++; console.log(` ok ${name}`); }
|
|
28
|
+
catch (err) { failed++; console.log(` FAIL ${name}`); console.log(` ${err.message}`); }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Run fn with console.log captured; returns { result, out }.
|
|
32
|
+
async function capture(fn) {
|
|
33
|
+
const lines = [];
|
|
34
|
+
const real = console.log;
|
|
35
|
+
console.log = (...a) => { lines.push(a.join(' ')); };
|
|
36
|
+
try { return { result: await fn(), out: lines.join('\n') }; }
|
|
37
|
+
finally { console.log = real; }
|
|
38
|
+
}
|
|
39
|
+
const count = (hay, needle) => hay.split(needle).length - 1;
|
|
40
|
+
const CONFLICT_LINE = 'the PR CONFLICTS with main';
|
|
41
|
+
const PENDING_BANNER = 'This is NOT a failure';
|
|
42
|
+
|
|
43
|
+
// A scripted `gh pr view`: the Nth read answers with states[N] (the last one repeats).
|
|
44
|
+
function ghScript(states, calls = []) {
|
|
45
|
+
let i = 0;
|
|
46
|
+
return (args) => {
|
|
47
|
+
calls.push(args.join(' '));
|
|
48
|
+
const s = states[Math.min(i, states.length - 1)];
|
|
49
|
+
i++;
|
|
50
|
+
return typeof s === 'function' ? s(args) : s;
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
const OPEN = (mergeable, mergeStateStatus) => ({ state: 'OPEN', mergeCommit: null, mergeable, mergeStateStatus });
|
|
54
|
+
const noPause = () => {};
|
|
55
|
+
|
|
56
|
+
// ---- the pure half ----
|
|
57
|
+
|
|
58
|
+
await test('prConflicting reads both shapes it is handed, and a failed read is not a conflict', () => {
|
|
59
|
+
// gh pr view (camelCase)
|
|
60
|
+
assert.equal(land.prConflicting(OPEN('CONFLICTING', 'DIRTY')), true);
|
|
61
|
+
assert.equal(land.prConflicting(OPEN('UNKNOWN', 'DIRTY')), true);
|
|
62
|
+
assert.equal(land.prConflicting(OPEN('CONFLICTING', 'UNKNOWN')), true);
|
|
63
|
+
assert.equal(land.prConflicting(OPEN('MERGEABLE', 'CLEAN')), false);
|
|
64
|
+
assert.equal(land.prConflicting(OPEN('MERGEABLE', 'BLOCKED')), false, 'a red or pending check is not a conflict');
|
|
65
|
+
assert.equal(land.prConflicting(OPEN('UNKNOWN', 'UNKNOWN')), false, 'mergeability still computing is not a conflict');
|
|
66
|
+
// publish-status merge_driver (snake_case, as mergeIfGreen returns it)
|
|
67
|
+
assert.equal(land.prConflicting({ reason: 'merge_conflict', mergeable: 'CONFLICTING', merge_state: 'DIRTY' }), true);
|
|
68
|
+
assert.equal(land.prConflicting({ reason: null, merge_state: 'DIRTY' }), true);
|
|
69
|
+
assert.equal(land.prConflicting({ reason: 'failing_check:unit', mergeable: 'MERGEABLE', merge_state: 'BLOCKED' }), false);
|
|
70
|
+
for (const nothing of [null, undefined, {}]) assert.equal(land.prConflicting(nothing), false);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
await test('the conflict lines name the fix — merge main in, then re-run this task\'s ship — and carry the card', () => {
|
|
74
|
+
const text = land.conflictPrLines(4242).join('\n');
|
|
75
|
+
assert.ok(text.includes(CONFLICT_LINE), text);
|
|
76
|
+
assert.ok(text.includes('git merge origin/main'), 'merge, never rebase: the branch is already published (strand-fix case C)');
|
|
77
|
+
assert.ok(!/rebase/.test(text), 'a rebase makes every later push non-fast-forward');
|
|
78
|
+
assert.ok(text.includes('node scripts/gds/ship.js 4242'), 'the resume names THIS task');
|
|
79
|
+
assert.ok(text.includes(CARDS.merge_conflict.happened), 'the plain-words card for merge_conflict follows');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
await test('landConflicted is a bail with the fix as its next step, never pending', async () => {
|
|
83
|
+
const { result } = await capture(() => land.landConflicted(4242));
|
|
84
|
+
assert.equal(result.ok, false);
|
|
85
|
+
assert.equal(result.pending, false, 'pending is what makes the card say "landing automatically"');
|
|
86
|
+
assert.match(result.reason, /conflicts with main/);
|
|
87
|
+
assert.equal(result.nextStep, '/strand-fix 4242');
|
|
88
|
+
assert.equal(result.bailId, null, 'untagged, so ship-merge never reroutes it into the PR path it already took');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// ---- the gh path: pollCiDeploy ----
|
|
92
|
+
|
|
93
|
+
await test('pollCiDeploy: a PR conflicting for the whole wait bails, says so ONCE, and never prints the pending banner', async () => {
|
|
94
|
+
const calls = [];
|
|
95
|
+
const { result, out } = await capture(() => land.pollCiDeploy(4242, 'task-4242', 'abc', {
|
|
96
|
+
ghJson: ghScript([OPEN('CONFLICTING', 'DIRTY')], calls), pause: noPause,
|
|
97
|
+
}));
|
|
98
|
+
assert.equal(result.pending, false, 'a conflicting PR is not "landing automatically"');
|
|
99
|
+
assert.equal(result.nextStep, '/strand-fix 4242');
|
|
100
|
+
assert.equal(count(out, CONFLICT_LINE), 1, `said once when it appeared, not on every poll:\n${out}`);
|
|
101
|
+
assert.ok(!out.includes(PENDING_BANNER), 'the "NOT a failure" banner is for a land that is on its way');
|
|
102
|
+
assert.ok(!out.includes(CARDS.land_pending.next), 'nor the "Nothing to do" card');
|
|
103
|
+
assert.ok(calls[0].includes('mergeable') && calls[0].includes('mergeStateStatus'), `the poll asks GitHub whether it can merge: ${calls[0]}`);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
await test('pollCiDeploy: a mergeable PR that merely outlasts the wait is still pending, as before', async () => {
|
|
107
|
+
const { result, out } = await capture(() => land.pollCiDeploy(4242, 'task-4242', 'abc', {
|
|
108
|
+
ghJson: ghScript([OPEN('MERGEABLE', 'BLOCKED')]), pause: noPause,
|
|
109
|
+
}));
|
|
110
|
+
assert.equal(result.pending, true);
|
|
111
|
+
assert.ok(out.includes(PENDING_BANNER));
|
|
112
|
+
assert.equal(count(out, CONFLICT_LINE), 0);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
await test('pollCiDeploy: a conflict the server heals inside the window is announced, then the wait ends pending', async () => {
|
|
116
|
+
const states = [OPEN('UNKNOWN', 'UNKNOWN'), OPEN('CONFLICTING', 'DIRTY'), OPEN('CONFLICTING', 'DIRTY'), OPEN('MERGEABLE', 'BLOCKED')];
|
|
117
|
+
const { result, out } = await capture(() => land.pollCiDeploy(4242, 'task-4242', 'abc', {
|
|
118
|
+
ghJson: ghScript(states), pause: noPause,
|
|
119
|
+
}));
|
|
120
|
+
assert.equal(count(out, CONFLICT_LINE), 1);
|
|
121
|
+
assert.equal(result.pending, true, 'healed: the checks re-run and auto-merge is armed again');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
await test('pollCiDeploy: a heal undone by main moving on is announced again, and the verdict is the LAST state', async () => {
|
|
125
|
+
const states = [OPEN('CONFLICTING', 'DIRTY'), OPEN('MERGEABLE', 'BLOCKED'), OPEN('CONFLICTING', 'DIRTY')];
|
|
126
|
+
const { result, out } = await capture(() => land.pollCiDeploy(4242, 'task-4242', 'abc', {
|
|
127
|
+
ghJson: ghScript(states), pause: noPause,
|
|
128
|
+
}));
|
|
129
|
+
assert.equal(count(out, CONFLICT_LINE), 2);
|
|
130
|
+
assert.equal(result.pending, false);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
await test('pollCiDeploy: a gh that predates the mergeable fields falls back to the old read, no worse than before', async () => {
|
|
134
|
+
const calls = [];
|
|
135
|
+
const oldGh = (args) => (args.join(' ').includes('mergeable') ? null : { state: 'OPEN', mergeCommit: null });
|
|
136
|
+
const { result, out } = await capture(() => land.pollCiDeploy(4242, 'task-4242', 'abc', {
|
|
137
|
+
ghJson: ghScript([oldGh], calls), pause: noPause,
|
|
138
|
+
}));
|
|
139
|
+
assert.equal(result.pending, true, 'with nothing to say about mergeability, the old verdict stands');
|
|
140
|
+
assert.equal(count(out, CONFLICT_LINE), 0);
|
|
141
|
+
assert.ok(calls.some((c) => c.endsWith('--json state,mergeCommit')), 'the two-field read was the fallback');
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
await test('pollCiDeploy: a failed read keeps the last answer instead of forgetting the conflict', async () => {
|
|
145
|
+
const states = [OPEN('CONFLICTING', 'DIRTY'), null];
|
|
146
|
+
const { result } = await capture(() => land.pollCiDeploy(4242, 'task-4242', 'abc', {
|
|
147
|
+
ghJson: ghScript(states), pause: noPause,
|
|
148
|
+
}));
|
|
149
|
+
assert.equal(result.pending, false, 'a gh hiccup at the end must not turn a conflict back into "landing automatically"');
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
// ---- the server path: pollServerPublish ----
|
|
153
|
+
|
|
154
|
+
const status = (merge_driver) => async () => ({
|
|
155
|
+
ok: true, data: { merged: false, pr_state: 'open', deploy_status: null, reconcile_blocked: null, merge_driver },
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
await test('pollServerPublish: a merge_conflict from the server is named once, and the timeout bails', async () => {
|
|
159
|
+
const { result, out } = await capture(() => land.pollServerPublish(4242, 'task-4242', {
|
|
160
|
+
readStatus: status({ reason: 'merge_conflict', mergeable: 'CONFLICTING', merge_state: 'DIRTY', rollup_state: 'SUCCESS' }),
|
|
161
|
+
pause: noPause,
|
|
162
|
+
}));
|
|
163
|
+
assert.equal(count(out, CONFLICT_LINE), 1, out);
|
|
164
|
+
assert.ok(!out.includes('is RED'), 'green checks: a conflict is not a red PR');
|
|
165
|
+
assert.ok(!out.includes(PENDING_BANNER));
|
|
166
|
+
assert.equal(result.pending, false);
|
|
167
|
+
assert.equal(result.nextStep, '/strand-fix 4242');
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
await test('pollServerPublish: a red check still reads RED and still waits pending — this change does not touch it', async () => {
|
|
171
|
+
const { result, out } = await capture(() => land.pollServerPublish(4242, 'task-4242', {
|
|
172
|
+
readStatus: status({ reason: 'failing_check:unit', mergeable: 'MERGEABLE', merge_state: 'BLOCKED' }),
|
|
173
|
+
pause: noPause,
|
|
174
|
+
}));
|
|
175
|
+
assert.ok(out.includes('is RED'));
|
|
176
|
+
assert.equal(count(out, CONFLICT_LINE), 0);
|
|
177
|
+
assert.equal(result.pending, true);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
console.log(`\nship-land conflict signal (task 1003499) tests: ${passed} passed, ${failed} failed`);
|
|
181
|
+
process.exitCode = failed > 0 ? 1 : 0;
|
package/tests/ship_preflight.mjs
CHANGED
|
@@ -12,9 +12,12 @@ import os from 'node:os';
|
|
|
12
12
|
import path from 'node:path';
|
|
13
13
|
|
|
14
14
|
const require = createRequire(import.meta.url);
|
|
15
|
-
const {
|
|
16
|
-
|
|
17
|
-
|
|
15
|
+
const {
|
|
16
|
+
parsePorcelain, trackedDirty, classifyEmptyShip, cleanupMacFinderDupRefs, findMainWorktree, closeoutPlan,
|
|
17
|
+
untrackedPaths, untrackedLeftBehind, untrackedWarningLines,
|
|
18
|
+
} = require('../modules/lifecycle/ship-preflight.js');
|
|
19
|
+
// task 1001436: the preflight step itself + the ship-input resolver it is handed.
|
|
20
|
+
const { preflight, shipInputPaths } = require('../scripts/gds/ship-preflight-steps.js');
|
|
18
21
|
|
|
19
22
|
const tests = [];
|
|
20
23
|
const test = (name, fn) => tests.push({ name, fn });
|
|
@@ -352,6 +355,160 @@ test('closeoutPlan: stashLabel embeds the task id', () => {
|
|
|
352
355
|
assert.equal(closeoutPlan('?? x\n', 1234).stashLabel, 'otb-postship-1234');
|
|
353
356
|
});
|
|
354
357
|
|
|
358
|
+
// ---- the residual phantom ship: untracked files left behind (task 1001436) ----
|
|
359
|
+
// A branch with REAL committed work ships; a new file beside it that was never
|
|
360
|
+
// `git add`ed silently does not. The preflight now names those files — a warning,
|
|
361
|
+
// never a refusal (trackedDirty must keep ignoring scratch).
|
|
362
|
+
|
|
363
|
+
test('untrackedPaths: only ?? lines, in order, unquoted; tracked changes are not listed', () => {
|
|
364
|
+
const out = ' M src/a.js\n?? tests/new_case.mjs\nA src/b.js\n?? "docs/with space.md"\n';
|
|
365
|
+
assert.deepEqual(untrackedPaths(out), ['tests/new_case.mjs', 'docs/with space.md']);
|
|
366
|
+
assert.deepEqual(untrackedPaths(''), []);
|
|
367
|
+
assert.deepEqual(untrackedPaths(null), []);
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
test('untrackedLeftBehind: nothing committed → [] (the empty-diff guard already refuses and lists them)', () => {
|
|
371
|
+
const out = '?? tests/new_case.mjs\n';
|
|
372
|
+
assert.deepEqual(untrackedLeftBehind(out, { committedFileCount: 0 }), []);
|
|
373
|
+
assert.deepEqual(untrackedLeftBehind(out), [], 'default opts behave as "nothing committed"');
|
|
374
|
+
// ...and that empty case is exactly the one classifyEmptyShip refuses on:
|
|
375
|
+
assert.equal(classifyEmptyShip(out).reason, 'uncommitted-work');
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
test('untrackedLeftBehind: committed work + an untracked file → that file is named', () => {
|
|
379
|
+
const out = ' M src/unrelated.js\n?? tests/new_case.mjs\n?? docs/adr/0999-x.md\n';
|
|
380
|
+
assert.deepEqual(untrackedLeftBehind(out, { committedFileCount: 3 }), ['tests/new_case.mjs', 'docs/adr/0999-x.md']);
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
test('untrackedLeftBehind: the ship\'s own input files are excluded, whatever their spelling', () => {
|
|
384
|
+
const out = '?? notes.md\n?? summary.txt\n?? shots/after.png\n?? tests/new_case.mjs\n';
|
|
385
|
+
const inputs = ['./notes.md', 'summary.txt', 'shots\\after.png'];
|
|
386
|
+
assert.deepEqual(untrackedLeftBehind(out, { committedFileCount: 1, inputs }), ['tests/new_case.mjs']);
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
test('untrackedWarningLines: [] when there is nothing to say', () => {
|
|
390
|
+
assert.deepEqual(untrackedWarningLines([]), []);
|
|
391
|
+
assert.deepEqual(untrackedWarningLines(null), []);
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
test('untrackedWarningLines: names the count, the task, each file, and says it does not block', () => {
|
|
395
|
+
const text = untrackedWarningLines(['tests/new_case.mjs', 'docs/x.md'], { taskId: 1001436 }).join('\n');
|
|
396
|
+
assert.match(text, /2 untracked file\(s\) in this worktree will NOT ship with task 1001436/);
|
|
397
|
+
assert.match(text, /- tests\/new_case\.mjs/);
|
|
398
|
+
assert.match(text, /- docs\/x\.md/);
|
|
399
|
+
assert.match(text, /Warning only — not blocking; the ship proceeds/);
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
test('untrackedWarningLines: a long list is capped, with the remainder counted', () => {
|
|
403
|
+
const files = Array.from({ length: 17 }, (_, i) => `scratch/f${i}.txt`);
|
|
404
|
+
const lines = untrackedWarningLines(files, { cap: 15 });
|
|
405
|
+
assert.equal(lines.filter((l) => l.startsWith(' - ')).length, 15);
|
|
406
|
+
assert.ok(lines.includes(' … and 2 more'));
|
|
407
|
+
assert.match(lines.join('\n'), /17 untracked file\(s\)/);
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
test('shipInputPaths: prose/visual flags resolve to repo-relative paths; stdin and out-of-tree paths add nothing', () => {
|
|
411
|
+
const root = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'otb-shipinputs-')));
|
|
412
|
+
const outside = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'otb-shipinputs-out-')));
|
|
413
|
+
try {
|
|
414
|
+
fs.mkdirSync(path.join(root, 'sub'));
|
|
415
|
+
for (const f of ['notes.md', 'summary.txt', 'sub/shot.png', 'resp.md']) fs.writeFileSync(path.join(root, f), 'x');
|
|
416
|
+
fs.writeFileSync(path.join(outside, 'notes.md'), 'x');
|
|
417
|
+
const args = [
|
|
418
|
+
'1001436', '--notes-file', 'notes.md', '--summary-file=summary.txt',
|
|
419
|
+
'--visual', path.join(root, 'sub', 'shot.png'), '--response-file', '-',
|
|
420
|
+
];
|
|
421
|
+
assert.deepEqual(shipInputPaths(args, { cwd: root, repoRoot: root }), ['notes.md', 'summary.txt', 'sub/shot.png']);
|
|
422
|
+
// Run from a subdirectory: the relative flag value resolves against cwd, the result against the root.
|
|
423
|
+
assert.deepEqual(shipInputPaths(['1', '--response-file', '../resp.md'], { cwd: path.join(root, 'sub'), repoRoot: root }), ['resp.md']);
|
|
424
|
+
// A file outside the checkout cannot be an untracked file in it.
|
|
425
|
+
assert.deepEqual(shipInputPaths(['1', '--notes-file', path.join(outside, 'notes.md')], { cwd: root, repoRoot: root }), []);
|
|
426
|
+
// A flag with no value (last token) is ignored rather than resolved to the root.
|
|
427
|
+
assert.deepEqual(shipInputPaths(['1', '--notes-file'], { cwd: root, repoRoot: root }), []);
|
|
428
|
+
assert.deepEqual(shipInputPaths([], { cwd: root, repoRoot: root }), []);
|
|
429
|
+
} finally {
|
|
430
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
431
|
+
fs.rmSync(outside, { recursive: true, force: true });
|
|
432
|
+
}
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
// The behaviour, not just the helpers: run the REAL preflight() in a throwaway
|
|
436
|
+
// repo with a local "origin", and read what it prints. Two worlds: a branch
|
|
437
|
+
// carrying committed work (the warning must fire, minus inputs and ignored
|
|
438
|
+
// files), and a branch with nothing committed (it must stay silent — the
|
|
439
|
+
// empty-diff guard owns that case).
|
|
440
|
+
function withPreflightRepo(fn) {
|
|
441
|
+
const tmp = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'otb-untracked-')));
|
|
442
|
+
const origin = path.join(tmp, 'origin.git');
|
|
443
|
+
const work = path.join(tmp, 'work');
|
|
444
|
+
const run = (cmd, cwd = work) => execSync(cmd, { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
445
|
+
const prevCwd = process.cwd();
|
|
446
|
+
try {
|
|
447
|
+
execSync(`git init -q --bare -b main "${origin}"`);
|
|
448
|
+
execSync(`git clone -q "${origin}" "${work}"`, { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
449
|
+
run('git config user.email t@t.t && git config user.name t && git config commit.gpgsign false');
|
|
450
|
+
run('git checkout -q -b main');
|
|
451
|
+
fs.writeFileSync(path.join(work, '.gitignore'), '*.log\n');
|
|
452
|
+
fs.writeFileSync(path.join(work, 'a.txt'), 'x');
|
|
453
|
+
run('git add -A && git commit -q -m init && git push -q origin main');
|
|
454
|
+
process.chdir(work);
|
|
455
|
+
return fn({ work, run });
|
|
456
|
+
} finally {
|
|
457
|
+
process.chdir(prevCwd);
|
|
458
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function capturePreflight(claim, opts) {
|
|
463
|
+
const out = [];
|
|
464
|
+
const err = [];
|
|
465
|
+
const log = console.log;
|
|
466
|
+
const error = console.error;
|
|
467
|
+
console.log = (...a) => out.push(a.join(' '));
|
|
468
|
+
console.error = (...a) => err.push(a.join(' '));
|
|
469
|
+
try {
|
|
470
|
+
return { result: preflight(claim, opts), out: out.join('\n'), err: err.join('\n') };
|
|
471
|
+
} finally {
|
|
472
|
+
console.log = log;
|
|
473
|
+
console.error = error;
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
test('preflight (real git): committed work + a forgotten new file → warns, still OK, inputs + ignored files unlisted', () => {
|
|
478
|
+
withPreflightRepo(({ work, run }) => {
|
|
479
|
+
run('git checkout -q -b feature');
|
|
480
|
+
fs.mkdirSync(path.join(work, 'src'));
|
|
481
|
+
fs.writeFileSync(path.join(work, 'src', 'fix.js'), 'module.exports = 1;\n');
|
|
482
|
+
run('git add src/fix.js && git commit -q -m "the committed half"');
|
|
483
|
+
fs.mkdirSync(path.join(work, 'tests'));
|
|
484
|
+
fs.writeFileSync(path.join(work, 'tests', 'forgot.mjs'), '// never git-added\n');
|
|
485
|
+
fs.writeFileSync(path.join(work, 'notes.md'), 'handoff notes\n');
|
|
486
|
+
fs.writeFileSync(path.join(work, 'debug.log'), 'ignored by .gitignore\n');
|
|
487
|
+
|
|
488
|
+
const { result, out, err } = capturePreflight({ task_id: 4242, head_sha: null }, { inputFiles: ['notes.md'] });
|
|
489
|
+
assert.equal(result.ok, true, 'a warning must never refuse the ship');
|
|
490
|
+
// A wholly-untracked directory is still named file by file (--untracked-files=all).
|
|
491
|
+
assert.deepEqual(result.untrackedLeftBehind, ['tests/forgot.mjs']);
|
|
492
|
+
assert.match(err, /1 untracked file\(s\) in this worktree will NOT ship with task 4242/);
|
|
493
|
+
assert.match(err, /- tests\/forgot\.mjs/);
|
|
494
|
+
assert.doesNotMatch(err, /notes\.md/, 'the ship\'s own --notes-file is not a deliverable');
|
|
495
|
+
assert.doesNotMatch(err, /debug\.log/, 'an ignored file is never listed');
|
|
496
|
+
assert.match(out, /preflight: tracked files clean \(1 untracked file\(s\) will not ship/);
|
|
497
|
+
});
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
test('preflight (real git): nothing committed on the branch → no untracked warning', () => {
|
|
501
|
+
withPreflightRepo(({ work, run }) => {
|
|
502
|
+
run('git checkout -q -b feature');
|
|
503
|
+
fs.writeFileSync(path.join(work, 'forgot.md'), 'never git-added\n');
|
|
504
|
+
const { result, out, err } = capturePreflight({ task_id: 4243, head_sha: null }, {});
|
|
505
|
+
assert.equal(result.ok, true);
|
|
506
|
+
assert.deepEqual(result.untrackedLeftBehind, []);
|
|
507
|
+
assert.doesNotMatch(err, /Untracked-file warning/);
|
|
508
|
+
assert.match(out, /preflight: working tree clean/);
|
|
509
|
+
});
|
|
510
|
+
});
|
|
511
|
+
|
|
355
512
|
// ---- runner ----
|
|
356
513
|
|
|
357
514
|
let pass = 0;
|
|
@@ -17,13 +17,17 @@ const REPO = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
|
17
17
|
const skip = spawnSync('bash', ['-c', 'true']).error ? 'bash not on PATH' : false;
|
|
18
18
|
const slash = (p) => p.replace(/\\/g, '/');
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
// `bsdStat` swaps in macOS's stat, which refuses GNU's `-c` outright and names an owner
|
|
21
|
+
// with `-f %Su` instead (task 1004170).
|
|
22
|
+
function stubs({ bsdStat = false } = {}) {
|
|
21
23
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sweep-stubs-'));
|
|
22
24
|
const sudoLog = slash(path.join(dir, 'sudo.log'));
|
|
23
25
|
const statLog = slash(path.join(dir, 'stat.log'));
|
|
24
26
|
const bin = (name, body) => fs.writeFileSync(path.join(dir, name), `#!/usr/bin/env bash\n${body}\n`, { mode: 0o755 });
|
|
25
27
|
bin('sudo', `echo "$*" >> "${sudoLog}"; exit 0`);
|
|
26
|
-
bin('stat', `echo "$*" >> "${statLog}";
|
|
28
|
+
bin('stat', `echo "$*" >> "${statLog}"; ` + (bsdStat
|
|
29
|
+
? 'case "$1 $2" in "-c "*) echo "stat: illegal option -- c" >&2; exit 1 ;; "-f %Su") echo checkout-owner ;; *) exit 1 ;; esac'
|
|
30
|
+
: 'echo checkout-owner'));
|
|
27
31
|
bin('systemctl', 'exit 0');
|
|
28
32
|
const lines = (f) => (fs.existsSync(f) ? fs.readFileSync(f, 'utf8').split('\n').filter(Boolean) : []);
|
|
29
33
|
return { dir, sudo: () => lines(sudoLog), stat: () => lines(statLog) };
|
|
@@ -109,4 +113,13 @@ for (const [name, src] of [['address-park-sweep', PARK], ['stray-unit-sweep', ST
|
|
|
109
113
|
assert.match(s.stat()[0] || '', new RegExp(`^-c %U .*${path.basename(inst)}$`), 'the instance root, not the package');
|
|
110
114
|
assert.match(s.sudo()[0] || '', /^-n -u checkout-owner psql /);
|
|
111
115
|
});
|
|
116
|
+
|
|
117
|
+
test(`${name}: on macOS, where stat refuses GNU's -c, the checkout's owner still acts (task 1004170)`, { skip }, () => {
|
|
118
|
+
// It used to read '' as the owner there and refuse with "cannot use '' as the app account".
|
|
119
|
+
const s = stubs({ bsdStat: true });
|
|
120
|
+
const inst = fs.mkdtempSync(path.join(os.tmpdir(), 'sweep-inst-'));
|
|
121
|
+
run(src, s, { INSTANCE_DIR: slash(inst) });
|
|
122
|
+
assert.deepEqual(s.stat().map((l) => l.split(' ').slice(0, 2).join(' ')), ['-c %U', '-f %Su'], 'GNU is asked first and BSD answers');
|
|
123
|
+
assert.match(s.sudo()[0] || '', /^-n -u checkout-owner psql /);
|
|
124
|
+
});
|
|
112
125
|
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
// tests/upgrade_migrate.mjs — `bongos upgrade` migrates an ADOPTED project that declares
|
|
2
|
+
// no migrate script, instead of rolling itself back (task 1004274; found preparing the
|
|
3
|
+
// task 1004047 catch-up pass).
|
|
4
|
+
//
|
|
5
|
+
// The catch-up pass could not reach mercury: an adopted Python/Flask project whose
|
|
6
|
+
// package.json is exactly what the adopt layer mints — name, private, dependencies, no
|
|
7
|
+
// scripts. upgrade.js migrated with `npm run migrate` only, so every upgrade of it died
|
|
8
|
+
// at step 4, after the pin had moved and the new core was installed, and rolled back —
|
|
9
|
+
// restarting a live app to change nothing — while the dry run reported nothing wrong.
|
|
10
|
+
//
|
|
11
|
+
// Pinned here: the fallback is the core's own migrate.sh, byte-identical to what a
|
|
12
|
+
// scaffold's script runs; a declared script still wins; the dry run says which one will
|
|
13
|
+
// run, on a line the Deploy page's pre-flight parser picks up; and the real command
|
|
14
|
+
// resolves the instance even when whoever launched the upgrade left a stray INIT_CWD.
|
|
15
|
+
//
|
|
16
|
+
// Run: node --test tests/upgrade_migrate.mjs
|
|
17
|
+
|
|
18
|
+
import { test } from 'node:test';
|
|
19
|
+
import assert from 'node:assert/strict';
|
|
20
|
+
import { createRequire } from 'node:module';
|
|
21
|
+
import { execFileSync } from 'node:child_process';
|
|
22
|
+
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, realpathSync, symlinkSync } from 'node:fs';
|
|
23
|
+
import { tmpdir } from 'node:os';
|
|
24
|
+
import path from 'node:path';
|
|
25
|
+
import { fileURLToPath } from 'node:url';
|
|
26
|
+
|
|
27
|
+
const require = createRequire(import.meta.url);
|
|
28
|
+
const { migratePlan, CORE_MIGRATE_SCRIPT } = require('../scripts/gds/upgrade-migrate.js');
|
|
29
|
+
const u = require('../scripts/gds/upgrade.js');
|
|
30
|
+
const init = require('../scripts/gds/init.js');
|
|
31
|
+
const { parsePreflight } = require('../scripts/gds/provision-core-upgrade.js');
|
|
32
|
+
|
|
33
|
+
const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
34
|
+
const fixture = (prefix) => realpathSync(mkdtempSync(path.join(tmpdir(), prefix)));
|
|
35
|
+
|
|
36
|
+
// A project exactly as the adopt layer leaves one that had no package.json of its own —
|
|
37
|
+
// built BY that layer, so this cannot drift from what adoption really writes.
|
|
38
|
+
function adoptedProject({ installed = '1.19.312' } = {}) {
|
|
39
|
+
const dir = fixture('upgrade-migrate-adopted-');
|
|
40
|
+
const { pkg, action } = init.mergeCoreDependency(null, { coreRange: `file:vendor/bongos-core-${installed}.tgz`, name: 'mercury' });
|
|
41
|
+
assert.equal(action, 'created', 'fixture: the adopt layer minted the package.json');
|
|
42
|
+
assert.deepEqual(Object.keys(pkg).sort(), ['dependencies', 'name', 'private'], 'fixture: mercury\'s real shape — no scripts at all');
|
|
43
|
+
writeFileSync(path.join(dir, 'package.json'), `${JSON.stringify(pkg, null, 2)}\n`);
|
|
44
|
+
const core = path.join(dir, 'node_modules', '@bongos', 'core');
|
|
45
|
+
mkdirSync(core, { recursive: true });
|
|
46
|
+
writeFileSync(path.join(core, 'package.json'), `${JSON.stringify({ name: '@bongos/core', version: installed })}\n`);
|
|
47
|
+
return dir;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function scaffoldedProject(scripts = { migrate: init.INSTANCE_MIGRATE_SCRIPT, start: init.INSTANCE_START_SCRIPT }) {
|
|
51
|
+
const dir = fixture('upgrade-migrate-scaffold-');
|
|
52
|
+
writeFileSync(path.join(dir, 'package.json'), `${JSON.stringify({ name: 'charter', private: true, scripts, dependencies: { '@bongos/core': '1.19.871' } }, null, 2)}\n`);
|
|
53
|
+
return dir;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Records every spawn as "cmd args…" plus its options. The pre-flight's database
|
|
57
|
+
// resolver is answered with a name (as the real one answers on a provisioned box), and
|
|
58
|
+
// `npm install` flips the installed core to `installTo` — all a successful install needs
|
|
59
|
+
// to look like here.
|
|
60
|
+
function recordingRun(calls, { dir, installTo, db = 'mercury' } = {}) {
|
|
61
|
+
return (cmd, args = [], opts = {}) => {
|
|
62
|
+
calls.push({ line: `${cmd} ${args.join(' ')}`.trim(), opts });
|
|
63
|
+
if (cmd === 'git' && args[0] === 'status') return { status: 0, stdout: '' };
|
|
64
|
+
if (cmd === process.execPath && String(args[1] || '').includes('instanceDbName')) return { status: 0, stdout: db };
|
|
65
|
+
if (cmd === 'npm' && args[0] === 'install' && installTo) {
|
|
66
|
+
writeFileSync(path.join(dir, 'node_modules', '@bongos', 'core', 'package.json'), `${JSON.stringify({ name: '@bongos/core', version: installTo })}\n`);
|
|
67
|
+
}
|
|
68
|
+
return { status: 0, stdout: '' };
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
test('the fallback IS the scaffold\'s own migrate script, byte for byte', () => {
|
|
73
|
+
assert.equal(`bash ${CORE_MIGRATE_SCRIPT}`, init.INSTANCE_MIGRATE_SCRIPT,
|
|
74
|
+
'an adopted project must be migrated by exactly the command a greenfield scaffold declares');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('a project with no migrate script is migrated by the core\'s migrate.sh, from its own root', () => {
|
|
78
|
+
const dir = adoptedProject();
|
|
79
|
+
const plan = migratePlan(dir, { env: { PATH: '/usr/bin', PGDATABASE: 'mercury', INIT_CWD: '/somewhere/else' } });
|
|
80
|
+
assert.equal(plan.via, 'core-script');
|
|
81
|
+
assert.equal(plan.cmd, 'bash');
|
|
82
|
+
assert.deepEqual(plan.args, [CORE_MIGRATE_SCRIPT]);
|
|
83
|
+
assert.equal(plan.env.INIT_CWD, dir, 'INIT_CWD is what npm run would set — the project, never an inherited stray');
|
|
84
|
+
assert.equal(plan.env.PGDATABASE, 'mercury', 'the database the runner named still reaches migrate.sh');
|
|
85
|
+
assert.match(plan.note, /declares no migrate script/);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('scripts without a migrate entry, or a blank one, also fall back', () => {
|
|
89
|
+
assert.equal(migratePlan(scaffoldedProject({ start: 'node app.js' })).via, 'core-script');
|
|
90
|
+
assert.equal(migratePlan(scaffoldedProject({ migrate: ' ' })).via, 'core-script');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('a declared migrate script still wins, whatever it says', () => {
|
|
94
|
+
for (const migrate of [init.INSTANCE_MIGRATE_SCRIPT, 'node scripts/own-migrate.js']) {
|
|
95
|
+
const plan = migratePlan(scaffoldedProject({ migrate }));
|
|
96
|
+
assert.equal(plan.via, 'npm-script');
|
|
97
|
+
assert.deepEqual([plan.cmd, ...plan.args], ['npm', 'run', 'migrate']);
|
|
98
|
+
assert.equal(plan.env, null, 'npm sets INIT_CWD itself, so nothing is overridden');
|
|
99
|
+
assert.equal(plan.note, null, 'the ordinary case adds no pre-flight line');
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('a missing or unparseable package.json keeps the old command rather than guessing', () => {
|
|
104
|
+
assert.equal(migratePlan(fixture('upgrade-migrate-empty-')).via, 'npm-script');
|
|
105
|
+
const bad = fixture('upgrade-migrate-bad-');
|
|
106
|
+
writeFileSync(path.join(bad, 'package.json'), '{ not json');
|
|
107
|
+
assert.equal(migratePlan(bad).via, 'npm-script');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('runMigrate spawns the plan: npm on a scaffold, the core script on an adopted project', () => {
|
|
111
|
+
const calls = [];
|
|
112
|
+
u.runMigrate(scaffoldedProject(), recordingRun(calls));
|
|
113
|
+
const adopted = adoptedProject();
|
|
114
|
+
u.runMigrate(adopted, recordingRun(calls));
|
|
115
|
+
assert.equal(calls[0].line, 'npm run migrate');
|
|
116
|
+
assert.equal(calls[0].opts.env, undefined, 'the scaffold path inherits the environment exactly as before');
|
|
117
|
+
assert.equal(calls[1].line, `bash ${CORE_MIGRATE_SCRIPT}`);
|
|
118
|
+
assert.equal(calls[1].opts.cwd, adopted);
|
|
119
|
+
assert.equal(calls[1].opts.env.INIT_CWD, adopted);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('a full registry upgrade of an adopted project migrates it instead of rolling back', async () => {
|
|
123
|
+
const dir = adoptedProject({ installed: '1.19.312' });
|
|
124
|
+
const calls = [];
|
|
125
|
+
const res = await u.runUpgrade(
|
|
126
|
+
{ to: '1.19.1029', registry: true, instance: dir, service: 'mercury', healthCheck: false },
|
|
127
|
+
{
|
|
128
|
+
log: () => {}, err: () => {},
|
|
129
|
+
run: recordingRun(calls, { dir, installTo: '1.19.1029' }),
|
|
130
|
+
materialize: () => ({ skills: 0 }),
|
|
131
|
+
regenerateApiArtifacts: () => ({ ok: true, ran: [] }),
|
|
132
|
+
databaseUrl: '', pgDatabase: '',
|
|
133
|
+
},
|
|
134
|
+
);
|
|
135
|
+
const lines = calls.map((c) => c.line);
|
|
136
|
+
assert.equal(res.ok, true, `the upgrade completes: ${res.error || ''}`);
|
|
137
|
+
assert.ok(!res.rolledBack, 'and was not rolled back');
|
|
138
|
+
assert.ok(lines.includes(`bash ${CORE_MIGRATE_SCRIPT}`), 'the schema moved with the code');
|
|
139
|
+
assert.ok(!lines.includes('npm run migrate'), 'the missing script was never asked for');
|
|
140
|
+
assert.ok(lines.indexOf(`bash ${CORE_MIGRATE_SCRIPT}`) < lines.indexOf('systemctl restart mercury'), 'and migrate still runs before the restart that makes the code live');
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('the dry run names the fallback on a line the Deploy page\'s pre-flight shows', async () => {
|
|
144
|
+
const dir = adoptedProject();
|
|
145
|
+
const out = [];
|
|
146
|
+
const res = await u.runUpgrade(
|
|
147
|
+
{ to: '1.19.1029', registry: true, instance: dir, dryRun: true },
|
|
148
|
+
{ log: (m) => out.push(m), err: () => {}, run: recordingRun([]) },
|
|
149
|
+
);
|
|
150
|
+
assert.equal(res.ok, true);
|
|
151
|
+
const text = out.join('\n');
|
|
152
|
+
assert.match(text, new RegExp(`would: npm install → bash ${CORE_MIGRATE_SCRIPT.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&')} →`));
|
|
153
|
+
const { checks } = parsePreflight(text);
|
|
154
|
+
assert.ok(checks.some((c) => /declares no migrate script/.test(c)), `the door's parser carries it: ${JSON.stringify(checks)}`);
|
|
155
|
+
assert.deepEqual(JSON.parse(readFileSync(path.join(dir, 'package.json'), 'utf8')).dependencies,
|
|
156
|
+
{ '@bongos/core': 'file:vendor/bongos-core-1.19.312.tgz' }, 'and the dry run moved nothing');
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test('the dry run of an ordinary scaffold is unchanged', async () => {
|
|
160
|
+
const dir = scaffoldedProject();
|
|
161
|
+
const core = path.join(dir, 'node_modules', '@bongos', 'core');
|
|
162
|
+
mkdirSync(core, { recursive: true });
|
|
163
|
+
writeFileSync(path.join(core, 'package.json'), `${JSON.stringify({ name: '@bongos/core', version: '1.19.871' })}\n`);
|
|
164
|
+
const out = [];
|
|
165
|
+
await u.runUpgrade({ to: '1.19.1029', registry: true, instance: dir, dryRun: true }, { log: (m) => out.push(m), err: () => {}, run: recordingRun([]) });
|
|
166
|
+
const text = out.join('\n');
|
|
167
|
+
assert.match(text, /would: npm install → npm run migrate →/);
|
|
168
|
+
assert.doesNotMatch(text, /declares no migrate script/);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
// The real migrate.sh, through its DB-free --print-roots seam (tests/migrate_root_resolution.mjs):
|
|
172
|
+
// the fallback must find the PROJECT, not the core package and not a stray INIT_CWD.
|
|
173
|
+
test('the real fallback command resolves the project as its instance root, despite a stray INIT_CWD', (t) => {
|
|
174
|
+
const dir = adoptedProject();
|
|
175
|
+
writeFileSync(path.join(dir, 'bongos.config.json'), JSON.stringify({ instanceRoot: '.' }));
|
|
176
|
+
const core = path.join(dir, 'node_modules', '@bongos', 'core');
|
|
177
|
+
// Swap the stub for the real core, so the script that runs is the one that ships.
|
|
178
|
+
execFileSync(process.execPath, ['-e', 'require("fs").rmSync(process.argv[1], { recursive: true, force: true })', core]);
|
|
179
|
+
try { symlinkSync(REPO_ROOT, core, 'dir'); } catch (e) {
|
|
180
|
+
if (e.code === 'EPERM' || e.code === 'EACCES') return t.skip('this platform refuses symlinks without elevation');
|
|
181
|
+
throw e;
|
|
182
|
+
}
|
|
183
|
+
const stray = fixture('upgrade-migrate-stray-');
|
|
184
|
+
const base = { ...process.env, INIT_CWD: stray };
|
|
185
|
+
for (const k of Object.keys(base)) if (/_INSTANCE_ROOT$/.test(k)) delete base[k];
|
|
186
|
+
const rootsOf = (out) => Object.fromEntries(out.split('\n').map((l) => /^(INVOKE_CWD|CORE_ROOT|INSTANCE_ROOT)=(.*)$/.exec(l)).filter(Boolean).map((m) => [m[1], m[2]]));
|
|
187
|
+
|
|
188
|
+
const plan = migratePlan(dir, { env: base });
|
|
189
|
+
const roots = rootsOf(execFileSync(plan.cmd, [...plan.args, '--print-roots'], { cwd: dir, env: plan.env, encoding: 'utf8' }));
|
|
190
|
+
assert.equal(roots.INSTANCE_ROOT, dir, 'migrate.sh migrates THIS project');
|
|
191
|
+
|
|
192
|
+
// And the override is load-bearing: the same script with the inherited value is misaimed.
|
|
193
|
+
const blind = rootsOf(execFileSync('bash', [CORE_MIGRATE_SCRIPT, '--print-roots'], { cwd: dir, env: base, encoding: 'utf8' }));
|
|
194
|
+
assert.notEqual(blind.INSTANCE_ROOT, dir, 'without INIT_CWD pinned, a stray value decides which project is migrated');
|
|
195
|
+
});
|