@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.
@@ -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
+ };
@@ -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
- const notes = arg('--notes', args) || '';
3703
- const summary = arg('--summary', args) || '';
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 summaryArg = arg('--summary', args) || '';
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 = arg('--response', args) || '';
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.306'; // CI auto-patch carrier (ADR 0161); see changelog above
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
+ });