@bongos/core 1.19.347 → 1.19.349
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 +33 -23
- package/docs/copy-inventory.md +22 -22
- package/docs/copy-registry.json +25 -25
- package/modules/hall-ui/public/idea.js +9 -1
- package/modules/hall-ui/public/ideas.js +17 -5
- package/modules/lifecycle/github-push.js +51 -4
- package/modules/lifecycle/routes/tasks.js +22 -2
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/client-baseurl-guard.js +116 -0
- package/scripts/gds/deploy-divergence.js +34 -3
- package/scripts/gds/fitness.js +1 -0
- package/scripts/gds/ship-land.js +24 -2
- package/scripts/gds/update-channel.js +121 -5
- package/src/module-api.js +5 -1
- package/tests/client_baseurl_guard.mjs +122 -0
- package/tests/idea_hall_composer.mjs +11 -0
- package/tests/ship_publish_error.mjs +86 -2
- package/tests/update_channel.mjs +125 -0
|
@@ -130,6 +130,23 @@ function scrubCredential(s) {
|
|
|
130
130
|
.replace(/(ghs_|ghp_|github_pat_)[A-Za-z0-9_]+/g, '$1***');
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
+
// How much scrubbed stderr travels with a failure (task 1003222). Sized to hold a
|
|
134
|
+
// real git failure whole — the 'remote:' lines that name the cause arrive LAST, so a
|
|
135
|
+
// short cap deletes precisely the part worth reading. It is a payload bound, nothing
|
|
136
|
+
// more; when it bites, truncateLoudly says so rather than ending mid-word.
|
|
137
|
+
const STDERR_MAX = 8000;
|
|
138
|
+
|
|
139
|
+
// Truncate in a way the reader can SEE. A silent clip is worse than no message: it
|
|
140
|
+
// looks like the complete text, which is how 'error: failed' got mistaken for the
|
|
141
|
+
// whole story. Keeps the TAIL as well as the head, because git's useful line is last.
|
|
142
|
+
function truncateLoudly(text, max = STDERR_MAX) {
|
|
143
|
+
const s = String(text || '');
|
|
144
|
+
if (s.length <= max) return s;
|
|
145
|
+
const head = Math.floor(max * 0.4);
|
|
146
|
+
const tail = max - head;
|
|
147
|
+
return `${s.slice(0, head)}\n\n…[${s.length - max} characters omitted — full text in the server log]…\n\n${s.slice(-tail)}`;
|
|
148
|
+
}
|
|
149
|
+
|
|
133
150
|
// Default git seam: execFile arg-array, shell:false, stdout captured. On failure
|
|
134
151
|
// throws GIT_FAILED. We never echo the (possibly credentialed) argv, but DO attach
|
|
135
152
|
// the credential-scrubbed stderr + the caller's step label (task 1547) so a real
|
|
@@ -149,9 +166,30 @@ async function defaultExec(args, opts = {}) {
|
|
|
149
166
|
return stdout;
|
|
150
167
|
} catch (e) {
|
|
151
168
|
// Identify the failing step by the caller's label, never by echoing argv.
|
|
152
|
-
//
|
|
153
|
-
|
|
154
|
-
|
|
169
|
+
//
|
|
170
|
+
// THE STDERR IS NOT CLIPPED SHORT ANY MORE (task 1003222). It used to be cut at
|
|
171
|
+
// 500 chars here and again at 200 on the way to the client, so a builder saw
|
|
172
|
+
// 'error: failed' — clipped mid-word, naming neither cause nor remedy — while the
|
|
173
|
+
// line that mattered ('remote: fatal error in commit_refs') and the exit code
|
|
174
|
+
// existed only server-side. That cost hours on the blocker-1000117 outage AND
|
|
175
|
+
// produced a WRONG first diagnosis, because the real message was never in the
|
|
176
|
+
// room. git puts the useful line LAST (the 'remote:' lines follow the progress
|
|
177
|
+
// chatter), which is exactly what a head-truncation throws away.
|
|
178
|
+
//
|
|
179
|
+
// The cap is a payload guard, not an editorial decision: generous enough that a
|
|
180
|
+
// real push failure fits whole, and when it does bite the text SAYS SO instead of
|
|
181
|
+
// ending mid-word and reading like the whole message.
|
|
182
|
+
const raw = scrubCredential(e && (e.stderr || e.message)).trim();
|
|
183
|
+
const stderr = truncateLoudly(raw, STDERR_MAX);
|
|
184
|
+
throw err('GIT_FAILED', {
|
|
185
|
+
git_step: step || (args && args[0]) || null,
|
|
186
|
+
stderr,
|
|
187
|
+
stderr_bytes: raw.length,
|
|
188
|
+
// The exit code distinguishes failure SHAPES that read identically in prose —
|
|
189
|
+
// 128 (git said no) vs 1 (a ref was rejected) vs null (killed by the timeout).
|
|
190
|
+
exit_code: (e && typeof e.code === 'number') ? e.code : null,
|
|
191
|
+
timed_out: !!(e && e.killed),
|
|
192
|
+
});
|
|
155
193
|
}
|
|
156
194
|
}
|
|
157
195
|
|
|
@@ -456,7 +494,14 @@ async function openOrFindPullRequest({ branch, title, body }, deps = {}) {
|
|
|
456
494
|
});
|
|
457
495
|
if (!createRes.ok) {
|
|
458
496
|
const detail = await createRes.text().catch(() => '');
|
|
459
|
-
|
|
497
|
+
// Same reasoning as GIT_FAILED's stderr (task 1003222): GitHub's refusal body
|
|
498
|
+
// names the actual rule that refused ('No commits between main and X', a
|
|
499
|
+
// protected-branch rule, a validation error naming the field) and a 300-char
|
|
500
|
+
// clip regularly cut it off. Scrubbed, because a body can echo a request header.
|
|
501
|
+
throw err('PR_CREATE_FAILED', {
|
|
502
|
+
status: createRes.status,
|
|
503
|
+
detail: truncateLoudly(scrubCredential(detail), STDERR_MAX),
|
|
504
|
+
});
|
|
460
505
|
}
|
|
461
506
|
const pr = await createRes.json();
|
|
462
507
|
return { number: pr.number, url: pr.html_url, node_id: pr.node_id };
|
|
@@ -1360,6 +1405,8 @@ module.exports = {
|
|
|
1360
1405
|
prReferencesTask,
|
|
1361
1406
|
findTaskPullRequest,
|
|
1362
1407
|
scrubCredential,
|
|
1408
|
+
truncateLoudly, // task 1003222 — exported so its behaviour is testable, not just its effect
|
|
1409
|
+
STDERR_MAX,
|
|
1363
1410
|
isAncestorOfDefaultBranch,
|
|
1364
1411
|
resolveToken,
|
|
1365
1412
|
getInstallationToken,
|
|
@@ -979,7 +979,12 @@ module.exports = function buildTasksRouter() {
|
|
|
979
979
|
if (e.code === 'NO_REPO_INFO') return res.fail('no_repo_info', 500);
|
|
980
980
|
if (e.code === 'PUSH_UNCONFIGURED') return res.fail('push_unconfigured', { status: 503, message: PUSH_UNCONFIGURED_MSG });
|
|
981
981
|
if (e.code === 'APP_NOT_INSTALLED') return res.fail('push_unconfigured', { status: 503, message: appNotInstalledMessage(e) });
|
|
982
|
-
|
|
982
|
+
// The GitHub HTTP status travels WITH the body (task 1003222). 422 ('no commits
|
|
983
|
+
// between'), 403 (a protected-branch rule) and 404 (the App cannot see the repo)
|
|
984
|
+
// are three different remedies, and the body alone does not always separate them.
|
|
985
|
+
if (e.code === 'PR_CREATE_FAILED') {
|
|
986
|
+
return res.fail('pr_create_failed', 502, { detail: e.detail, github_status: e.status ?? null });
|
|
987
|
+
}
|
|
983
988
|
// task 1547: GIT_FAILED now carries WHICH git step failed + the credential-
|
|
984
989
|
// scrubbed stderr (github-push.defaultExec). Log both so the next push outage
|
|
985
990
|
// is a one-line diagnosis, not a multi-hour opaque-`publish_failed` mystery
|
|
@@ -990,7 +995,22 @@ module.exports = function buildTasksRouter() {
|
|
|
990
995
|
e.git_step ? `step=${e.git_step}` : '',
|
|
991
996
|
e.stderr ? `stderr=${e.stderr}` : ''
|
|
992
997
|
);
|
|
993
|
-
|
|
998
|
+
// NO SECOND TRUNCATION (task 1003222). This line used to clip the already-
|
|
999
|
+
// shortened stderr to 200 chars, so the builder got 'error: failed' — cut
|
|
1000
|
+
// mid-word, naming neither cause nor remedy — while the real remote message sat
|
|
1001
|
+
// in a log they could not reach. A publish failure must be diagnosable from
|
|
1002
|
+
// where the builder is STANDING; that is the whole point of the task.
|
|
1003
|
+
//
|
|
1004
|
+
// `reason` keeps its name and position so existing readers (ship.js prints it)
|
|
1005
|
+
// keep working; it is simply no longer amputated. Everything here is already
|
|
1006
|
+
// credential-scrubbed at the throw site by github-push.scrubCredential.
|
|
1007
|
+
return res.fail('publish_failed', 500, {
|
|
1008
|
+
...(e.git_step ? { step: e.git_step } : {}),
|
|
1009
|
+
...(e.stderr ? { reason: e.stderr } : {}),
|
|
1010
|
+
...(e.stderr_bytes != null ? { reason_bytes: e.stderr_bytes } : {}),
|
|
1011
|
+
...(e.exit_code != null ? { git_exit_code: e.exit_code } : {}),
|
|
1012
|
+
...(e.timed_out ? { timed_out: true } : {}),
|
|
1013
|
+
});
|
|
994
1014
|
} finally {
|
|
995
1015
|
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) { /* best-effort */ }
|
|
996
1016
|
}
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.349",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.349",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.349",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// scripts/gds/client-baseurl-guard.js — a web page must not send its API calls to
|
|
3
|
+
// the site root (task 1003263).
|
|
4
|
+
//
|
|
5
|
+
// THE BUG THIS EXISTS TO STOP. The generated client (clients/bongos-client) exposes
|
|
6
|
+
// two call styles, and mixing them is silent and fatal:
|
|
7
|
+
//
|
|
8
|
+
// NAMESPACED api.inbox.getInboxTemplate({}) → path template '/inbox/template',
|
|
9
|
+
// API-RELATIVE, needs the API base
|
|
10
|
+
// ESCAPE HATCH api.request('GET', `${API}/me`) → caller supplies the FULL path
|
|
11
|
+
//
|
|
12
|
+
// createClient documents `baseUrl: ''` as "paths are already absolute" and passes
|
|
13
|
+
// them through unchanged. So a page that sets baseUrl:'' AND calls a namespaced
|
|
14
|
+
// method requests '/inbox/template' — the SITE ROOT, where nothing is mounted — and
|
|
15
|
+
// 404s on every call. The router mounts at '/api/bongos/v1' (api-prefix.js
|
|
16
|
+
// VERSIONED_API_PREFIX), which is exactly the client's own default.
|
|
17
|
+
//
|
|
18
|
+
// WHY A FITNESS CHECK AND NOT A TEST. Three pages shipped this defect — ideas.js and
|
|
19
|
+
// idea.js were dead in production from the day they shipped, and collab.js was caught
|
|
20
|
+
// in review one commit before it would have been. Every page test stubs the client
|
|
21
|
+
// method directly, so URL construction is never exercised; tests/idea_hall_composer.mjs
|
|
22
|
+
// even asserted with a regex against the source text, which passes happily on a broken
|
|
23
|
+
// URL. 54 green tests over pages that 404 on every call. The defect is invisible to the
|
|
24
|
+
// test style this repo uses for hall pages, so it needs a guard at a different altitude.
|
|
25
|
+
//
|
|
26
|
+
// STATIC AND CONSERVATIVE. It flags only the unambiguous combination — an explicit
|
|
27
|
+
// empty baseUrl in a createClient call, in a file that also makes a namespaced call.
|
|
28
|
+
// A page using the escape hatch with baseUrl:'' is CORRECT and stays silent (work.js,
|
|
29
|
+
// copy-desk.js, goal-inbox.js and most of the hall do exactly that).
|
|
30
|
+
|
|
31
|
+
'use strict';
|
|
32
|
+
|
|
33
|
+
const fs = require('node:fs');
|
|
34
|
+
const path = require('node:path');
|
|
35
|
+
const { execFileSync } = require('node:child_process');
|
|
36
|
+
|
|
37
|
+
const REPO_ROOT = path.resolve(__dirname, '..', '..');
|
|
38
|
+
const NAME = 'a web page sends its API calls to the API, not the site root (task 1003263)';
|
|
39
|
+
|
|
40
|
+
// createClient({ ... baseUrl: '' ... }) — an EXPLICIT empty base. Omitting baseUrl is
|
|
41
|
+
// the correct fix and must not match; only the literal empty string is the defect.
|
|
42
|
+
const EMPTY_BASEURL = /createClient\(\s*\{[^}]*baseUrl\s*:\s*(''|""|`\s*`)/;
|
|
43
|
+
|
|
44
|
+
// api.<namespace>.<method>( — the generated namespaced style. `api.request(` is the
|
|
45
|
+
// escape hatch and is deliberately NOT matched: with it, baseUrl:'' is correct.
|
|
46
|
+
const NAMESPACED_CALL = /\bapi\s*\.\s*(?!request\b)[A-Za-z_$][\w$]*\s*\.\s*[A-Za-z_$][\w$]*\s*\(/;
|
|
47
|
+
|
|
48
|
+
// Every tracked browser-served script. Server code never calls createClient this way.
|
|
49
|
+
function trackedPageScripts() {
|
|
50
|
+
try {
|
|
51
|
+
const out = execFileSync('git', ['ls-files', 'modules/*/public/*.js', 'public/*.js'],
|
|
52
|
+
{ cwd: REPO_ROOT, encoding: 'utf8' });
|
|
53
|
+
return out.split('\n').map((s) => s.trim()).filter(Boolean);
|
|
54
|
+
} catch {
|
|
55
|
+
return [];
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function scanPages(files, read) {
|
|
60
|
+
const violations = [];
|
|
61
|
+
let scanned = 0;
|
|
62
|
+
let usingClient = 0;
|
|
63
|
+
for (const rel of files) {
|
|
64
|
+
const src = read(rel);
|
|
65
|
+
if (src == null) continue;
|
|
66
|
+
scanned += 1;
|
|
67
|
+
if (!/createClient\(/.test(src)) continue;
|
|
68
|
+
usingClient += 1;
|
|
69
|
+
if (!EMPTY_BASEURL.test(src)) continue;
|
|
70
|
+
if (!NAMESPACED_CALL.test(src)) continue; // escape-hatch page — baseUrl:'' is right
|
|
71
|
+
const m = NAMESPACED_CALL.exec(src);
|
|
72
|
+
violations.push(
|
|
73
|
+
`${rel} sets baseUrl:'' AND calls the generated client (${m[0].replace(/\s+/g, '')}…). `
|
|
74
|
+
+ "Those paths are API-relative, so baseUrl:'' sends them to the site root and every "
|
|
75
|
+
+ 'call 404s. Drop the baseUrl override, or switch every call to '
|
|
76
|
+
+ "api.request('METHOD', `${API}/…`) with a full path.",
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
return { violations, scanned, usingClient };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function checkClientBaseUrl({ files, read } = {}) {
|
|
83
|
+
const list = files || trackedPageScripts();
|
|
84
|
+
if (!list.length) {
|
|
85
|
+
return {
|
|
86
|
+
name: NAME, ok: false, hardFail: true,
|
|
87
|
+
violations: ['scan defect — enumerated 0 tracked page scripts; a broken enumeration, not a clean result.'],
|
|
88
|
+
warnings: [], note: 'static scan of browser-served scripts for a root-bound API client.',
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
const reader = read || ((rel) => {
|
|
92
|
+
try { return fs.readFileSync(path.join(REPO_ROOT, rel), 'utf8'); } catch { return null; }
|
|
93
|
+
});
|
|
94
|
+
const { violations, scanned, usingClient } = scanPages(list, reader);
|
|
95
|
+
return {
|
|
96
|
+
name: NAME,
|
|
97
|
+
ok: violations.length === 0,
|
|
98
|
+
hardFail: violations.length > 0,
|
|
99
|
+
violations,
|
|
100
|
+
warnings: [],
|
|
101
|
+
note: `${scanned} browser-served script(s) scanned, ${usingClient} building a client: none may combine `
|
|
102
|
+
+ "baseUrl:'' with a generated namespaced call (api.<ns>.<method>()) — that pairing 404s every request. "
|
|
103
|
+
+ "baseUrl:'' with api.request() and a full path is the correct escape-hatch style and is not flagged.",
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function main() {
|
|
108
|
+
const r = checkClientBaseUrl();
|
|
109
|
+
for (const v of r.violations) console.error(`✗ ${v}`);
|
|
110
|
+
console.log(`${r.hardFail ? 'FAIL' : 'PASS'} ${r.note}`);
|
|
111
|
+
process.exit(r.hardFail ? 1 : 0);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (require.main === module) main();
|
|
115
|
+
|
|
116
|
+
module.exports = { checkClientBaseUrl, scanPages, trackedPageScripts, EMPTY_BASEURL, NAMESPACED_CALL };
|
|
@@ -160,6 +160,28 @@ async function fetchLive(base, timeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
|
160
160
|
}
|
|
161
161
|
}
|
|
162
162
|
|
|
163
|
+
// Is the automation that should have PREVENTED this divergence itself alive? (task 1003158)
|
|
164
|
+
//
|
|
165
|
+
// This script detects the SYMPTOM — live is behind main — and only ever at a moment a human is
|
|
166
|
+
// already looking. What went unwatched is the auto-upgrade subscription itself: when it stopped,
|
|
167
|
+
// nothing said so, and cloudbongos.com sat 36 releases behind for days. Reading the roster here means
|
|
168
|
+
// a dead upgrader is reported in the same breath as the staleness it was supposed to prevent.
|
|
169
|
+
//
|
|
170
|
+
// Speaks ONLY when a subscription manifest is actually present. An ordinary core checkout has none by
|
|
171
|
+
// design (ADR 0136 — it ships empty), and alarming about that every session is exactly the noise that
|
|
172
|
+
// gets a real warning scrolled past. Present-but-broken, or present-and-subscribing-nothing, is the
|
|
173
|
+
// signal worth having: someone meant to enroll instances here and none are being upgraded.
|
|
174
|
+
function subscriptionHealth(dir = REPO_ROOT) {
|
|
175
|
+
try {
|
|
176
|
+
const uc = require('./update-channel');
|
|
177
|
+
const roster = uc.loadSubscriptions({ instanceDir: dir });
|
|
178
|
+
if (!roster.present) return null;
|
|
179
|
+
return uc.describeRoster(roster, { controlPlane: true });
|
|
180
|
+
} catch {
|
|
181
|
+
return null; // never let the health probe break the divergence check it rides along with
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
163
185
|
async function check({ base, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
|
|
164
186
|
const target = base || arg('--url') || API_BASE;
|
|
165
187
|
const local = coreVersionAt(null);
|
|
@@ -173,7 +195,9 @@ async function check({ base, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
|
|
|
173
195
|
const unreleased = unreleasedSince(mainRef);
|
|
174
196
|
const live = await fetchLive(target, timeoutMs);
|
|
175
197
|
|
|
176
|
-
|
|
198
|
+
const subscriptions = subscriptionHealth();
|
|
199
|
+
|
|
200
|
+
if (live.error) return { ok: false, undetermined: true, reason: `could not read ${live.url}: ${live.error}`, target, local, onMain, unreleased, subscriptions };
|
|
177
201
|
|
|
178
202
|
const behind = cmpSemver(live.coreVersion, onMain) < 0;
|
|
179
203
|
const ahead = cmpSemver(live.coreVersion, onMain) > 0;
|
|
@@ -181,7 +205,7 @@ async function check({ base, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
|
|
|
181
205
|
|
|
182
206
|
return {
|
|
183
207
|
ok: !behind && !hasUnreleased,
|
|
184
|
-
target, local, onMain, mainRef, unreleased, live, behind, ahead, hasUnreleased,
|
|
208
|
+
target, local, onMain, mainRef, unreleased, live, behind, ahead, hasUnreleased, subscriptions,
|
|
185
209
|
};
|
|
186
210
|
}
|
|
187
211
|
|
|
@@ -232,6 +256,13 @@ function report(r, { quiet = false } = {}) {
|
|
|
232
256
|
if (r.ahead) {
|
|
233
257
|
say(` note: live (${r.live.coreVersion}) is AHEAD of ${r.mainRef} (${r.onMain}) — this checkout is stale; fetch.`);
|
|
234
258
|
}
|
|
259
|
+
// The watcher's watcher: if this checkout carries a subscription roster and that roster is not
|
|
260
|
+
// healthy, the unattended upgrade is not going to close the gap above on its own.
|
|
261
|
+
if (r.subscriptions && r.subscriptions.level !== 'ok') {
|
|
262
|
+
say('');
|
|
263
|
+
say(` ${r.subscriptions.level === 'alarm' ? '⚠ AUTO-UPGRADE' : 'note: auto-upgrade'} — ${r.subscriptions.message}`);
|
|
264
|
+
if (r.behind) say(' So the gap above will NOT close by itself.');
|
|
265
|
+
}
|
|
235
266
|
say('');
|
|
236
267
|
say(' Merging to main deploys nothing by design. "shipped" means "merged" —');
|
|
237
268
|
say(' it does not mean users can see it.');
|
|
@@ -317,4 +348,4 @@ if (require.main === module) {
|
|
|
317
348
|
});
|
|
318
349
|
}
|
|
319
350
|
|
|
320
|
-
module.exports = { check, report, line, coreVersionAt, unreleasedSince, cmpSemver };
|
|
351
|
+
module.exports = { check, report, line, coreVersionAt, unreleasedSince, cmpSemver, subscriptionHealth };
|
package/scripts/gds/fitness.js
CHANGED
|
@@ -1307,6 +1307,7 @@ const CHECKS = [
|
|
|
1307
1307
|
require('./control-manifest.js').checkControlsExist, // Check 21 — task 1003131: goal-1000076 controls assert their own existence (the ADR 0022 assurance-of-assurance)
|
|
1308
1308
|
require('./routine-schedule.js').checkRoutineCadencesScheduled, // Check 25 — task 1003209: a routine that declares a cadence must have a scheduler firing it AT that period (ADR 0188)
|
|
1309
1309
|
require('./baseline-staleness.js').checkBaselineFloors, // Check 26 — task 1003210: a shrink-only baseline that never shrinks is unmanaged debt; a floor exempting one must say why
|
|
1310
|
+
require('./client-baseurl-guard.js').checkClientBaseUrl, // Check 27 — task 1003263: a page must not combine baseUrl:'' with a generated namespaced call (that pairing 404s every request; three pages shipped it, two of them dead in prod)
|
|
1310
1311
|
];
|
|
1311
1312
|
|
|
1312
1313
|
function runAll() {
|
package/scripts/gds/ship-land.js
CHANGED
|
@@ -89,12 +89,23 @@ function formatPublishFailure(data, status, branch, opts = {}) {
|
|
|
89
89
|
const envelopeDetails = (data && data.error && typeof data.error === 'object' && data.error.details) || null;
|
|
90
90
|
const details = envelopeDetails || data || {};
|
|
91
91
|
const label = message || code || (data && data.message) || `HTTP ${status}`;
|
|
92
|
-
//
|
|
92
|
+
// Flattened ONLY for the matchers below and the one-line summary. The readable
|
|
93
|
+
// copy is printed verbatim underneath (task 1003222): the server no longer clips
|
|
94
|
+
// stderr, and git's useful line is the LAST one, so collapsing a 20-line push
|
|
95
|
+
// failure into a single run-on sentence hides the very thing that was un-hidden.
|
|
93
96
|
const reason = details.reason ? String(details.reason).replace(/\s+/g, ' ').trim() : '';
|
|
94
97
|
const parts = [];
|
|
95
98
|
if (details.step) parts.push(`step: ${details.step}`);
|
|
96
|
-
if (
|
|
99
|
+
if (details.git_exit_code != null) parts.push(`git exit ${details.git_exit_code}`);
|
|
100
|
+
if (details.github_status != null) parts.push(`GitHub HTTP ${details.github_status}`);
|
|
101
|
+
if (details.timed_out) parts.push('TIMED OUT');
|
|
102
|
+
// The one-line summary stays short and scannable; the full text follows it.
|
|
103
|
+
if (reason) parts.push(`reason: ${reason.length > 200 ? `${reason.slice(0, 200)}…` : reason}`);
|
|
97
104
|
let out = parts.length ? `${label} (${parts.join('; ')})` : label;
|
|
105
|
+
|
|
106
|
+
// ORDER MATTERS: summary, then the REMEDY, then the verbatim block last. The
|
|
107
|
+
// remedy is the actionable sentence and must not end up stranded below twenty
|
|
108
|
+
// lines of git chatter where it reads as a footnote to the quote.
|
|
98
109
|
if (NON_FAST_FORWARD_RE.test(reason)) {
|
|
99
110
|
out += opts.localHistoryRewritten === true
|
|
100
111
|
? ` — your LOCAL history was rewritten (a rebase or amend), so ${branch} is no longer a `
|
|
@@ -104,6 +115,17 @@ function formatPublishFailure(data, status, branch, opts = {}) {
|
|
|
104
115
|
: ` — the server already moved ${branch}: fetch origin, reset onto origin/${branch}, `
|
|
105
116
|
+ 're-run the generators, and re-ship so the push fast-forwards.';
|
|
106
117
|
}
|
|
118
|
+
|
|
119
|
+
// The untruncated remote message, verbatim and indented so it reads as a quotation
|
|
120
|
+
// rather than as more ship output. Only when it is multi-line — a single-line
|
|
121
|
+
// stderr is already fully present in the summary above, and repeating it is noise.
|
|
122
|
+
const full = details.reason ? String(details.reason).trimEnd() : '';
|
|
123
|
+
if (full && /\n/.test(full)) {
|
|
124
|
+
out += `\n\n --- what the remote actually said ---\n${full.split('\n').map((l) => ` ${l}`).join('\n')}\n --- end ---`;
|
|
125
|
+
if (details.reason_bytes != null && details.reason_bytes > full.length) {
|
|
126
|
+
out += `\n (${details.reason_bytes} bytes at the server; the middle was omitted here)`;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
107
129
|
return out;
|
|
108
130
|
}
|
|
109
131
|
|
|
@@ -32,6 +32,23 @@ const DEFAULT_CHANNEL = 'patch';
|
|
|
32
32
|
// core template — a fresh instance subscribes nothing until the operator lists a target here.
|
|
33
33
|
const SUBSCRIPTIONS_REL = path.join('config', 'update-subscriptions.json');
|
|
34
34
|
|
|
35
|
+
// Why a roster came back the way it did. The routine reports this EVERY run: an upgrader whose only
|
|
36
|
+
// job is keeping instances current must never be indistinguishable from one that has stopped. Before
|
|
37
|
+
// task 1003158 an absent manifest, a malformed manifest and a deliberately empty roster all produced
|
|
38
|
+
// the same silent `instances: []`, which is exactly how cloudbongos.com sat 36 releases behind for
|
|
39
|
+
// days with nothing complaining (the same failure SHAPE as blocker 1000097 / task 1002521).
|
|
40
|
+
const ROSTER_OK = 'ok'; // at least one usable entry
|
|
41
|
+
const ROSTER_ABSENT = 'manifest-absent'; // no file at all
|
|
42
|
+
const ROSTER_MALFORMED = 'manifest-malformed';// file present, JSON unparseable
|
|
43
|
+
const ROSTER_EMPTY = 'roster-empty'; // file parsed, nothing subscribable in it
|
|
44
|
+
const ROSTER_REASONS = [ROSTER_OK, ROSTER_ABSENT, ROSTER_MALFORMED, ROSTER_EMPTY];
|
|
45
|
+
|
|
46
|
+
// How many releases a subscribed instance may fall behind before "the upgrader stopped" is the only
|
|
47
|
+
// remaining explanation. A successful sweep takes the HIGHEST permitted version in one jump, so a
|
|
48
|
+
// healthy roster returns to 0 behind after every run — a backlog that keeps growing means no run is
|
|
49
|
+
// landing. 5 leaves room for a busy publish-on-merge day between two daily sweeps without crying wolf.
|
|
50
|
+
const DEFAULT_STALENESS_THRESHOLD = 5;
|
|
51
|
+
|
|
35
52
|
// ---- semver (stable x.y.z, optional prerelease tag) -------------------------
|
|
36
53
|
// A deliberately small parser — the repo avoids an npm-semver dependency in the CLI path (see
|
|
37
54
|
// upgrade.js versionFromDep). We only need compare + the major/minor/patch fields.
|
|
@@ -143,16 +160,23 @@ function parseNpmViewVersions(stdout) {
|
|
|
143
160
|
// this control plane keeps up to date. Never throws: a missing/malformed file ⇒ an empty roster
|
|
144
161
|
// (subscribe nothing), because an unreadable manifest must not upgrade anything. Each entry is
|
|
145
162
|
// normalized to { slug, dir, service, healthUrl, channel } with the channel validated + defaulted.
|
|
163
|
+
//
|
|
164
|
+
// Still fails CLOSED, no longer fails QUIET (task 1003158): every return carries a `reason` from
|
|
165
|
+
// ROSTER_REASONS and a `dropped` count of entries that were unusable, so a caller can say WHY it is
|
|
166
|
+
// about to do nothing. Pair it with describeRoster() for the line to print.
|
|
146
167
|
function loadSubscriptions({ instanceDir = process.cwd(), fsImpl = fs, file = SUBSCRIPTIONS_REL } = {}) {
|
|
147
168
|
const abs = path.isAbsolute(file) ? file : path.join(instanceDir, file);
|
|
148
169
|
let raw;
|
|
149
|
-
try { raw = fsImpl.readFileSync(abs, 'utf8'); } catch (_) { return { defaultChannel: DEFAULT_CHANNEL, instances: [], source: abs, present: false }; }
|
|
170
|
+
try { raw = fsImpl.readFileSync(abs, 'utf8'); } catch (_) { return { defaultChannel: DEFAULT_CHANNEL, instances: [], source: abs, present: false, dropped: 0, reason: ROSTER_ABSENT }; }
|
|
150
171
|
let json;
|
|
151
|
-
try { json = JSON.parse(raw); } catch (e) { return { defaultChannel: DEFAULT_CHANNEL, instances: [], source: abs, present: true, error: `malformed JSON: ${e.message}
|
|
172
|
+
try { json = JSON.parse(raw); } catch (e) { return { defaultChannel: DEFAULT_CHANNEL, instances: [], source: abs, present: true, dropped: 0, error: `malformed JSON: ${e.message}`, reason: ROSTER_MALFORMED }; }
|
|
152
173
|
const defaultChannel = normalizeChannel(json.defaultChannel);
|
|
153
174
|
const rows = Array.isArray(json.instances) ? json.instances : [];
|
|
154
|
-
|
|
155
|
-
|
|
175
|
+
// An entry without a dir can't be upgraded — drop it, but COUNT the drops. Silently discarding an
|
|
176
|
+
// operator's malformed row is how an instance believes it is enrolled while nothing ever touches it.
|
|
177
|
+
const usable = rows.filter((r) => r && typeof r === 'object' && r.dir);
|
|
178
|
+
const dropped = rows.length - usable.length;
|
|
179
|
+
const instances = usable
|
|
156
180
|
.map((r) => ({
|
|
157
181
|
slug: String(r.slug || r.dir),
|
|
158
182
|
dir: String(r.dir),
|
|
@@ -178,7 +202,90 @@ function loadSubscriptions({ instanceDir = process.cwd(), fsImpl = fs, file = SU
|
|
|
178
202
|
? Object.fromEntries(Object.entries(r.env).filter(([, v]) => typeof v === 'string'))
|
|
179
203
|
: {},
|
|
180
204
|
}));
|
|
181
|
-
return {
|
|
205
|
+
return {
|
|
206
|
+
defaultChannel, instances, source: abs, present: true, dropped,
|
|
207
|
+
reason: instances.length ? ROSTER_OK : ROSTER_EMPTY,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ---- reporting: make "nothing happened" audible -----------------------------
|
|
212
|
+
|
|
213
|
+
// Turn a loadSubscriptions() result into the line a routine prints EVERY run, plus a level a caller
|
|
214
|
+
// can act on ('ok' | 'warn' | 'alarm'). The judgement that matters is `controlPlane`: a fresh
|
|
215
|
+
// instance subscribing nothing is the shipped default (ADR 0136 — the manifest ships empty), but a
|
|
216
|
+
// CONTROL PLANE that subscribes nothing has silently stopped doing its only job, and that is an alarm.
|
|
217
|
+
// A malformed manifest is an alarm everywhere — an operator wrote something and it does not parse.
|
|
218
|
+
function describeRoster(roster, { controlPlane = false } = {}) {
|
|
219
|
+
const r = roster || {};
|
|
220
|
+
const reason = ROSTER_REASONS.includes(r.reason) ? r.reason : ROSTER_EMPTY;
|
|
221
|
+
const where = r.source || SUBSCRIPTIONS_REL;
|
|
222
|
+
const dropped = Number(r.dropped) || 0;
|
|
223
|
+
const n = Array.isArray(r.instances) ? r.instances.length : 0;
|
|
224
|
+
|
|
225
|
+
if (reason === ROSTER_MALFORMED) {
|
|
226
|
+
return { level: 'alarm', reason, count: n, dropped,
|
|
227
|
+
message: `update-subscriptions: MALFORMED manifest at ${where} (${r.error || 'unparseable'}) — subscribing NOTHING. No instance will be upgraded until this parses.` };
|
|
228
|
+
}
|
|
229
|
+
if (reason === ROSTER_ABSENT) {
|
|
230
|
+
return { level: controlPlane ? 'alarm' : 'warn', reason, count: n, dropped,
|
|
231
|
+
message: `update-subscriptions: no manifest at ${where} — subscribing NOTHING.${controlPlane ? ' This is a CONTROL PLANE: it is supposed to keep instances current and currently keeps none.' : ' That is the shipped default; add an instance to enroll it.'}` };
|
|
232
|
+
}
|
|
233
|
+
if (reason === ROSTER_EMPTY) {
|
|
234
|
+
return { level: controlPlane ? 'alarm' : 'warn', reason, count: n, dropped,
|
|
235
|
+
message: `update-subscriptions: manifest at ${where} lists no upgradable instance${dropped ? ` (${dropped} entr${dropped === 1 ? 'y' : 'ies'} dropped for a missing \`dir\`)` : ''} — subscribing NOTHING.${controlPlane ? ' This is a CONTROL PLANE: it is supposed to keep instances current and currently keeps none.' : ''}` };
|
|
236
|
+
}
|
|
237
|
+
// Usable entries — still surface partial loss, which is the quiet half of an enrolled-but-untouched
|
|
238
|
+
// instance: the sweep looks healthy while the operator's broken row is never upgraded.
|
|
239
|
+
if (dropped > 0) {
|
|
240
|
+
return { level: 'warn', reason, count: n, dropped,
|
|
241
|
+
message: `update-subscriptions: ${n} instance${n === 1 ? '' : 's'} subscribed, but ${dropped} entr${dropped === 1 ? 'y was' : 'ies were'} DROPPED for a missing \`dir\` in ${where} — those instances are NOT being upgraded.` };
|
|
242
|
+
}
|
|
243
|
+
return { level: 'ok', reason, count: n, dropped,
|
|
244
|
+
message: `update-subscriptions: ${n} instance${n === 1 ? '' : 's'} subscribed from ${where}.` };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ---- watching the watcher ---------------------------------------------------
|
|
248
|
+
|
|
249
|
+
// How far behind is this instance in releases its OWN channel is entitled to take? Not raw version
|
|
250
|
+
// distance: majors, prereleases and (on the patch channel) minors are releases the subscription is
|
|
251
|
+
// correctly declining, and counting them would alarm on instances that are behaving exactly as
|
|
252
|
+
// configured. `behind` is therefore "stable releases this channel WOULD have taken and has not".
|
|
253
|
+
//
|
|
254
|
+
// This is the check that was missing (task 1003158). deploy-divergence.js detects the SYMPTOM — live
|
|
255
|
+
// is behind main — at ship time, when a human is already looking. Nothing watched whether the
|
|
256
|
+
// upgrader itself was still alive, so its death was only ever discovered by someone noticing stale UI
|
|
257
|
+
// days later. A healthy sweep drives `behind` to 0 every run, so a persistent backlog IS the alarm.
|
|
258
|
+
//
|
|
259
|
+
// A `pinned` instance is never stale: channelAllows declines everything, so behind === 0 by
|
|
260
|
+
// construction. That is correct — a pinned instance is not neglected, it is pinned.
|
|
261
|
+
function subscriptionStaleness({ installed, channel, available, threshold = DEFAULT_STALENESS_THRESHOLD } = {}) {
|
|
262
|
+
const ch = normalizeChannel(channel);
|
|
263
|
+
const list = Array.isArray(available) ? available : [];
|
|
264
|
+
// `installed` is parsed and the channel re-normalized ONCE, not per candidate:
|
|
265
|
+
// channelAllows re-does both on every element, and this filter is the only place
|
|
266
|
+
// that calls it in a loop. Cheap either way at registry scale — hoisted because
|
|
267
|
+
// the invariant genuinely is invariant, not because the arithmetic hurts.
|
|
268
|
+
const from = parseSemver(installed);
|
|
269
|
+
const behind = (ch === 'pinned' || !from) ? 0
|
|
270
|
+
: list.filter((v) => channelAllows(installed, v, ch)).length;
|
|
271
|
+
const t = Number(threshold);
|
|
272
|
+
const limit = Number.isFinite(t) && t >= 0 ? t : DEFAULT_STALENESS_THRESHOLD;
|
|
273
|
+
return {
|
|
274
|
+
channel: ch,
|
|
275
|
+
installed: installed == null ? null : String(installed),
|
|
276
|
+
target: resolveChannelTarget({ installed, channel: ch, available: list }),
|
|
277
|
+
behind,
|
|
278
|
+
threshold: limit,
|
|
279
|
+
stale: behind > limit,
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// The alarm line for one subscribed instance, or null when it is healthy (nothing to say).
|
|
284
|
+
function describeStaleness(st, { slug = 'instance' } = {}) {
|
|
285
|
+
if (!st || !st.stale) return null;
|
|
286
|
+
return `update-subscriptions: ${slug} is ${st.behind} ${st.channel} release${st.behind === 1 ? '' : 's'} behind `
|
|
287
|
+
+ `(installed ${st.installed || 'unknown'}, entitled to ${st.target}) — over the ${st.threshold} threshold. `
|
|
288
|
+
+ 'A healthy sweep clears this to zero every run, so the upgrader has most likely stopped.';
|
|
182
289
|
}
|
|
183
290
|
|
|
184
291
|
module.exports = {
|
|
@@ -195,4 +302,13 @@ module.exports = {
|
|
|
195
302
|
listAvailableVersions,
|
|
196
303
|
parseNpmViewVersions,
|
|
197
304
|
loadSubscriptions,
|
|
305
|
+
ROSTER_OK,
|
|
306
|
+
ROSTER_ABSENT,
|
|
307
|
+
ROSTER_MALFORMED,
|
|
308
|
+
ROSTER_EMPTY,
|
|
309
|
+
ROSTER_REASONS,
|
|
310
|
+
DEFAULT_STALENESS_THRESHOLD,
|
|
311
|
+
describeRoster,
|
|
312
|
+
subscriptionStaleness,
|
|
313
|
+
describeStaleness,
|
|
198
314
|
};
|
package/src/module-api.js
CHANGED
|
@@ -1124,8 +1124,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
1124
1124
|
// landed since 1.19.345 with no explicit bump. run 32801147433. (task 1002620)
|
|
1125
1125
|
// 1.19.347 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1126
1126
|
// landed since 1.19.346 with no explicit bump. run 32802265337. (task 1002620)
|
|
1127
|
+
// 1.19.348 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1128
|
+
// landed since 1.19.347 with no explicit bump. run 32802746363. (task 1002620)
|
|
1129
|
+
// 1.19.349 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1130
|
+
// landed since 1.19.348 with no explicit bump. run 32803276871. (task 1002620)
|
|
1127
1131
|
// ---------------------------------------------------------------------------
|
|
1128
|
-
const CORE_VERSION = '1.19.
|
|
1132
|
+
const CORE_VERSION = '1.19.349'; // CI auto-patch carrier (ADR 0161); see changelog above
|
|
1129
1133
|
|
|
1130
1134
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
1131
1135
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|