@bongos/core 1.19.318 → 1.19.320
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 +125 -40
- package/.claude/skills/merge-mode/SKILL.md +3 -1
- package/.devcontainer/init-firewall.sh +41 -5
- package/clients/bongos-client/examples/hello-world.mjs +11 -2
- package/docs/copy-inventory.md +20 -20
- package/docs/copy-registry.json +22 -22
- package/modules/hall-ui/public/builders.js +0 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/audit-replay.sh +20 -3
- package/scripts/gds/box.js +79 -86
- package/scripts/gds/db-backup-nightly.sh +17 -4
- package/scripts/gds/db-backup-offsite.sh +23 -6
- package/scripts/gds/discipline-modes.json +1 -1
- package/scripts/gds/doc-cli-guard.js +34 -2
- package/scripts/gds/fitness.js +8 -4
- package/scripts/gds/gds-db-backup.service +15 -4
- package/scripts/gds/gds-db-backup.timer +12 -1
- package/scripts/gds/gds-db-offsite.service +16 -5
- package/scripts/gds/gds-db-offsite.timer +12 -1
- package/scripts/gds/go-live.js +142 -48
- package/scripts/gds/instance-apex.js +28 -0
- package/scripts/gds/newcomer-floor.templates.json +2 -2
- package/scripts/gds/provision.js +7 -3
- package/scripts/gds/ship-deploy-target.js +388 -0
- package/scripts/gds/ship-finish.js +377 -0
- package/scripts/gds/ship-flow.js +814 -0
- package/scripts/gds/ship-git.js +100 -0
- package/scripts/gds/ship-grade-steps.js +376 -0
- package/scripts/gds/ship-grade.js +319 -0
- package/scripts/gds/ship-io.js +100 -0
- package/scripts/gds/ship-land.js +539 -0
- package/scripts/gds/ship-memory-sync.js +192 -0
- package/scripts/gds/ship-merge.js +297 -0
- package/scripts/gds/ship-preflight-steps.js +323 -0
- package/scripts/gds/ship-regen.js +286 -0
- package/scripts/gds/ship-resume.js +151 -0
- package/scripts/gds/ship-session-upload.js +216 -0
- package/scripts/gds/ship-state.js +73 -0
- package/scripts/gds/ship-surprise.js +84 -0
- package/scripts/gds/ship.js +105 -4195
- package/scripts/gds/update-channel.js +12 -0
- package/scripts/status-probe.mjs +30 -2
- package/src/module-api.js +5 -1
- package/tests/go_live.mjs +65 -32
- package/tests/helpers.mjs +13 -0
- package/tests/provision.mjs +7 -1
- package/tests/regrade_eligibility.mjs +43 -0
- package/tests/ship_assurance.mjs +4 -2
- package/tests/ship_publish_error.mjs +43 -0
- package/tests/update_channel.mjs +33 -0
|
@@ -159,6 +159,18 @@ function loadSubscriptions({ instanceDir = process.cwd(), fsImpl = fs, file = SU
|
|
|
159
159
|
service: r.service ? String(r.service) : null,
|
|
160
160
|
healthUrl: r.healthUrl ? String(r.healthUrl) : null,
|
|
161
161
|
channel: normalizeChannel(r.channel == null ? defaultChannel : r.channel),
|
|
162
|
+
// Deploy topology (task 1003219). Optional, and only meaningful together: with
|
|
163
|
+
// versionUrl + deployTimer present the sweep can hand the bump to go-live.js and
|
|
164
|
+
// inherit its backup, pull-deploy-timer guard and INDEPENDENT box-side version
|
|
165
|
+
// read-back. Absent, the sweep falls back to calling `bongos upgrade` directly.
|
|
166
|
+
// Carried through here because a normalizer that silently drops a field makes an
|
|
167
|
+
// entry look enrolled while the capability it names never engages.
|
|
168
|
+
versionUrl: r.versionUrl ? String(r.versionUrl) : null,
|
|
169
|
+
deployTimer: r.deployTimer ? String(r.deployTimer) : null,
|
|
170
|
+
deployService: r.deployService ? String(r.deployService) : null,
|
|
171
|
+
backupDb: r.backupDb ? String(r.backupDb) : null,
|
|
172
|
+
backupDir: r.backupDir ? String(r.backupDir) : null,
|
|
173
|
+
registryPackage: r.registryPackage ? String(r.registryPackage) : null,
|
|
162
174
|
// Per-instance env merged into the `bongos upgrade` child (e.g. PGDATABASE so the migrate step
|
|
163
175
|
// hits THIS instance's DB, not the control plane's — see the live-op learnings for demo). Only
|
|
164
176
|
// string values are kept; anything else is ignored.
|
package/scripts/status-probe.mjs
CHANGED
|
@@ -34,10 +34,30 @@ import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
|
34
34
|
import { existsSync } from 'node:fs';
|
|
35
35
|
import path from 'node:path';
|
|
36
36
|
import { pathToFileURL } from 'node:url';
|
|
37
|
+
import { createRequire } from 'node:module';
|
|
38
|
+
|
|
39
|
+
// Target + User-Agent come from the INSTANCE's own branding pack (task 1002757).
|
|
40
|
+
// Both were founder literals, so another instance's uptime CI probed the founder
|
|
41
|
+
// host and reported ITS health as their own. No literal fallback for the target:
|
|
42
|
+
// a probe with nothing to probe must say so, not silently watch someone else.
|
|
43
|
+
const requireCjs = createRequire(import.meta.url);
|
|
44
|
+
const brandingSafe = () => { try { return requireCjs('../src/branding'); } catch { return null; } };
|
|
45
|
+
const defaultTarget = () => {
|
|
46
|
+
const b = brandingSafe();
|
|
47
|
+
try {
|
|
48
|
+
const origin = b && b.branding().domains?.publicOrigin;
|
|
49
|
+
return origin ? `${String(origin).replace(/\/+$/, '')}/healthz` : null;
|
|
50
|
+
} catch { return null; }
|
|
51
|
+
};
|
|
52
|
+
const defaultUserAgent = () => {
|
|
53
|
+
const b = brandingSafe();
|
|
54
|
+
try { return `${b.userAgent().split('/')[0]}-status-probe/1.0 (+github-actions)`; }
|
|
55
|
+
catch { return 'cloud-bongos-status-probe/1.0 (+github-actions)'; }
|
|
56
|
+
};
|
|
37
57
|
|
|
38
58
|
const DEFAULTS = {
|
|
39
59
|
dir: '.',
|
|
40
|
-
target:
|
|
60
|
+
target: defaultTarget(),
|
|
41
61
|
attempts: 3,
|
|
42
62
|
'timeout-ms': 15000,
|
|
43
63
|
'gap-ms': 10000,
|
|
@@ -70,7 +90,7 @@ async function attempt(target, timeoutMs) {
|
|
|
70
90
|
const res = await fetch(target, {
|
|
71
91
|
signal: ac.signal,
|
|
72
92
|
redirect: 'manual', // a 200 must come from the origin, not a redirect chain
|
|
73
|
-
headers: { 'User-Agent':
|
|
93
|
+
headers: { 'User-Agent': defaultUserAgent() },
|
|
74
94
|
});
|
|
75
95
|
return { ok: res.status === 200, httpStatus: res.status, latencyMs: Date.now() - start };
|
|
76
96
|
} catch (_) {
|
|
@@ -125,6 +145,14 @@ async function main() {
|
|
|
125
145
|
const historyFile = path.join(dir, 'history.jsonl');
|
|
126
146
|
const statusFile = path.join(dir, 'status.json');
|
|
127
147
|
|
|
148
|
+
// No target means the branding pack names no public origin and none was passed
|
|
149
|
+
// (task 1002757 — there is deliberately no founder-host fallback to probe).
|
|
150
|
+
if (!args.target) {
|
|
151
|
+
console.error('status-probe: no target. Pass --target <url>, or give this instance a domains.publicOrigin in config/branding.json.');
|
|
152
|
+
process.exitCode = 2;
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
|
|
128
156
|
const result = await probe({
|
|
129
157
|
target: args.target,
|
|
130
158
|
attempts: args.attempts,
|
package/src/module-api.js
CHANGED
|
@@ -1063,8 +1063,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
1063
1063
|
// landed since 1.19.316 with no explicit bump. run 32669571610. (task 1002620)
|
|
1064
1064
|
// 1.19.318 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1065
1065
|
// landed since 1.19.317 with no explicit bump. run 32669798745. (task 1002620)
|
|
1066
|
+
// 1.19.319 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1067
|
+
// landed since 1.19.318 with no explicit bump. run 32670195088. (task 1002620)
|
|
1068
|
+
// 1.19.320 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1069
|
+
// landed since 1.19.319 with no explicit bump. run 32678147546. (task 1002620)
|
|
1066
1070
|
// ---------------------------------------------------------------------------
|
|
1067
|
-
const CORE_VERSION = '1.19.
|
|
1071
|
+
const CORE_VERSION = '1.19.320'; // CI auto-patch carrier (ADR 0161); see changelog above
|
|
1068
1072
|
|
|
1069
1073
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
1070
1074
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
package/tests/go_live.mjs
CHANGED
|
@@ -296,48 +296,40 @@ test("'could not check' is a distinct halt from 'absent'", () => {
|
|
|
296
296
|
// `env K=V` list that omits it drops it too. This cost a real failed go-live.
|
|
297
297
|
// ---------------------------------------------------------------------------
|
|
298
298
|
|
|
299
|
-
test('a
|
|
300
|
-
const cmd = G.asOwner({ runAs: 'lars', env: { PGDATABASE: 'cb' } }, 'npm install', {
|
|
301
|
-
|
|
302
|
-
//
|
|
303
|
-
//
|
|
304
|
-
// the
|
|
305
|
-
|
|
306
|
-
assert.ok(
|
|
307
|
-
|
|
308
|
-
//
|
|
299
|
+
test('a privileged npm command DERIVES its token — no secret in argv at all', () => {
|
|
300
|
+
const cmd = G.asOwner({ runAs: 'lars', env: { PGDATABASE: 'cb' } }, 'npm install', { needsNpm: true });
|
|
301
|
+
// THE REGRESSION, TWICE (task 1003218). `env NPM_TOKEN="$NPM_TOKEN"` put the value in
|
|
302
|
+
// sudo's COMMAND= field. `--preserve-env=NPM_TOKEN` moved it to sudo's ENV= field —
|
|
303
|
+
// a different field of the SAME log line, which is not a fix. sudo logs its whole
|
|
304
|
+
// invocation, so the only safe answer is that the secret is never part of it.
|
|
305
|
+
assert.ok(!/NPM_TOKEN=npm_/.test(cmd), 'no token VALUE may appear');
|
|
306
|
+
assert.ok(!/--preserve-env/.test(cmd), '--preserve-env logs the value in ENV=');
|
|
307
|
+
assert.ok(!/env [^;]*NPM_TOKEN="\$NPM_TOKEN"/.test(cmd), 'no by-reference assignment in argv either');
|
|
308
|
+
// What SHOULD be there: the child deriving it, and refusing to run on an empty read.
|
|
309
|
+
assert.match(cmd, /_authToken/);
|
|
310
|
+
assert.match(cmd, /EMPTY NPM_TOKEN/);
|
|
311
|
+
// Non-secret config still travels plainly.
|
|
309
312
|
assert.match(cmd, /PGDATABASE=cb/);
|
|
310
313
|
});
|
|
311
314
|
|
|
312
|
-
test('
|
|
313
|
-
//
|
|
314
|
-
//
|
|
315
|
-
assert.
|
|
316
|
-
assert.deepEqual(G.npmForward({}), []);
|
|
315
|
+
test('an empty token ABORTS rather than half-running', () => {
|
|
316
|
+
// An empty token sends npm to the PUBLIC registry, which 404s the private core, which
|
|
317
|
+
// auto-rolls-back "cleanly" — a failure that teaches you nothing. Fail loudly instead.
|
|
318
|
+
assert.match(G.NPM_AUTH_PREAMBLE, /exit 12/);
|
|
317
319
|
});
|
|
318
320
|
|
|
319
|
-
test('
|
|
320
|
-
const cmd = G.asOwner({ runAs: 'me'
|
|
321
|
-
assert.
|
|
322
|
-
assert.ok(!cmd.includes('bad name') && !cmd.includes('$(id)') && !cmd.includes('1BAD'));
|
|
323
|
-
});
|
|
324
|
-
|
|
325
|
-
test('with no privilege drop a secret needs no mention at all', () => {
|
|
326
|
-
// The child already inherits our environment, so naming it would be pure exposure.
|
|
327
|
-
const cmd = G.asOwner({ env: { A: 'b' } }, 'x', { forward: ['NPM_TOKEN'] });
|
|
328
|
-
assert.equal(cmd, 'env A=b x');
|
|
321
|
+
test('a command that does not need npm is left alone', () => {
|
|
322
|
+
const cmd = G.asOwner({ runAs: 'me', env: { PGDATABASE: 'mydb' } }, 'echo hi');
|
|
323
|
+
assert.equal(cmd, 'sudo -u me -H env PGDATABASE=mydb HOME=/home/me echo hi');
|
|
329
324
|
assert.ok(!cmd.includes('NPM_TOKEN'));
|
|
330
325
|
});
|
|
331
326
|
|
|
332
|
-
test('no
|
|
333
|
-
|
|
334
|
-
|
|
327
|
+
test('with no privilege drop the command still derives its own auth', () => {
|
|
328
|
+
const cmd = G.asOwner({ env: { A: 'b' } }, 'npm x', { needsNpm: true });
|
|
329
|
+
assert.ok(!cmd.startsWith('sudo'));
|
|
330
|
+
assert.match(cmd, /_authToken/);
|
|
335
331
|
});
|
|
336
332
|
|
|
337
|
-
// ---------------------------------------------------------------------------
|
|
338
|
-
// A failed run must say what the tool said
|
|
339
|
-
// ---------------------------------------------------------------------------
|
|
340
|
-
|
|
341
333
|
test('the report surfaces the failing tool’s own words', () => {
|
|
342
334
|
const out = G.renderReport({
|
|
343
335
|
ok: false, failedAt: 'UPGRADE', targetName: 'web', from: '1.19.305', to: '1.19.306',
|
|
@@ -378,3 +370,44 @@ test('a spawn error is handled, not thrown', () => {
|
|
|
378
370
|
const r = G.refreshMain({ run: () => ({ error: new Error('ENOENT'), status: null }) });
|
|
379
371
|
assert.equal(r.ok, false);
|
|
380
372
|
});
|
|
373
|
+
|
|
374
|
+
// ---------------------------------------------------------------------------
|
|
375
|
+
// Local execution — one engine, two callers (task 1003219)
|
|
376
|
+
//
|
|
377
|
+
// The nightly lane runs ON the box, so it cannot reach the deploy path through ssh.
|
|
378
|
+
// Without a local mode it would need a second implementation, and the two would
|
|
379
|
+
// drift — which is exactly how the unattended deploy ended up with WEAKER checks
|
|
380
|
+
// than the manual one (no backup, no timer guard, no independent read-back).
|
|
381
|
+
// ---------------------------------------------------------------------------
|
|
382
|
+
|
|
383
|
+
test('a target with no ssh host is local; one with an ssh host is not', () => {
|
|
384
|
+
const base = { instanceDir: '/i', service: 's.service', healthUrl: 'http://h', versionUrl: 'http://v', deployTimer: 'd.timer' };
|
|
385
|
+
assert.equal(G.isLocalTarget(base), true);
|
|
386
|
+
assert.equal(G.isLocalTarget({ ...base, ssh: 'my-box' }), false);
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
test('ssh is OPTIONAL — a local target validates clean', () => {
|
|
390
|
+
const local = { instanceDir: '/i', service: 's.service', healthUrl: 'http://h', versionUrl: 'http://v', deployTimer: 'd.timer' };
|
|
391
|
+
assert.deepEqual(G.validateTarget('local', local), []);
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
test('the remaining required fields are still enforced for a local target', () => {
|
|
395
|
+
const errs = G.validateTarget('local', { instanceDir: '/i' });
|
|
396
|
+
for (const f of ['service', 'healthUrl', 'versionUrl', 'deployTimer']) {
|
|
397
|
+
assert.match(errs.join(' '), new RegExp(`missing required field "${f}"`));
|
|
398
|
+
}
|
|
399
|
+
// ssh must NOT be demanded any more.
|
|
400
|
+
assert.ok(!/missing required field "ssh"/.test(errs.join(' ')));
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
test('`local` is a boolean flag, not a shell token', () => {
|
|
404
|
+
const base = { instanceDir: '/i', service: 's.service', healthUrl: 'http://h', versionUrl: 'http://v', deployTimer: 'd.timer' };
|
|
405
|
+
assert.deepEqual(G.validateTarget('t', { ...base, local: true }), []);
|
|
406
|
+
assert.match(G.validateTarget('t', { ...base, local: 'yes' }).join(' '), /must be true or false/);
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
test('gitRefExists reports rather than throwing', () => {
|
|
410
|
+
assert.equal(G.gitRefExists('x', { run: () => ({ status: 0 }) }), true);
|
|
411
|
+
assert.equal(G.gitRefExists('x', { run: () => ({ status: 1 }) }), false);
|
|
412
|
+
assert.equal(G.gitRefExists('x', { run: () => ({ error: new Error('ENOENT') }) }), false);
|
|
413
|
+
});
|
package/tests/helpers.mjs
CHANGED
|
@@ -142,3 +142,16 @@ export function lifecycleDbSource(modulesDir = path.join(ROOT, 'modules')) {
|
|
|
142
142
|
.sort();
|
|
143
143
|
return files.map((f) => fs.readFileSync(path.join(dir, f), 'utf8')).join('\n');
|
|
144
144
|
}
|
|
145
|
+
|
|
146
|
+
// shipCliSource — the ship CLI as one source string.
|
|
147
|
+
//
|
|
148
|
+
// The companion to lifecycleDbSource above, for the same reason: task 1003205
|
|
149
|
+
// carved the 4,613-line scripts/gds/ship.js into ship.js (a re-export facade +
|
|
150
|
+
// the CLI entry point) plus ship-*.js phase modules. Tests that assert "the ship
|
|
151
|
+
// CLI does X" by regexing its source mean the CLI as a whole, not one file.
|
|
152
|
+
export function shipCliSource(scriptsDir = path.join(ROOT, 'scripts', 'gds')) {
|
|
153
|
+
const files = fs.readdirSync(scriptsDir)
|
|
154
|
+
.filter((f) => f === 'ship.js' || (f.startsWith('ship-') && f.endsWith('.js')))
|
|
155
|
+
.sort();
|
|
156
|
+
return files.map((f) => fs.readFileSync(path.join(scriptsDir, f), 'utf8')).join('\n');
|
|
157
|
+
}
|
package/tests/provision.mjs
CHANGED
|
@@ -144,10 +144,16 @@ t('buildCorePinRefreshCommit (the commit step): git sequence, token-URL push, an
|
|
|
144
144
|
root: '/srv/bongos/spike-harbor', fromVersion: '1.19.2', toVersion: '1.19.196',
|
|
145
145
|
repo, ownerToken: 'ghs_xxx', credFile: '/tmp/cred',
|
|
146
146
|
});
|
|
147
|
+
// task 1002757: the bot email is RESOLVED (env → instance apex → neutral
|
|
148
|
+
// .invalid), never the hub's own address hardcoded — a self-hosted provisioner
|
|
149
|
+
// must not attribute its commits to the hub. Assert against the resolver so
|
|
150
|
+
// this pins the behaviour, not one instance's literal.
|
|
147
151
|
assert.deepEqual(withTok.config, [
|
|
148
152
|
'git -C /srv/bongos/spike-harbor config user.name "Cloud Bongos"',
|
|
149
|
-
|
|
153
|
+
`git -C /srv/bongos/spike-harbor config user.email "${P.provisionerBotEmail()}"`,
|
|
150
154
|
]);
|
|
155
|
+
assert.equal(P.provisionerBotEmail({ PROVISION_BOT_EMAIL: 'ops@acme.test' }), 'ops@acme.test', 'env override wins');
|
|
156
|
+
assert.doesNotMatch(P.provisionerBotEmail({}), /amazonprimea/i, 'never a founder-host address');
|
|
151
157
|
assert.equal(withTok.add, 'git -C /srv/bongos/spike-harbor add -A');
|
|
152
158
|
assert.match(withTok.commit, /@bongos\/core 1\.19\.2 -> 1\.19\.196 \(task 1003050\)/);
|
|
153
159
|
assert.match(withTok.push, /store --file=\/tmp\/cred.*x-access-token@github\.com\/jaxri\/spike-harbor\.git HEAD:main/);
|
|
@@ -198,5 +198,48 @@ test('buildGradePayload confidence defaults to null when signals omit it', () =>
|
|
|
198
198
|
assert.equal(body.subagent_output.confidence, null);
|
|
199
199
|
});
|
|
200
200
|
|
|
201
|
+
// ---------- task 1003221: don't prescribe a command the caller can't run ----------
|
|
202
|
+
//
|
|
203
|
+
// The 'confirmed' answer hardcoded "run /merge-mode", which lands by pushing main
|
|
204
|
+
// via push-main.js (SEC #863 / ADR 0043) and needs an authenticated gh plus a main
|
|
205
|
+
// checkout. A server-mediated builder (pushVia()==='server' — every dev box) has
|
|
206
|
+
// neither, so the advice was unreachable by construction. It cost a real builder
|
|
207
|
+
// 163 minutes on 2026-08-21. strandNextStep already had the right wording for both
|
|
208
|
+
// modes; these pin that the two modes now diverge and that neither regresses.
|
|
209
|
+
|
|
210
|
+
test('1003221 a server-mediated builder is NOT told to run /merge-mode', () => {
|
|
211
|
+
const v = evaluateRegradeEligibility({
|
|
212
|
+
task: { id: 601, title: 't', status: 'confirmed' },
|
|
213
|
+
lastClaim: null,
|
|
214
|
+
builder: { id: 7, rank: 'archon' },
|
|
215
|
+
pushMode: 'server',
|
|
216
|
+
});
|
|
217
|
+
assert.equal(v.ok, false);
|
|
218
|
+
assert.match(v.reason, /already 'confirmed'/, 'still says why a re-grade is refused');
|
|
219
|
+
assert.doesNotMatch(v.reason, /run \/merge-mode/, 'must not prescribe the unreachable command');
|
|
220
|
+
assert.match(v.reason, /ship\.js 601/, 'names the command that DOES work here');
|
|
221
|
+
assert.match(v.reason, /NOT available here/, 'and says plainly why /merge-mode is out');
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test('1003221 a laptop builder still gets /merge-mode (no regression)', () => {
|
|
225
|
+
const v = evaluateRegradeEligibility({
|
|
226
|
+
task: { id: 602, title: 't', status: 'confirmed' },
|
|
227
|
+
lastClaim: null,
|
|
228
|
+
builder: { id: 7, rank: 'archon' },
|
|
229
|
+
pushMode: 'local',
|
|
230
|
+
});
|
|
231
|
+
assert.match(v.reason, /\/merge-mode/, 'the laptop path keeps its working advice');
|
|
232
|
+
assert.match(v.reason, /ship\.js 602/, 'and still offers the resume alternative');
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test('1003221 omitting pushMode keeps the historical wording (backward compatible)', () => {
|
|
236
|
+
const v = evaluateRegradeEligibility({
|
|
237
|
+
task: { id: 603, title: 't', status: 'confirmed' },
|
|
238
|
+
lastClaim: null,
|
|
239
|
+
builder: { id: 7, rank: 'archon' },
|
|
240
|
+
});
|
|
241
|
+
assert.match(v.reason, /\/merge-mode/, 'an old caller/test that passes no pushMode is unchanged');
|
|
242
|
+
});
|
|
243
|
+
|
|
201
244
|
console.log(`\n--regrade eligibility (#678) tests: ${passed} passed, ${failed} failed`);
|
|
202
245
|
process.exit(failed > 0 ? 1 : 0);
|
package/tests/ship_assurance.mjs
CHANGED
|
@@ -33,6 +33,7 @@ import { createRequire } from 'node:module';
|
|
|
33
33
|
import fs from 'node:fs';
|
|
34
34
|
import path from 'node:path';
|
|
35
35
|
import url from 'node:url';
|
|
36
|
+
import { shipCliSource } from './helpers.mjs';
|
|
36
37
|
|
|
37
38
|
const require = createRequire(import.meta.url);
|
|
38
39
|
const ROOT = path.dirname(path.dirname(url.fileURLToPath(import.meta.url)));
|
|
@@ -178,8 +179,9 @@ await t('the CLI carries the declaration through its deliberate-empty path', ()
|
|
|
178
179
|
// Without this, the gate would refuse exactly the --db-only / --allow-empty
|
|
179
180
|
// flow CLAUDE.md calls a supported shape — turning a correctness fix into a
|
|
180
181
|
// regression for the builders who rely on it.
|
|
181
|
-
|
|
182
|
-
|
|
182
|
+
// task 1003205: the ship CLI is ship.js + ship-*.js now — read the family.
|
|
183
|
+
const src = shipCliSource();
|
|
184
|
+
assert.match(src, /no_artifact:\s*true/, 'the ship CLI must send the declaration on an empty land');
|
|
183
185
|
assert.match(src, /landed\(\{ noArtifact: true/,
|
|
184
186
|
'and the deliberate-empty land sites must mark themselves as such');
|
|
185
187
|
});
|
|
@@ -115,5 +115,48 @@ test('the "non-fast-forward" wording triggers the same hint through the envelope
|
|
|
115
115
|
});
|
|
116
116
|
|
|
117
117
|
console.log('');
|
|
118
|
+
// ---------- task 1003221: a non-fast-forward has TWO causes ----------
|
|
119
|
+
//
|
|
120
|
+
// The hint used to give ONE diagnosis — "the server already moved <branch>" — and
|
|
121
|
+
// prescribe `reset onto origin/<branch>`. That is right when the server is ahead
|
|
122
|
+
// and DESTRUCTIVE when it is not: a builder who rebased or amended an
|
|
123
|
+
// already-published branch would be told to throw away the commits they just made.
|
|
124
|
+
// That was the real 2026-08-21 case. opts.localHistoryRewritten discriminates;
|
|
125
|
+
// null (git could not tell) must keep the original wording rather than guess.
|
|
126
|
+
const NFF = {
|
|
127
|
+
error: { code: 'publish_failed', message: 'publish_failed',
|
|
128
|
+
details: { step: 'push', reason: '! [rejected] main -> main (non-fast-forward)' } },
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
test('1003221 rewritten local history is told to MERGE, never reset', () => {
|
|
132
|
+
const out = ship.formatPublishFailure(NFF, 500, 'claude/x', { localHistoryRewritten: true });
|
|
133
|
+
assert.match(out, /APPEND-ONLY/);
|
|
134
|
+
assert.match(out, /git merge origin\/claude\/x/, 'names the merge that actually recovers');
|
|
135
|
+
assert.match(out, /Do NOT reset or re-rebase/, 'and forbids the lossy move explicitly');
|
|
136
|
+
assert.doesNotMatch(out, /reset onto/, 'must not carry the server-ahead remedy');
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test('1003221 a server-ahead non-fast-forward keeps the reset remedy', () => {
|
|
140
|
+
const out = ship.formatPublishFailure(NFF, 500, 'claude/x', { localHistoryRewritten: false });
|
|
141
|
+
assert.match(out, /the server already moved claude\/x/);
|
|
142
|
+
assert.match(out, /reset onto origin\/claude\/x/);
|
|
143
|
+
assert.doesNotMatch(out, /APPEND-ONLY/);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test('1003221 an UNKNOWN cause (null) does not invent a diagnosis', () => {
|
|
147
|
+
const unknown = ship.formatPublishFailure(NFF, 500, 'claude/x', { localHistoryRewritten: null });
|
|
148
|
+
const legacy = ship.formatPublishFailure(NFF, 500, 'claude/x');
|
|
149
|
+
assert.equal(unknown, legacy, 'null must be byte-identical to the pre-fix wording');
|
|
150
|
+
assert.match(unknown, /reset onto/);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test('1003221 a non-NFF failure gains no hint from either flag', () => {
|
|
154
|
+
const other = { error: { code: 'publish_failed', details: { step: 'bundle', reason: 'bad object' } } };
|
|
155
|
+
for (const v of [true, false, null]) {
|
|
156
|
+
const out = ship.formatPublishFailure(other, 500, 'claude/x', { localHistoryRewritten: v });
|
|
157
|
+
assert.doesNotMatch(out, /APPEND-ONLY|reset onto/, `flag ${v} must not add a remedy to an unrelated failure`);
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
|
|
118
161
|
console.log(`ship_publish_error: ${passed} passed, ${failed} failed`);
|
|
119
162
|
process.exit(failed === 0 ? 0 : 1);
|
package/tests/update_channel.mjs
CHANGED
|
@@ -184,3 +184,36 @@ t('loadSubscriptions: an entry without a dir is dropped (cannot be upgraded)', (
|
|
|
184
184
|
|
|
185
185
|
console.log(`\nupdate_channel: ${passed} passed, ${failed} failed`);
|
|
186
186
|
process.exit(failed ? 1 : 0);
|
|
187
|
+
|
|
188
|
+
// ---------------------------------------------------------------------------
|
|
189
|
+
// Deploy topology survives normalization (task 1003219)
|
|
190
|
+
//
|
|
191
|
+
// The sweep can only hand a bump to go-live.js — and so inherit its backup, its
|
|
192
|
+
// pull-deploy-timer guard and its independent box-side read-back — if the roster's
|
|
193
|
+
// topology actually REACHES the entry. The first cut of this dropped the new fields
|
|
194
|
+
// on the floor, so an instance that named them looked enrolled while the sweep
|
|
195
|
+
// quietly kept using the weaker direct path. A normalizer that silently discards a
|
|
196
|
+
// field is indistinguishable from a feature that does not work.
|
|
197
|
+
// ---------------------------------------------------------------------------
|
|
198
|
+
|
|
199
|
+
test('deploy topology is carried through loadSubscriptions', () => {
|
|
200
|
+
const entry = {
|
|
201
|
+
slug: 's', dir: '/i', service: 'x.service', healthUrl: 'http://h',
|
|
202
|
+
versionUrl: 'http://v', deployTimer: 'd.timer', deployService: 'd.service',
|
|
203
|
+
backupDb: 'db', backupDir: '~/b', registryPackage: '@scope/core',
|
|
204
|
+
};
|
|
205
|
+
const fsImpl = { readFileSync: () => JSON.stringify({ instances: [entry] }) };
|
|
206
|
+
const { instances } = loadSubscriptions({ instanceDir: '/x', fsImpl });
|
|
207
|
+
assert.equal(instances.length, 1);
|
|
208
|
+
for (const k of ['versionUrl', 'deployTimer', 'deployService', 'backupDb', 'backupDir', 'registryPackage']) {
|
|
209
|
+
assert.equal(instances[0][k], entry[k], `${k} must survive normalization`);
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test('an entry that names no topology reports null, not undefined', () => {
|
|
214
|
+
const fsImpl = { readFileSync: () => JSON.stringify({ instances: [{ slug: 's', dir: '/i' }] }) };
|
|
215
|
+
const { instances } = loadSubscriptions({ instanceDir: '/x', fsImpl });
|
|
216
|
+
for (const k of ['versionUrl', 'deployTimer', 'deployService', 'backupDb', 'backupDir']) {
|
|
217
|
+
assert.equal(instances[0][k], null, `${k} should be an explicit null`);
|
|
218
|
+
}
|
|
219
|
+
});
|