@bongos/core 1.19.851 → 1.19.852
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 +40 -30
- package/docs/adr/0125-control-plane-scaffolds-standalone-instance-repo.md +3 -1
- package/docs/adr/0305-a-recipe-lives-with-what-it-documents.md +157 -0
- package/docs/adr/README.md +1 -0
- package/docs/copy-inventory.md +17 -16
- package/docs/copy-registry.json +26 -17
- package/docs/module-api-changelog.md +3 -1
- package/docs/recipes/core-release-pipeline.md +11 -2
- package/docs/recipes/upgrading-the-core.md +1 -0
- package/modules/hall-ui/public/builders.js +65 -6
- package/modules/hall-ui/public/hall-kit.js +72 -6
- package/modules/hall-ui/public/hall-render.js +103 -11
- package/modules/hall-ui/public/style.css +20 -0
- package/modules/lifecycle/db-analytics.js +79 -20
- package/modules/lifecycle/routes/analytics.js +22 -2
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/provision-repo-auth.js +51 -8
- package/scripts/gds/update-channel.js +27 -7
- package/scripts/gds/upgrade.js +20 -7
- package/src/module-api.js +1 -1
- package/tests/curve_kind_overlay.mjs +298 -0
- package/tests/provision.mjs +46 -3
- package/tests/update_channel.mjs +31 -0
- package/tests/upgrade.mjs +142 -1
|
@@ -278,8 +278,35 @@ async function gradeTrendByDay(days = 30) {
|
|
|
278
278
|
// -------------------------------------------------------------------------
|
|
279
279
|
|
|
280
280
|
|
|
281
|
-
|
|
281
|
+
// THE KIND DIMENSION (task 1004005). `kind` narrows every series that CAN be
|
|
282
|
+
// narrowed to one kind of work, so the page can answer "how am I doing at THIS
|
|
283
|
+
// kind of work". Three rules it follows, each of them a correctness rule rather
|
|
284
|
+
// than a style one:
|
|
285
|
+
//
|
|
286
|
+
// 1. COST IS NOT ANSWERABLE PER KIND, and is therefore not answered. cost_log
|
|
287
|
+
// has no kind and reaches tasks only through a NULLABLE task_id that is
|
|
288
|
+
// "NULL for non-task spend (infra, domain, etc.)" — most rows, as the
|
|
289
|
+
// cost-per-work comment below records. A kind-filtered cost line would
|
|
290
|
+
// silently drop nearly every cost row and plot a near-zero trend that looks
|
|
291
|
+
// like a real, cheap one. So a filtered call returns cost_trend: [] and
|
|
292
|
+
// cost_per_task: null, and the payload says why in `unavailable` — the page
|
|
293
|
+
// hides the chart and states the reason instead of drawing a lie.
|
|
294
|
+
// 2. top_kinds is NEVER filtered. It is the fingerprint the kind picker is
|
|
295
|
+
// BUILT from; narrowing it to the selected kind would leave the reader with
|
|
296
|
+
// one bar and no way back.
|
|
297
|
+
// 3. The same `kind` is honoured by ecosystemAnalytics, so the overlay compares
|
|
298
|
+
// like with like. A filtered line against an unfiltered benchmark is the
|
|
299
|
+
// failure mode this task's own text names as worse than no benchmark.
|
|
300
|
+
//
|
|
301
|
+
// An unknown kind is not rejected here — it simply matches nothing, which reads
|
|
302
|
+
// as an empty window. COALESCE mirrors top_kinds, so the literal 'unspecified'
|
|
303
|
+
// bucket the fingerprint shows is selectable.
|
|
304
|
+
async function builderAnalytics(builderId, days = 90, kind = null) {
|
|
282
305
|
const d = String(days);
|
|
306
|
+
const k = kind === undefined || kind === null || kind === '' ? null : String(kind);
|
|
307
|
+
// Every kind-aware series takes this as its LAST parameter: null → no filter,
|
|
308
|
+
// so an unfiltered call runs the same plan it always did.
|
|
309
|
+
const KIND_TASKS = `($3::text IS NULL OR COALESCE(kind, 'unspecified') = $3)`;
|
|
283
310
|
const [shipRate, costTrend, durationTrend, gradeTrend, topKinds, headline, claimRollup] =
|
|
284
311
|
await Promise.all([
|
|
285
312
|
// Ships per day — the core "dev curve".
|
|
@@ -290,11 +317,14 @@ async function builderAnalytics(builderId, days = 90) {
|
|
|
290
317
|
WHERE shipped_by = $1 AND status = 'shipped'
|
|
291
318
|
AND shipped_at >= now() - ($2 || ' days')::interval
|
|
292
319
|
AND ${SMOKE_NOT_LIKE}
|
|
320
|
+
AND ${KIND_TASKS}
|
|
293
321
|
GROUP BY 1 ORDER BY 1 ASC`,
|
|
294
|
-
[builderId, d]
|
|
322
|
+
[builderId, d, k]
|
|
295
323
|
),
|
|
296
324
|
// Cost per day + tasks touched that day → cost-per-task in JS.
|
|
297
|
-
|
|
325
|
+
// Skipped entirely under a kind filter — see rule 1 above. Not queried and
|
|
326
|
+
// then discarded: an un-askable question should not cost a round trip.
|
|
327
|
+
k !== null ? { rows: [] } : pool.query(
|
|
298
328
|
`SELECT to_char(date_trunc('day', recorded_at), 'YYYY-MM-DD') AS day,
|
|
299
329
|
SUM(amount_usd)::numeric(12,2) AS total_usd,
|
|
300
330
|
COUNT(DISTINCT task_id)::int AS n_tasks
|
|
@@ -314,8 +344,9 @@ async function builderAnalytics(builderId, days = 90) {
|
|
|
314
344
|
AND actual_minutes IS NOT NULL AND est_minutes IS NOT NULL
|
|
315
345
|
AND shipped_at >= now() - ($2 || ' days')::interval
|
|
316
346
|
AND ${SMOKE_NOT_LIKE}
|
|
347
|
+
AND ${KIND_TASKS}
|
|
317
348
|
GROUP BY 1 ORDER BY 1 ASC`,
|
|
318
|
-
[builderId, d]
|
|
349
|
+
[builderId, d, k]
|
|
319
350
|
),
|
|
320
351
|
// Grade pass-rate per day (quality holding up as throughput rises?).
|
|
321
352
|
// Quality-graded rows only — outage / null-axis rows would read as
|
|
@@ -330,10 +361,14 @@ async function builderAnalytics(builderId, days = 90) {
|
|
|
330
361
|
JOIN tasks t ON t.id = g.task_id
|
|
331
362
|
WHERE t.shipped_by = $1
|
|
332
363
|
AND g.graded_at >= now() - ($2 || ' days')::interval
|
|
364
|
+
AND ($3::text IS NULL OR COALESCE(t.kind, 'unspecified') = $3)
|
|
333
365
|
GROUP BY 1 ORDER BY 1 ASC`,
|
|
334
|
-
[builderId, d]
|
|
366
|
+
[builderId, d, k]
|
|
335
367
|
),
|
|
336
368
|
// Top kinds shipped (all-time — a "what do I build" fingerprint).
|
|
369
|
+
// DELIBERATELY UNFILTERED — see rule 2 above: this list is what the kind
|
|
370
|
+
// picker is built from, so narrowing it to the current selection would
|
|
371
|
+
// leave the reader one bar and no way back to the others.
|
|
337
372
|
pool.query(
|
|
338
373
|
`SELECT COALESCE(kind, 'unspecified') AS kind, COUNT(*)::int AS n
|
|
339
374
|
FROM tasks
|
|
@@ -352,18 +387,23 @@ async function builderAnalytics(builderId, days = 90) {
|
|
|
352
387
|
FROM tasks
|
|
353
388
|
WHERE shipped_by = $1
|
|
354
389
|
AND ${SMOKE_NOT_LIKE}
|
|
390
|
+
AND ${KIND_TASKS}
|
|
355
391
|
AND (shipped_at IS NULL OR shipped_at >= now() - ($2 || ' days')::interval)`,
|
|
356
|
-
[builderId, d]
|
|
392
|
+
[builderId, d, k]
|
|
357
393
|
),
|
|
358
394
|
// Confirmation rate — share of resolved claims that ended in a ship
|
|
359
395
|
// (vs. abandoned / expired / partial). Distinct from the grade pass-rate.
|
|
360
396
|
pool.query(
|
|
361
397
|
`SELECT COUNT(*)::int AS resolved_claims,
|
|
362
398
|
SUM(CASE WHEN outcome = 'shipped' THEN 1 ELSE 0 END)::int AS shipped_claims
|
|
363
|
-
FROM claims
|
|
364
|
-
WHERE builder_id = $1 AND outcome IS NOT NULL
|
|
365
|
-
AND claimed_at >= now() - ($2 || ' days')::interval
|
|
366
|
-
|
|
399
|
+
FROM claims c
|
|
400
|
+
WHERE c.builder_id = $1 AND c.outcome IS NOT NULL
|
|
401
|
+
AND c.claimed_at >= now() - ($2 || ' days')::interval
|
|
402
|
+
AND ($3::text IS NULL OR EXISTS (
|
|
403
|
+
SELECT 1 FROM tasks t
|
|
404
|
+
WHERE t.id = c.task_id
|
|
405
|
+
AND COALESCE(t.kind, 'unspecified') = $3))`,
|
|
406
|
+
[builderId, d, k]
|
|
367
407
|
),
|
|
368
408
|
]);
|
|
369
409
|
|
|
@@ -408,15 +448,24 @@ async function builderAnalytics(builderId, days = 90) {
|
|
|
408
448
|
|
|
409
449
|
return {
|
|
410
450
|
window_days: days,
|
|
451
|
+
kind: k, // the filter in force, echoed so a client can never mislabel a chart
|
|
452
|
+
// WHAT THIS WINDOW CANNOT ANSWER, named rather than left to look like a
|
|
453
|
+
// zero. Under a kind filter cost is un-attributable (see rule 1), so the
|
|
454
|
+
// page hides those two readouts and says why. An unfiltered call omits
|
|
455
|
+
// nothing and this is [].
|
|
456
|
+
unavailable: k === null ? [] : ['cost_trend', 'cost_per_task', 'total_cost_usd'],
|
|
411
457
|
ship_rate: shipRate.rows, // [{ day, n }]
|
|
412
|
-
cost_trend, // [{ day, total_usd, n_tasks, per_work }] — per_work is cumulative (task 1503)
|
|
458
|
+
cost_trend, // [{ day, total_usd, n_tasks, per_work }] — per_work is cumulative (task 1503); [] under a kind filter
|
|
413
459
|
duration_trend: durationTrend.rows, // [{ day, avg_actual, avg_est }]
|
|
414
460
|
grade_trend: grades, // [{ day, n, n_passed, avg_score }]
|
|
415
|
-
top_kinds: topKinds.rows, // [{ kind, n }]
|
|
461
|
+
top_kinds: topKinds.rows, // [{ kind, n }] — always ALL kinds; this is the picker's source
|
|
416
462
|
headline: {
|
|
417
463
|
total_shipped: h.total_shipped,
|
|
418
|
-
|
|
419
|
-
|
|
464
|
+
// null, not 0, under a kind filter: 0 is a measurement and this is the
|
|
465
|
+
// absence of one. A client that prints $0.00 here has said something false.
|
|
466
|
+
total_cost_usd: k === null ? Math.round(totalCost * 100) / 100 : null,
|
|
467
|
+
cost_per_task: k !== null ? null
|
|
468
|
+
: h.total_shipped > 0 ? Math.round((totalCost / h.total_shipped) * 100) / 100 : 0,
|
|
420
469
|
avg_duration_min: h.avg_duration_min,
|
|
421
470
|
confirmation_rate: c.resolved_claims > 0 ? Math.round((c.shipped_claims / c.resolved_claims) * 100) / 100 : null,
|
|
422
471
|
resolved_claims: c.resolved_claims,
|
|
@@ -433,8 +482,14 @@ async function builderAnalytics(builderId, days = 90) {
|
|
|
433
482
|
// daily series already have public feeds (/public/grades, /public/cost-summary),
|
|
434
483
|
// so this adds just the ship-rate, duration, top-kinds, and headline aggregates.
|
|
435
484
|
|
|
436
|
-
async function ecosystemAnalytics(days = 90) {
|
|
485
|
+
async function ecosystemAnalytics(days = 90, kind = null) {
|
|
437
486
|
const d = String(days);
|
|
487
|
+
// Same contract as builderAnalytics' kind, and for one reason: the hall overlays
|
|
488
|
+
// this series on a builder's own. If only one side could be narrowed, the overlay
|
|
489
|
+
// would compare a kind against the whole ecosystem and still look like a
|
|
490
|
+
// like-for-like benchmark (task 1004005).
|
|
491
|
+
const k = kind === undefined || kind === null || kind === '' ? null : String(kind);
|
|
492
|
+
const KIND_TASKS = `($2::text IS NULL OR COALESCE(kind, 'unspecified') = $2)`;
|
|
438
493
|
const [shipRate, durationTrend, topKinds, headline] = await Promise.all([
|
|
439
494
|
pool.query(
|
|
440
495
|
`SELECT to_char(date_trunc('day', shipped_at), 'YYYY-MM-DD') AS day,
|
|
@@ -443,8 +498,9 @@ async function ecosystemAnalytics(days = 90) {
|
|
|
443
498
|
WHERE status = 'shipped' AND shipped_by IS NOT NULL
|
|
444
499
|
AND shipped_at >= now() - ($1 || ' days')::interval
|
|
445
500
|
AND ${SMOKE_NOT_LIKE}
|
|
501
|
+
AND ${KIND_TASKS}
|
|
446
502
|
GROUP BY 1 ORDER BY 1 ASC`,
|
|
447
|
-
[d]
|
|
503
|
+
[d, k]
|
|
448
504
|
),
|
|
449
505
|
pool.query(
|
|
450
506
|
`SELECT to_char(date_trunc('day', shipped_at), 'YYYY-MM-DD') AS day,
|
|
@@ -455,8 +511,9 @@ async function ecosystemAnalytics(days = 90) {
|
|
|
455
511
|
AND actual_minutes IS NOT NULL AND est_minutes IS NOT NULL
|
|
456
512
|
AND shipped_at >= now() - ($1 || ' days')::interval
|
|
457
513
|
AND ${SMOKE_NOT_LIKE}
|
|
514
|
+
AND ${KIND_TASKS}
|
|
458
515
|
GROUP BY 1 ORDER BY 1 ASC`,
|
|
459
|
-
[d]
|
|
516
|
+
[d, k]
|
|
460
517
|
),
|
|
461
518
|
pool.query(
|
|
462
519
|
`SELECT COALESCE(kind, 'unspecified') AS kind, COUNT(*)::int AS n
|
|
@@ -475,17 +532,19 @@ async function ecosystemAnalytics(days = 90) {
|
|
|
475
532
|
FROM tasks
|
|
476
533
|
WHERE status = 'shipped' AND shipped_by IS NOT NULL
|
|
477
534
|
AND shipped_at >= now() - ($1 || ' days')::interval
|
|
478
|
-
AND ${SMOKE_NOT_LIKE}
|
|
479
|
-
|
|
535
|
+
AND ${SMOKE_NOT_LIKE}
|
|
536
|
+
AND ${KIND_TASKS}`,
|
|
537
|
+
[d, k]
|
|
480
538
|
),
|
|
481
539
|
]);
|
|
482
540
|
|
|
483
541
|
const h = headline.rows[0] || { total_shipped: 0, active_builders: 0, avg_duration_min: 0 };
|
|
484
542
|
return {
|
|
485
543
|
window_days: days,
|
|
544
|
+
kind: k, // echoed so an overlay can prove both sides were narrowed the same way
|
|
486
545
|
ship_rate: shipRate.rows,
|
|
487
546
|
duration_trend: durationTrend.rows,
|
|
488
|
-
top_kinds: topKinds.rows,
|
|
547
|
+
top_kinds: topKinds.rows, // always ALL kinds — the picker's source, never narrowed
|
|
489
548
|
headline: {
|
|
490
549
|
total_shipped: h.total_shipped,
|
|
491
550
|
active_builders: h.active_builders,
|
|
@@ -28,6 +28,26 @@ function parseDays(raw) {
|
|
|
28
28
|
return 90;
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
// The `kind` filter (task 1004005) — "how am I doing at THIS kind of work".
|
|
32
|
+
//
|
|
33
|
+
// Bounded HERE rather than trusted to the query layer, even though both callers
|
|
34
|
+
// pass it as a bound $n parameter and neither interpolates it. A kind is one
|
|
35
|
+
// short token from a known vocabulary, so anything longer or stranger is a
|
|
36
|
+
// mistake or a probe, and a 200-char string has no business reaching a GROUP BY
|
|
37
|
+
// on either of these tables. An absent, blank or over-long value resolves to
|
|
38
|
+
// null, which every query below reads as "no filter" — the unfiltered plan.
|
|
39
|
+
//
|
|
40
|
+
// An UNRECOGNISED kind is deliberately NOT rejected: `kind` is a free text
|
|
41
|
+
// column with a COALESCE('unspecified') bucket, the vocabulary grows, and a 400
|
|
42
|
+
// on a kind that simply has no rows yet would be a worse answer than the honest
|
|
43
|
+
// empty window the filter already returns.
|
|
44
|
+
const KIND_MAX = 40;
|
|
45
|
+
function parseKind(raw) {
|
|
46
|
+
const s = (raw === undefined || raw === null ? '' : String(raw)).trim();
|
|
47
|
+
if (!s || s.length > KIND_MAX) return null;
|
|
48
|
+
return s;
|
|
49
|
+
}
|
|
50
|
+
|
|
31
51
|
module.exports = function buildAnalyticsRouter() {
|
|
32
52
|
const router = express.Router();
|
|
33
53
|
|
|
@@ -40,7 +60,7 @@ module.exports = function buildAnalyticsRouter() {
|
|
|
40
60
|
// rank: public — ecosystem-wide dev-curve aggregate; individuals stripped.
|
|
41
61
|
router.get('/public/analytics/ecosystem', asyncHandler('GET /public/analytics/ecosystem', async (req, res) => {
|
|
42
62
|
const days = parseDays(req.query.days);
|
|
43
|
-
const data = await db.ecosystemAnalytics(days);
|
|
63
|
+
const data = await db.ecosystemAnalytics(days, parseKind(req.query.kind));
|
|
44
64
|
res.set('Cache-Control', 'public, max-age=60');
|
|
45
65
|
res.json(data);
|
|
46
66
|
}, { message: false, errorCode: 'ecosystem_analytics_failed' }));
|
|
@@ -55,7 +75,7 @@ module.exports = function buildAnalyticsRouter() {
|
|
|
55
75
|
return res.fail('forbidden', { status: 403, message: 'You can only view your own analytics.' });
|
|
56
76
|
}
|
|
57
77
|
const days = parseDays(req.query.days);
|
|
58
|
-
const data = await db.builderAnalytics(targetId, days);
|
|
78
|
+
const data = await db.builderAnalytics(targetId, days, parseKind(req.query.kind));
|
|
59
79
|
res.json({ builder_id: targetId, ...data });
|
|
60
80
|
}, { message: false, errorCode: 'builder_analytics_failed' }));
|
|
61
81
|
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.852",
|
|
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.852",
|
|
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.852",
|
|
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",
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
|
|
28
28
|
'use strict';
|
|
29
29
|
|
|
30
|
+
const { execFileSync } = require('node:child_process');
|
|
30
31
|
const path = require('node:path');
|
|
31
32
|
|
|
32
33
|
// The same value provision-config.js exports as REPO_ROOT, recomputed here rather than
|
|
@@ -44,14 +45,55 @@ function parseTargetRef(ref) {
|
|
|
44
45
|
return m ? { owner: m[1], name: m[2] } : null;
|
|
45
46
|
}
|
|
46
47
|
|
|
47
|
-
// The
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
//
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
48
|
+
// The SSH alias the founder control plane wires for the platform repo (ADR 0125/0129).
|
|
49
|
+
const DEPLOY_ALIAS = 'github-deploy';
|
|
50
|
+
|
|
51
|
+
// One probe per alias per process — `ssh -G` re-reads the same static config every call.
|
|
52
|
+
const aliasProbeCache = new Map();
|
|
53
|
+
|
|
54
|
+
// Does an SSH host alias actually resolve HERE? `ssh -G <name>` prints the effective
|
|
55
|
+
// config for that name: a real `Host <name>` block rewrites `hostname`, an absent one
|
|
56
|
+
// echoes the name straight back — which is then exactly what git hands to DNS, and
|
|
57
|
+
// fails on. A box with no ssh client at all answers the same way. This has to be probed
|
|
58
|
+
// rather than assumed because the alias is a property of ONE box's ~/.ssh/config, not of
|
|
59
|
+
// this code (task 1003996). `runSsh` is injectable for tests; nothing spawns at require
|
|
60
|
+
// time, so this file stays inert on the merge-resolver path (see the header).
|
|
61
|
+
function sshAliasResolves(alias, runSsh) {
|
|
62
|
+
if (!runSsh && aliasProbeCache.has(alias)) return aliasProbeCache.get(alias);
|
|
63
|
+
let resolves = false;
|
|
64
|
+
try {
|
|
65
|
+
const probe = runSsh || ((a) => execFileSync('ssh', ['-G', a], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 5000 }));
|
|
66
|
+
const hostname = (String(probe(alias)).match(/^hostname[ \t]+(\S+)/mi) || [])[1];
|
|
67
|
+
resolves = !!hostname && hostname.toLowerCase() !== alias.toLowerCase();
|
|
68
|
+
} catch { resolves = false; }
|
|
69
|
+
if (!runSsh) aliasProbeCache.set(alias, resolves);
|
|
70
|
+
return resolves;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// The git remote for an instance's OWN repo — git@<alias>:<owner>/<repo>.git, NO secret
|
|
74
|
+
// in the command (SSH-key auth, nothing to leak into journald).
|
|
75
|
+
//
|
|
76
|
+
// Which <alias>: an explicit one, else PROVISION_GIT_SSH_ALIAS, else the control plane's
|
|
77
|
+
// own deploy alias WHEN IT RESOLVES here, else github.com. The last two rungs are the
|
|
78
|
+
// point: reusing the founder box's alias is what makes the instance repo reachable by the
|
|
79
|
+
// same key that already pushes the platform repo (ADR 0125), and on that box the probe
|
|
80
|
+
// keeps choosing it. Anywhere else the alias is a dangling name, and `git@github.com:` is
|
|
81
|
+
// both the correct address and the one a plane's own deploy key is issued against.
|
|
82
|
+
function instanceRepoRemote(repo, { sshAlias, runSsh } = {}) {
|
|
83
|
+
const alias = sshAlias
|
|
84
|
+
|| process.env.PROVISION_GIT_SSH_ALIAS
|
|
85
|
+
|| (sshAliasResolves(DEPLOY_ALIAS, runSsh) ? DEPLOY_ALIAS : 'github.com');
|
|
86
|
+
return `git@${alias}:${repo.owner}/${repo.name}.git`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Every remote URL shape this code writes for `repo`: the github.com form plus the deploy
|
|
90
|
+
// alias in play. What a stored remote is VALIDATED against (upgrade.js), which must not
|
|
91
|
+
// re-run the probe — a remote we wrote stays ours even from a box where the alias no
|
|
92
|
+
// longer resolves.
|
|
93
|
+
function instanceRepoRemotes(repo) {
|
|
94
|
+
const aliases = new Set(['github.com', DEPLOY_ALIAS]);
|
|
95
|
+
if (process.env.PROVISION_GIT_SSH_ALIAS) aliases.add(process.env.PROVISION_GIT_SSH_ALIAS);
|
|
96
|
+
return [...aliases].map((a) => `git@${a}:${repo.owner}/${repo.name}.git`);
|
|
55
97
|
}
|
|
56
98
|
|
|
57
99
|
// The instance owner's stored `repo`-scoped OAuth token (ADR 0155), or null (none
|
|
@@ -100,6 +142,7 @@ async function lookupInstanceRow(instanceDir, deps = {}) {
|
|
|
100
142
|
|
|
101
143
|
module.exports = {
|
|
102
144
|
instanceRepoRemote,
|
|
145
|
+
instanceRepoRemotes,
|
|
103
146
|
lookupInstanceRow,
|
|
104
147
|
parseTargetRef,
|
|
105
148
|
resolveOwnerGithubToken,
|
|
@@ -28,6 +28,7 @@ const { spawnSync } = require('node:child_process');
|
|
|
28
28
|
const CORE_PKG = '@bongos/core';
|
|
29
29
|
const VALID_CHANNELS = ['pinned', 'patch', 'minor'];
|
|
30
30
|
const DEFAULT_CHANNEL = 'patch';
|
|
31
|
+
const NPM_VIEW_TIMEOUT_MS = 30000;
|
|
31
32
|
// The subscription manifest an operator edits to opt instances in. Ships EMPTY (instances: []) in the
|
|
32
33
|
// core template — a fresh instance subscribes nothing until the operator lists a target here.
|
|
33
34
|
const SUBSCRIPTIONS_REL = path.join('config', 'update-subscriptions.json');
|
|
@@ -55,8 +56,10 @@ const DEFAULT_STALENESS_THRESHOLD = 5;
|
|
|
55
56
|
|
|
56
57
|
// Parse "1.17.2" / "v1.17.2" / "1.17.2-rc.1" → { major, minor, patch, pre } or null. `pre` is the
|
|
57
58
|
// prerelease tag ('' for a plain stable release), used only to EXCLUDE prereleases from auto-targets.
|
|
59
|
+
// `+build` metadata parses and is dropped: semver gives it no precedence, and npm installs the
|
|
60
|
+
// version underneath it, so a caller asking "is this older?" must see through it (task 1004009).
|
|
58
61
|
function parseSemver(v) {
|
|
59
|
-
const m = String(v == null ? '' : v).trim().match(/^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/);
|
|
62
|
+
const m = String(v == null ? '' : v).trim().match(/^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/);
|
|
60
63
|
if (!m) return null;
|
|
61
64
|
return { major: +m[1], minor: +m[2], patch: +m[3], pre: m[4] || '' };
|
|
62
65
|
}
|
|
@@ -78,11 +81,27 @@ function compareSemver(a, b) {
|
|
|
78
81
|
for (const k of ['major', 'minor', 'patch']) {
|
|
79
82
|
if (pa[k] !== pb[k]) return pa[k] < pb[k] ? -1 : 1;
|
|
80
83
|
}
|
|
81
|
-
// Same x.y.z: a prerelease is LOWER than the stable release.
|
|
82
|
-
// precisely here — we never auto-target a prerelease, so their relative order is immaterial.)
|
|
84
|
+
// Same x.y.z: a prerelease is LOWER than the stable release.
|
|
83
85
|
if (pa.pre && !pb.pre) return -1;
|
|
84
86
|
if (!pa.pre && pb.pre) return 1;
|
|
85
|
-
return
|
|
87
|
+
return comparePrerelease(pa.pre, pb.pre);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Semver §11.4 for two prerelease tags: dot-separated identifiers left to right, numeric ones as
|
|
91
|
+
// numbers and below text ones, and the longer list wins a tie. Auto-targeting never needed this
|
|
92
|
+
// (a prerelease is never a target); `bongos upgrade`'s downgrade guard does — rc.2 → rc.1 is one.
|
|
93
|
+
function comparePrerelease(a, b) {
|
|
94
|
+
const ia = a ? a.split('.') : [];
|
|
95
|
+
const ib = b ? b.split('.') : [];
|
|
96
|
+
for (let i = 0; i < Math.min(ia.length, ib.length); i++) {
|
|
97
|
+
if (ia[i] === ib[i]) continue;
|
|
98
|
+
const na = /^\d+$/.test(ia[i]);
|
|
99
|
+
const nb = /^\d+$/.test(ib[i]);
|
|
100
|
+
if (na && nb) return +ia[i] < +ib[i] ? -1 : 1;
|
|
101
|
+
if (na !== nb) return na ? -1 : 1;
|
|
102
|
+
return ia[i] < ib[i] ? -1 : 1;
|
|
103
|
+
}
|
|
104
|
+
return Math.sign(ia.length - ib.length);
|
|
86
105
|
}
|
|
87
106
|
|
|
88
107
|
function normalizeChannel(channel) {
|
|
@@ -126,11 +145,12 @@ function resolveChannelTarget({ installed, channel, available } = {}) {
|
|
|
126
145
|
// The package is public (task 1003872), so this needs no credential and no instance-local
|
|
127
146
|
// `.npmrc` — any cwd resolves it. Fail-SOFT: any error returns
|
|
128
147
|
// { ok:false } so the routine skips that instance rather than crashing the whole sweep. `run` +
|
|
129
|
-
// `pkg` are injectable for tests.
|
|
130
|
-
|
|
148
|
+
// `pkg` are injectable for tests. The timeout is a DEFAULT, not an opt-in: a hung registry read
|
|
149
|
+
// would otherwise stall a whole sweep, or an upgrade refusal that only wanted to name a version.
|
|
150
|
+
function listAvailableVersions({ cwd = process.cwd(), pkg = CORE_PKG, run = spawnSync, env = process.env, timeoutMs = NPM_VIEW_TIMEOUT_MS } = {}) {
|
|
131
151
|
let res;
|
|
132
152
|
try {
|
|
133
|
-
res = run('npm', ['view', pkg, 'versions', '--json'], { cwd, env, encoding: 'utf8' });
|
|
153
|
+
res = run('npm', ['view', pkg, 'versions', '--json'], { cwd, env, encoding: 'utf8', timeout: timeoutMs });
|
|
134
154
|
} catch (e) {
|
|
135
155
|
return { ok: false, error: e && e.message ? e.message : String(e), versions: [] };
|
|
136
156
|
}
|
package/scripts/gds/upgrade.js
CHANGED
|
@@ -32,6 +32,7 @@ const path = require('node:path');
|
|
|
32
32
|
const crypto = require('node:crypto');
|
|
33
33
|
const { spawnSync } = require('node:child_process');
|
|
34
34
|
const { arg, hasFlag } = require('./cli-lib');
|
|
35
|
+
const { parseSemver, compareSemver, isStable, listAvailableVersions } = require('./update-channel');
|
|
35
36
|
|
|
36
37
|
const CORE_PKG = '@bongos/core';
|
|
37
38
|
const ARTIFACT = 'bongos-core';
|
|
@@ -202,7 +203,7 @@ function dirtyPinFiles(instanceDir, run = spawnSync) { return dirtyPaths(instanc
|
|
|
202
203
|
|
|
203
204
|
// The GitHub owner/name behind `remote`, or null when the remote is not a GitHub
|
|
204
205
|
// repository (there is nothing to push a GitHub token at) or cannot be read. Accepts
|
|
205
|
-
// the plain github.com forms and
|
|
206
|
+
// the plain github.com forms and every SSH alias instanceRepoRemotes wires
|
|
206
207
|
// (`git@github-deploy:owner/repo.git`) — never another host, so a stored token is
|
|
207
208
|
// never pointed at github.com/<owner>/<repo> for a repo that lives elsewhere.
|
|
208
209
|
function remoteGithubRepo(instanceDir, remote = 'origin', run = spawnSync) {
|
|
@@ -214,7 +215,7 @@ function remoteGithubRepo(instanceDir, remote = 'origin', run = spawnSync) {
|
|
|
214
215
|
const repo = auth.parseTargetRef(url);
|
|
215
216
|
if (!repo) return null;
|
|
216
217
|
const host = (url.match(/^(?:[a-z+]+:\/\/)?(?:[^@/]+@)?([^/:]+)[/:]/i) || [])[1] || '';
|
|
217
|
-
if (host.toLowerCase() !== 'github.com' &&
|
|
218
|
+
if (host.toLowerCase() !== 'github.com' && !auth.instanceRepoRemotes(repo).includes(url)) return null;
|
|
218
219
|
return repo;
|
|
219
220
|
}
|
|
220
221
|
|
|
@@ -1093,9 +1094,19 @@ async function runUpgrade(opts, deps = {}) {
|
|
|
1093
1094
|
let snapshot = null;
|
|
1094
1095
|
if (!targetVersion) return { ok: false, error: 'missing --to <version> (the target core version)' };
|
|
1095
1096
|
|
|
1096
|
-
const
|
|
1097
|
+
const pinnedVersion = readPinnedCoreVersion(instanceDir, fsImpl);
|
|
1098
|
+
const fromVersion = readInstalledCoreVersion(instanceDir, fsImpl) || pinnedVersion;
|
|
1097
1099
|
const doRollback = (reason) => rollback({ instanceDir, snapshot, fromVersion, toVersion: targetVersion, opts }, reason, deps);
|
|
1098
|
-
|
|
1100
|
+
// A --to OLDER than this instance's core is refused before anything moves, --force included
|
|
1101
|
+
// (task 1004009). Measured against the NEWER of installed and pinned — a half-applied bump
|
|
1102
|
+
// leaves them apart. rollback() restores its own pin and never comes through here.
|
|
1103
|
+
const baseline = compareSemver(pinnedVersion, fromVersion) > 0 ? pinnedVersion : fromVersion;
|
|
1104
|
+
const isDowngrade = !!(parseSemver(baseline) && parseSemver(targetVersion) && compareSemver(targetVersion, baseline) < 0);
|
|
1105
|
+
if (isDowngrade && !opts.allowDowngrade) {
|
|
1106
|
+
const newest = listAvailableVersions({ cwd: instanceDir, run }).versions.filter(isStable).sort(compareSemver).pop();
|
|
1107
|
+
return { ok: false, downgrade: true, fromVersion, toVersion: targetVersion, error: `refusing to DOWNGRADE: --to ${targetVersion} is older than this instance's core ${baseline}${pinnedVersion && pinnedVersion !== fromVersion ? ` (installed ${fromVersion}, pinned ${pinnedVersion})` : ''}; newest published: ${newest || 'unknown — the registry could not be read'}. Re-read the live version and target that; pass --allow-downgrade only if going backwards is the point (migrations are not reverted).` };
|
|
1108
|
+
}
|
|
1109
|
+
log(`bongos ${isDowngrade ? 'DOWNGRADE (--allow-downgrade; migrations are NOT reverted)' : 'upgrade'} — ${CORE_PKG}: ${fromVersion || '(none)'} → ${targetVersion}`);
|
|
1099
1110
|
log(` instance: ${instanceDir}${dryRun ? ' [DRY RUN — no changes]' : ''}`);
|
|
1100
1111
|
if (fromVersion === targetVersion && !opts.force) {
|
|
1101
1112
|
return { ok: false, error: `already on ${targetVersion} (pass --force to re-run the steps anyway)`, fromVersion };
|
|
@@ -1350,7 +1361,7 @@ async function runUpgrade(opts, deps = {}) {
|
|
|
1350
1361
|
return { ok: false, error: `${why} — rolled back to ${snapshot.prevVersion || 'the previous core'}`, fromVersion, toVersion: targetVersion, versionOk, healthOk, servedOk, servedVersion, rolledBack: true, rollback: rb };
|
|
1351
1362
|
}
|
|
1352
1363
|
|
|
1353
|
-
const ledger = await recordLedger({ fromVersion, toVersion: targetVersion, sourceCommit: opts.sourceCommit, note: opts.note, requestedByBuilderId: opts.requestedBy, source: opts.upgradeSource }, deps);
|
|
1364
|
+
const ledger = await recordLedger({ fromVersion, toVersion: targetVersion, sourceCommit: opts.sourceCommit, note: isDowngrade ? `downgrade (--allow-downgrade)${opts.note ? `: ${opts.note}` : ''}` : opts.note, requestedByBuilderId: opts.requestedBy, source: opts.upgradeSource }, deps);
|
|
1354
1365
|
if (ledger.ok) log(' ✓ recorded in core_upgrades ledger');
|
|
1355
1366
|
else if (ledger.skipped) log(` • ledger skipped (${ledger.reason})`);
|
|
1356
1367
|
else err(` ! ledger insert failed (${ledger.reason}) — record it manually`);
|
|
@@ -1383,7 +1394,7 @@ async function runUpgrade(opts, deps = {}) {
|
|
|
1383
1394
|
}, run, log, err);
|
|
1384
1395
|
|
|
1385
1396
|
const ok = versionOk && healthOk !== false && servedOk !== false;
|
|
1386
|
-
log(ok ? `\n✓ upgrade complete — core ${fromVersion || '(none)'} → ${targetVersion}` : `\n! upgrade finished with warnings — review above.`);
|
|
1397
|
+
log(ok ? `\n✓ ${isDowngrade ? 'downgrade' : 'upgrade'} complete — core ${fromVersion || '(none)'} → ${targetVersion}` : `\n! ${isDowngrade ? 'downgrade' : 'upgrade'} finished with warnings — review above.`);
|
|
1387
1398
|
return { ok, fromVersion, toVersion: targetVersion, versionOk, healthOk, servedOk, servedVersion, ledger: ledger.ok, pin };
|
|
1388
1399
|
}
|
|
1389
1400
|
|
|
@@ -1412,6 +1423,7 @@ function parseArgs(argv) {
|
|
|
1412
1423
|
gitRemote: arg('--git-remote', argv),
|
|
1413
1424
|
dryRun: hasFlag('--dry-run', argv),
|
|
1414
1425
|
force: hasFlag('--force', argv),
|
|
1426
|
+
allowDowngrade: hasFlag('--allow-downgrade', argv), // task 1004009: a --to older than the installed core is refused without it
|
|
1415
1427
|
skipMigrate: hasFlag('--skip-migrate', argv),
|
|
1416
1428
|
skipMaterialize: hasFlag('--skip-materialize', argv),
|
|
1417
1429
|
skipRegenDocs: hasFlag('--skip-regen-docs', argv),
|
|
@@ -1449,7 +1461,8 @@ async function main(argv = process.argv.slice(2)) {
|
|
|
1449
1461
|
' --commit-pin commit + push package.json/-lock so a pull-deploy reset cannot revert the bump. The push rides the instance owner\'s stored GitHub token when one is stored (a PRIVATE instance\'s origin is a read-only deploy key — ADR 0176), else the git remote (--git-remote <name>, default origin); a refused push is reported loudly with the fix',
|
|
1450
1462
|
' --pin-no-push with --commit-pin: commit the pin but do NOT push it — for an instance whose repo of record is not ours (a provisioned co-tenant), where the push is wrong and no pull-deploy can revert the commit',
|
|
1451
1463
|
' --dry-run show the plan; change nothing',
|
|
1452
|
-
' --force skip the clean-tree + same-version guards',
|
|
1464
|
+
' --force skip the clean-tree + same-version guards (NOT the downgrade guard)',
|
|
1465
|
+
' --allow-downgrade let --to be OLDER than the installed core. Refused otherwise: a stale --to silently takes merged work off a live instance, and migrations are never reverted, so the DB stays ahead of the code',
|
|
1453
1466
|
'',
|
|
1454
1467
|
'Full bump: pre-flight → bump pin → npm install → verify integrity pin → migrate → materialize .claude → regen API docs+client → restart → verify (health + served coreVersion).',
|
|
1455
1468
|
'On a failure after the pin moves (install/pin-verify/migrate/restart/health/served-version), auto-rollback reverts the pin, reinstalls the previous core, restarts, and records a rolled_back ledger row (the DB is not rolled back — core migrations are additive).',
|
package/src/module-api.js
CHANGED
|
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
71
71
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
72
72
|
// the entry to that file. Look for a version's history there, not here.
|
|
73
73
|
// ---------------------------------------------------------------------------
|
|
74
|
-
const CORE_VERSION = '1.19.
|
|
74
|
+
const CORE_VERSION = '1.19.852'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
75
75
|
|
|
76
76
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
77
77
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|