@bongos/core 1.19.1038 → 1.19.1040

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.
Files changed (44) hide show
  1. package/.bongos-core.json +98 -38
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +4 -0
  4. package/clients/bongos-client/index.cjs +4 -0
  5. package/clients/bongos-client/index.d.ts +4 -0
  6. package/clients/bongos-client/index.mjs +4 -0
  7. package/docs/api/openapi.json +39 -1
  8. package/docs/api-reference.md +7 -1
  9. package/docs/copy-inventory.md +2 -2
  10. package/docs/copy-registry.json +2 -2
  11. package/docs/file-map.md +1 -0
  12. package/docs/module-api-changelog.md +5 -1
  13. package/docs/recipes/core-release-pipeline.md +1 -1
  14. package/docs/recipes/private-npm-distribution.md +1 -1
  15. package/modules/hall-ui/public/deploy.html +9 -0
  16. package/modules/hall-ui/public/deploy.js +14 -1
  17. package/modules/hall-ui/public/deploy.states.json +14 -0
  18. package/modules/lifecycle/conflict-resolve.js +9 -1
  19. package/modules/npm-release/module.json +19 -0
  20. package/modules/npm-release/public/work.css +8 -0
  21. package/modules/npm-release/public/work.js +261 -0
  22. package/modules/npm-release/registry.js +271 -0
  23. package/modules/npm-release/routes/work.js +46 -0
  24. package/modules/npm-release/work.js +252 -0
  25. package/modules/provisioning/starter-bundles.js +6 -2
  26. package/package-lock.json +2 -2
  27. package/package.json +1 -1
  28. package/release-notes.json +24 -0
  29. package/scripts/gds/exec-path-guard.js +1 -1
  30. package/scripts/gds/install-git-hooks.js +18 -2
  31. package/scripts/gds/plain-cards.js +5 -0
  32. package/scripts/gds/publish-integration-gate.js +142 -0
  33. package/scripts/gds/publish-manifest.js +1 -0
  34. package/scripts/gds/ship-land.js +72 -6
  35. package/src/bongos/module-scope-map.js +5 -0
  36. package/src/module-api.js +1 -1
  37. package/tests/conflict_resolve.mjs +15 -1
  38. package/tests/merge_driver_register.mjs +28 -0
  39. package/tests/module_loader.mjs +1 -1
  40. package/tests/npm_release_page.mjs +173 -0
  41. package/tests/npm_release_work.mjs +350 -0
  42. package/tests/publish_integration_gate.mjs +198 -0
  43. package/tests/render_check.mjs +3 -2
  44. package/tests/ship_land_ci_not_started.mjs +168 -0
@@ -0,0 +1,46 @@
1
+ 'use strict';
2
+
3
+ // modules/npm-release/routes/work.js — GET /npm-release/work, the deploy page's
4
+ // "where is my work?" reading (task 1002622). See ../work.js for what it answers.
5
+ //
6
+ // GATED LIKE THE PAGE IT FEEDS. /deploy is served only to holders of `core.pin.move`
7
+ // (serve-internal.js DEPLOY_PAGE_RE), and this answer carries the same disclosure the
8
+ // door's own preview does — the exact core this hall runs and how far behind the
9
+ // newest it is (ADR 0043's reason for gating that preview). A read no wider than the
10
+ // page is also what keeps the page from ever offering more than its API will answer.
11
+ // An atom, not requireRank: tests/government_require_permission.mjs fails the build on
12
+ // a new requireRank in a modules/*/routes gate (BV1.R105 / ADR 0151).
13
+ //
14
+ // READ-ONLY. Nothing here writes, queues or moves anything; the Release button is a
15
+ // separate route with its own gate.
16
+
17
+ const express = require('express');
18
+ const api = require('../../../src/module-api');
19
+ const { readWork } = require('../work');
20
+
21
+ const log = api.logger('npm-release');
22
+
23
+ module.exports = function buildNpmReleaseWorkRouter({ read = readWork } = {}) {
24
+ const router = express.Router();
25
+
26
+ // Query: days (ledger window, default 14, max 60), before + limit (history paging).
27
+ // Read by hand rather than through validateOrRespond: every field is optional and
28
+ // clamped, so there is nothing to refuse.
29
+ router.get(
30
+ '/npm-release/work',
31
+ api.requireBuilder,
32
+ api.requirePermission('core.pin.move'),
33
+ async (req, res) => {
34
+ try {
35
+ const q = req.query || {};
36
+ const before = typeof q.before === 'string' ? q.before : undefined;
37
+ res.json(await read({ pool: api.pool, days: q.days, before, limit: q.limit }));
38
+ } catch (e) {
39
+ log.error(`npm-release work read failed: ${e && e.message}`);
40
+ res.status(500).json({ error: { code: 'work_read_failed', message: 'Could not read where the work is right now.' } });
41
+ }
42
+ },
43
+ );
44
+
45
+ return router;
46
+ };
@@ -0,0 +1,252 @@
1
+ 'use strict';
2
+
3
+ // modules/npm-release/work.js — "where is my work?", answered for the deploy page (task 1002622).
4
+ //
5
+ // THE COMPLAINT THIS EXISTS FOR. The owner, 2026-09-26, over a /deploy that read "Serving
6
+ // 1.19.1033 … Core 1.19.1039 is published": "I did so many tasks and changes yesterday and
7
+ // it's not showing anything that's been done." Fifteen tasks marked shipped sat between those
8
+ // two numbers, and the page named none of them. A version number is the least useful thing a
9
+ // deploy page can show; the work is the thing its reader did.
10
+ //
11
+ // SO EVERY RECENT TASK IS PLACED IN THE STAGE IT HAS ACTUALLY REACHED, in the order work
12
+ // travels:
13
+ // not_merged the ledger says done (completed / confirmed), the code is not on main
14
+ // unversioned merged after the newest version the notes reach was published
15
+ // not_live in a version newer than the one this hall runs
16
+ // live_unreleased running here, but not yet what other projects are offered
17
+ // released running here and offered to other projects
18
+ // no_version shipped, but no version names it — a ledger-only ship (a scope edit, a
19
+ // verify task) or a merge whose note could not be tied to a task
20
+ //
21
+ // THE JOIN. A version's work comes from the release notes the package was packed with, where
22
+ // each merge that carried `task <id>: <summary>` is `{ id, text }`. A hand-landed merge names
23
+ // no id, so an entry without one is matched on its text against the task's own ship summary —
24
+ // the same sentence ship.js wrote into the merge. Nothing is inferred from the changelog,
25
+ // whose CI lines ("carrier for merges landed since …") name no work at all.
26
+ //
27
+ // THE PAGE KNOWS NOTHING (deploy.js's rule, kept). Every version and verdict here is a reading
28
+ // with its source and time; a reading that failed is reported as failed, never as empty.
29
+
30
+ const fs = require('node:fs');
31
+ const path = require('node:path');
32
+ const api = require('../../src/module-api');
33
+ const { readRegistry, compareVersions } = require('./registry');
34
+
35
+ // The package this server runs — the one whose "live" version is this process's own core.
36
+ // Any other package has no "live on this hall" stage: nothing here runs it.
37
+ const CORE_PACKAGE = '@bongos/core';
38
+ const VERSION_RE = /^\d+\.\d+\.\d+$/;
39
+ const TEXT_MAX = 400;
40
+ const DEFAULT_DAYS = 14;
41
+ const MAX_DAYS = 60;
42
+ const DEFAULT_LIMIT = 30;
43
+ const MAX_LIMIT = 100;
44
+ // The core carries its notes at its package root: <root>/release-notes.json, beside
45
+ // package.json. From modules/npm-release/ that is two levels up.
46
+ const INSTALLED_NOTES = path.join(__dirname, '..', '..', 'release-notes.json');
47
+
48
+ /** The package this project publishes. Config, not code: another project names its own. */
49
+ function configuredPackage(env = process.env) {
50
+ const v = String(env.NPM_RELEASE_PACKAGE || '').trim();
51
+ return v || CORE_PACKAGE;
52
+ }
53
+
54
+ function readInstalledNotes() {
55
+ try { return JSON.parse(fs.readFileSync(INSTALLED_NOTES, 'utf8')); }
56
+ catch { return null; }
57
+ }
58
+
59
+ function boundText(text) {
60
+ const t = String(text || '').trim();
61
+ return t.length > TEXT_MAX ? `${t.slice(0, TEXT_MAX - 1)}…` : t;
62
+ }
63
+
64
+ /** A notes file's versions, oldest first, each entry re-checked (it may have come off the network). */
65
+ function versionsIn(notes) {
66
+ if (!notes || typeof notes !== 'object' || Array.isArray(notes)) return [];
67
+ return Object.keys(notes)
68
+ .filter((v) => VERSION_RE.test(v) && Array.isArray(notes[v]))
69
+ .sort(compareVersions)
70
+ .map((v) => ({
71
+ version: v,
72
+ entries: notes[v]
73
+ .filter((e) => e && typeof e.text === 'string' && e.text.trim())
74
+ .map((e) => (/^\d+$/.test(String(e.id || '')) ? { id: String(e.id), text: boundText(e.text) } : { text: boundText(e.text) })),
75
+ }));
76
+ }
77
+
78
+ /**
79
+ * Where each task stands. PURE — every input injected.
80
+ *
81
+ * `tasks`: ledger rows `{ id, title, status, at, builder, summary }`.
82
+ * `versions`: `versionsIn` output. `live` / `released`: versions or null. `coveredAt`: when
83
+ * the newest version the notes reach was published (ISO) or null when unknown.
84
+ */
85
+ function placeTasks({ tasks, versions, live, released, coveredAt }) {
86
+ const byId = new Map();
87
+ const byText = new Map();
88
+ for (const { version, entries } of versions) {
89
+ for (const e of entries) {
90
+ if (e.id && !byId.has(e.id)) byId.set(e.id, version);
91
+ if (!e.id && !byText.has(e.text)) byText.set(e.text, version);
92
+ }
93
+ }
94
+ const covered = coveredAt ? Date.parse(coveredAt) : NaN;
95
+ const stages = { not_merged: [], unversioned: [], not_live: [], live_unreleased: [], released: [], no_version: [] };
96
+ for (const t of tasks) {
97
+ const row = { id: String(t.id), title: t.title, status: t.status, builder: t.builder || null, at: t.at };
98
+ if (t.status !== 'shipped') { stages.not_merged.push(row); continue; }
99
+ const version = byId.get(row.id) || (t.summary ? byText.get(boundText(t.summary)) : undefined);
100
+ if (!version) {
101
+ // Merged after the notes' newest version went out: it is waiting for the next one.
102
+ // Before it (or with no way to tell): no version names it.
103
+ const at = Date.parse(t.at);
104
+ if (Number.isFinite(covered) && Number.isFinite(at) && at > covered) stages.unversioned.push(row);
105
+ else stages.no_version.push(row);
106
+ continue;
107
+ }
108
+ row.version = version;
109
+ const offered = released ? compareVersions(version, released) <= 0 : null;
110
+ if (live && compareVersions(version, live) > 0) {
111
+ // Offered to other projects before this hall ran it — the order the Release step
112
+ // exists to prevent. Said, not hidden.
113
+ if (offered) row.offered = true;
114
+ stages.not_live.push(row);
115
+ } else if (offered === false) stages.live_unreleased.push(row);
116
+ else stages.released.push(row);
117
+ }
118
+ return stages;
119
+ }
120
+
121
+ /** The version history, newest first, from `before` (exclusive) down, `limit` long. PURE. */
122
+ function history({ versions, published, live, released, newest, before, limit, titles }) {
123
+ const known = new Map(versions.map((v) => [v.version, v.entries]));
124
+ // Versions the registry lists but the notes do not reach are shown as unread, never
125
+ // dropped: a history with a silent hole in it reads as "nothing happened there".
126
+ const all = new Set(versions.map((v) => v.version));
127
+ for (const v of Object.keys(published || {})) if (!newest || compareVersions(v, newest) <= 0) all.add(v);
128
+ let list = [...all].sort(compareVersions).reverse();
129
+ if (before && VERSION_RE.test(before)) list = list.filter((v) => compareVersions(v, before) < 0);
130
+ const page = list.slice(0, limit);
131
+ return {
132
+ more: list.length > page.length,
133
+ versions: page.map((v) => ({
134
+ version: v,
135
+ published_at: (published && published[v]) || null,
136
+ live: live === v,
137
+ released: released === v,
138
+ entries: known.has(v)
139
+ ? known.get(v).map((e) => (e.id ? { id: e.id, title: titles.get(e.id) || null, text: e.text } : { text: e.text }))
140
+ : null,
141
+ })),
142
+ };
143
+ }
144
+
145
+ async function recentTasks(pool, days) {
146
+ // Who did it: the shipper once shipped, the last claimant before that.
147
+ const { rows } = await pool.query(
148
+ `SELECT t.id, t.title, t.status, t.value_summary,
149
+ COALESCE(t.shipped_at, t.updated_at) AS at,
150
+ COALESCE(sb.display_name, sb.github_login, cb.display_name, cb.github_login) AS builder
151
+ FROM tasks t
152
+ LEFT JOIN builders sb ON sb.id = t.shipped_by
153
+ LEFT JOIN LATERAL (
154
+ SELECT c.builder_id FROM claims c WHERE c.task_id = t.id ORDER BY c.claimed_at DESC LIMIT 1
155
+ ) lc ON true
156
+ LEFT JOIN builders cb ON cb.id = lc.builder_id
157
+ WHERE t.status IN ('completed', 'confirmed', 'shipped')
158
+ AND COALESCE(t.shipped_at, t.updated_at) >= now() - make_interval(days => $1::int)
159
+ ORDER BY COALESCE(t.shipped_at, t.updated_at) DESC
160
+ LIMIT 500`,
161
+ [days],
162
+ );
163
+ return rows.map((r) => ({
164
+ id: String(r.id),
165
+ title: r.title,
166
+ status: r.status,
167
+ summary: r.value_summary || null,
168
+ builder: r.builder || null,
169
+ at: r.at instanceof Date ? r.at.toISOString() : r.at,
170
+ }));
171
+ }
172
+
173
+ async function taskTitles(pool, ids) {
174
+ if (!ids.length) return new Map();
175
+ const { rows } = await pool.query('SELECT id, title FROM tasks WHERE id = ANY($1::bigint[])', [ids]);
176
+ return new Map(rows.map((r) => [String(r.id), r.title]));
177
+ }
178
+
179
+ function clampInt(v, dflt, max) {
180
+ const n = Number.parseInt(v, 10);
181
+ return Number.isInteger(n) && n > 0 ? Math.min(n, max) : dflt;
182
+ }
183
+
184
+ /**
185
+ * The deploy page's whole reading, in one call — the route's only dependency.
186
+ *
187
+ * `days` bounds the ledger window, `before` + `limit` page the version history. `get`,
188
+ * `now`, `store` and `installedNotes` are the seams tests drive; `live` defaults to this
189
+ * process's own core version when the package is the core.
190
+ */
191
+ async function readWork({
192
+ pool, pkg = configuredPackage(), live, days, before, limit,
193
+ installedNotes = readInstalledNotes, get, now, store,
194
+ } = {}) {
195
+ const windowDays = clampInt(days, DEFAULT_DAYS, MAX_DAYS);
196
+ const pageLimit = clampInt(limit, DEFAULT_LIMIT, MAX_LIMIT);
197
+ const running = live !== undefined ? live : (pkg === CORE_PACKAGE ? api.CORE_VERSION : null);
198
+ const liveVersion = VERSION_RE.test(String(running || '')) ? running : null;
199
+
200
+ const local = pkg === CORE_PACKAGE ? installedNotes() : null;
201
+ const localVersions = versionsIn(local);
202
+ const localTop = localVersions.length ? localVersions[localVersions.length - 1].version : null;
203
+
204
+ const { registry, notes } = await readRegistry({
205
+ pkg, get, now, store,
206
+ haveNotesFor: (v) => !!localTop && compareVersions(localTop, v) >= 0,
207
+ });
208
+
209
+ // The newest notes win; the installed copy stands in when they could not be read.
210
+ let versions = localVersions;
211
+ let source = localTop ? { from: 'installed', version: localTop } : null;
212
+ let notesError = null;
213
+ if (notes.ok) {
214
+ versions = versionsIn(notes.notes);
215
+ source = { from: 'package', version: notes.version };
216
+ } else if (!notes.skipped) {
217
+ notesError = notes.error || 'the release notes could not be read';
218
+ }
219
+ const coversTo = versions.length ? versions[versions.length - 1].version : null;
220
+ const published = registry.ok ? registry.published : {};
221
+ const released = registry.ok ? registry.latest : null;
222
+
223
+ const tasks = await recentTasks(pool, windowDays);
224
+ const stages = placeTasks({ tasks, versions, live: liveVersion, released, coveredAt: coversTo ? published[coversTo] || null : null });
225
+
226
+ const known = new Map(tasks.map((t) => [t.id, t.title]));
227
+ const hist = history({
228
+ versions, published, live: liveVersion, released,
229
+ newest: registry.ok ? registry.newest : coversTo,
230
+ before, limit: pageLimit, titles: known,
231
+ });
232
+ const missing = [...new Set(hist.versions.flatMap((v) => (v.entries || []).filter((e) => e.id && !e.title).map((e) => e.id)))];
233
+ if (missing.length) {
234
+ const more = await taskTitles(pool, missing);
235
+ for (const v of hist.versions) for (const e of v.entries || []) if (e.id && !e.title) e.title = more.get(e.id) || null;
236
+ }
237
+
238
+ return {
239
+ package: pkg,
240
+ checked_at: new Date(now ? now() : Date.now()).toISOString(),
241
+ live: liveVersion,
242
+ released,
243
+ newest: registry.ok ? registry.newest : null,
244
+ registry: registry.ok ? { ok: true } : { ok: false, error: registry.error },
245
+ notes: { source, covers_to: coversTo, error: notesError },
246
+ window_days: windowDays,
247
+ stages,
248
+ history: hist,
249
+ };
250
+ }
251
+
252
+ module.exports = { readWork };
@@ -68,8 +68,12 @@ const ALWAYS_ON_CORE = Object.freeze([
68
68
  // provider ("enabled ONLY on the hub instance") and `provisioning` is the surface
69
69
  // that stands other instances up — a project created THROUGH the hub is a
70
70
  // consumer of both, never a second copy of them. Named so the integrity test can
71
- // refuse them by construction rather than by anyone remembering.
72
- const HUB_ONLY_MODULES = Object.freeze(['platform-identity', 'provisioning']);
71
+ // refuse them by construction rather than by anyone remembering. `npm-release` is the
72
+ // deploy page of the one project that PUBLISHES the core (task 1002622): every project
73
+ // the hub creates receives those releases, so none is offered the publisher's page. It
74
+ // is package-agnostic by construction, so offering it to a project that publishes its
75
+ // own npm package is a later, deliberate removal from this list — with picker copy.
76
+ const HUB_ONLY_MODULES = Object.freeze(['platform-identity', 'provisioning', 'npm-release']);
73
77
 
74
78
  // Modules that were once offered and are being WITHDRAWN. A retired key is still
75
79
  // a module on disk — its manifest, routes and tables are deleted by later tasks —
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.1038",
3
+ "version": "1.19.1040",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.1038",
9
+ "version": "1.19.1040",
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.1038",
3
+ "version": "1.19.1040",
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",
@@ -7509,9 +7509,33 @@
7509
7509
  }
7510
7510
  ],
7511
7511
  "1.19.1038": [
7512
+ {
7513
+ "id": "1004289",
7514
+ "text": "When the code host refuses to run the automatic tests at all, for example because the account's allowance ran out, the shipping tool now says so plainly and names the owner's fix, instead of telling the builder to fix code tha"
7515
+ },
7512
7516
  {
7513
7517
  "id": "1002840",
7514
7518
  "text": "The grader can now call AI models from other companies through one plug-in point, ready for a second-opinion reviewer; nothing changes today — it still uses only Claude and spends nothing new."
7515
7519
  }
7520
+ ],
7521
+ "1.19.1039": [
7522
+ {
7523
+ "id": "1004290",
7524
+ "text": "A new release of the core is now published only after the check that it can set up a brand-new empty database has passed, so a release that cannot install cleanly can no longer reach projects."
7525
+ }
7526
+ ],
7527
+ "1.19.1040": [
7528
+ {
7529
+ "id": "1002622",
7530
+ "text": "The Deploy page now lists every recently finished task by name and shows how far each one has got — not merged yet, published, running on Bongos, or released — plus what each version carried."
7531
+ },
7532
+ {
7533
+ "id": "1004282",
7534
+ "text": "Wrote up tonight's work on accounts and community: 12 changes finished, the site upgraded, and your page-placement decisions turned into tasks. New code is paused until the GitHub build budget resets next month."
7535
+ },
7536
+ {
7537
+ "id": "1003368",
7538
+ "text": "Closed a security hole where a builder's branch could choose the program the server runs while auto-resolving merge conflicts; the server now always uses its own trusted copy."
7539
+ }
7516
7540
  ]
7517
7541
  }
@@ -64,7 +64,7 @@ const STATIC_ROOTS = new Set([
64
64
  // brittle — a line shift re-opens the review rather than carrying a stale approval.
65
65
  const EXEMPTIONS = [
66
66
  {
67
- at: 'modules/lifecycle/conflict-resolve.js:382',
67
+ at: 'modules/lifecycle/conflict-resolve.js:390',
68
68
  why: 'THE site this check was written for (audit ref B1), and the one place a runtime path is '
69
69
  + 'REQUIRED rather than tolerated. Each generator derives its repo root from its own '
70
70
  + '__dirname, so running the SERVER\'s copy would regenerate the deploy tree instead of the '
@@ -49,14 +49,30 @@ const MERGE_DRIVER_NAME = 'otb-regen';
49
49
  // - `.name` alone is the fatal state above.
50
50
  // So writing `.driver` FIRST makes the intermediate state safe, and removing the whole
51
51
  // section on failure makes even a mid-write abort leave no trace.
52
- function registerMergeDriver(root, { run = execFileSync } = {}) {
52
+ //
53
+ // `driverScript` (task 1003368, audit B2) pins the command to an ABSOLUTE path. The default
54
+ // is the RELATIVE `scripts/gds/git-merge-regen.js`, which git resolves against the merge's
55
+ // cwd — the checked-out tree — so on a clone of a builder's branch it names the BRANCH's copy
56
+ // of the script. The server's conflict resolver passes its own checkout's copy so the command
57
+ // git runs is never one the branch could have written. Only the server-side caller may pass
58
+ // it; a builder's own clone keeps the relative form, which is what they want to run.
59
+ function mergeDriverCommand(driverScript) {
60
+ if (!driverScript) return 'node scripts/gds/git-merge-regen.js %O %A %B %P';
61
+ // Forward slashes + double quotes: git hands this to `sh -c`, and a path with spaces or a
62
+ // Windows backslash would otherwise split or be eaten as an escape.
63
+ const abs = path.resolve(driverScript).replace(/\\/g, '/');
64
+ if (/["$`]/.test(abs)) throw new Error(`unsafe merge driver path: ${abs}`);
65
+ return `node "${abs}" %O %A %B %P`;
66
+ }
67
+
68
+ function registerMergeDriver(root, { run = execFileSync, driverScript } = {}) {
53
69
  // execFileSync (arg array, no shell) so the literal %O/%A/%B/%P placeholders and
54
70
  // the spaces in the command survive verbatim on every platform (a shell would
55
71
  // mangle %VARS on Windows cmd).
56
72
  try {
57
73
  // .driver FIRST — a section carrying only .driver is valid and works.
58
74
  run('git', ['config', `merge.${MERGE_DRIVER_NAME}.driver`,
59
- 'node scripts/gds/git-merge-regen.js %O %A %B %P'], { cwd: root, stdio: 'ignore' });
75
+ mergeDriverCommand(driverScript)], { cwd: root, stdio: 'ignore' });
60
76
  // .name SECOND — cosmetic (it is what `git config --list` shows); if this throws,
61
77
  // the section left behind is the safe driver-only one.
62
78
  run('git', ['config', `merge.${MERGE_DRIVER_NAME}.name`,
@@ -169,6 +169,11 @@ const CARDS = Object.freeze({
169
169
  'An automatic test failed on your work, so it has not been added to the project.',
170
170
  'I will run that test here, fix what it found, and send the work again. Sending the same work again would fail the same way.',
171
171
  null, FAULT),
172
+ // task 1004289: the tests never ran at all (the account cannot run them right now).
173
+ ci_not_started: card(
174
+ 'The automatic tests never ran on your work: the code host did not start them, because the account has run out of test time or has a payment problem. Nothing was tested, so nothing needs fixing.',
175
+ 'I will not change the work. Once the owner restores the test time, I will start the tests again on the same work and it will be added on its own.',
176
+ 'The project owner, to restore the account\'s test time on the code host.', FAULT),
172
177
  non_required_check_failing: card(
173
178
  'An optional automatic test failed on your work.',
174
179
  'I will look at what it found. It does not stop the work being added, but it is usually worth fixing.',
@@ -0,0 +1,142 @@
1
+ #!/usr/bin/env node
2
+ // scripts/gds/publish-integration-gate.js — the publish lane waits for the integration
3
+ // lane's verdict on the exact commit it is about to release (task 1004290).
4
+ //
5
+ // WHY. publish.yml fires on the `unit` workflow's success, and `unit` is DB-free by
6
+ // design. The proof that the packaged core can set up an EMPTY database and serve a
7
+ // DB-backed route — tests/consumer_layout_boot.mjs, task 1002863 — runs in the
8
+ // `integration` workflow, on the same main push. Without this gate that result arrived
9
+ // after the publish and gated nothing: a core that installs and boots but cannot migrate
10
+ // its own database still reached npm, and from there the nightly tenant upgrade sweep.
11
+ //
12
+ // WHAT IT COSTS, MEASURED 2026-09-26 (not assumed). Both lanes start on the same push, and
13
+ // integration is the SHORTER one: on the two commits right after task 1002863 landed,
14
+ // integration took 247s and 245s against unit's 351s and 335s. So when unit's success fires
15
+ // the publish, integration has usually already answered and this gate is a single API
16
+ // read. It runs inside the existing publish job — no new job — and it polls only in the
17
+ // rare case integration is still going. Over 299 main pushes (2026-09-20 → 26) integration
18
+ // was green 254 times, cancelled 41 times (a newer merge superseded it) and red 3 times,
19
+ // all three the Actions-budget outage of 2026-09-25 — so it is not a flaky lane that would
20
+ // stall every release.
21
+ //
22
+ // THE VERDICTS (decideIntegrationGate, pure):
23
+ // go the newest push-event run on this commit succeeded → publish
24
+ // wait no run yet, or it is still going → poll
25
+ // superseded it was cancelled — almost always a newer merge's run replacing it under
26
+ // integration.yml's cancel-in-progress group. Nothing is published; that
27
+ // newer merge fires its own publish, which asks about its own commit.
28
+ // red anything else (failure, timed_out, startup_failure …) → refuse, loudly
29
+ // Only PUSH-event runs count: the weekly scheduled run on main carries HEAD's sha but runs
30
+ // only the parked-set ratchet, so its conclusion says nothing about this commit.
31
+ //
32
+ // OUTPUT: `go=true|false` to $GITHUB_OUTPUT. Exit 0 for go and superseded, 1 for red,
33
+ // timeout or an unreadable API (a refusal that must be seen), 2 for bad arguments.
34
+ //
35
+ // node scripts/gds/publish-integration-gate.js --sha <40-hex> --repo <owner/name>
36
+ // [--timeout-min 12] [--interval-s 20] token: GH_TOKEN or GITHUB_TOKEN
37
+
38
+ 'use strict';
39
+
40
+ const fs = require('node:fs');
41
+
42
+ const WORKFLOW_FILE = 'integration.yml';
43
+ const DEFAULT_TIMEOUT_MIN = 12; // inside publish.yml's 20-minute job, leaving room to pack + publish
44
+ const DEFAULT_INTERVAL_S = 20;
45
+ const SHA_RE = /^[0-9a-f]{40}$/;
46
+ const REPO_RE = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
47
+
48
+ // decideIntegrationGate — PURE. `runs` is list-workflow-runs' `workflow_runs` for one
49
+ // head sha and the push event. The newest run decides (a re-push of the same sha, or a
50
+ // re-run, supersedes an older verdict).
51
+ function decideIntegrationGate(runs) {
52
+ const list = Array.isArray(runs) ? runs.filter((r) => r && typeof r === 'object') : [];
53
+ if (!list.length) return { verdict: 'wait', run: null };
54
+ const newest = list.slice().sort((a, b) =>
55
+ (Number(b.run_number) || 0) - (Number(a.run_number) || 0)
56
+ || String(b.created_at || '').localeCompare(String(a.created_at || '')))[0];
57
+ if (newest.status !== 'completed') return { verdict: 'wait', run: newest };
58
+ if (newest.conclusion === 'success') return { verdict: 'go', run: newest };
59
+ if (newest.conclusion === 'cancelled') return { verdict: 'superseded', run: newest };
60
+ return { verdict: 'red', run: newest };
61
+ }
62
+
63
+ function runsUrl(repo, sha) {
64
+ return `https://api.github.com/repos/${repo}/actions/workflows/${WORKFLOW_FILE}/runs?head_sha=${sha}&event=push&per_page=20`;
65
+ }
66
+
67
+ // waitForIntegration — poll until the gate has a verdict or the time is up. A 401/403/404
68
+ // is not something waiting fixes (a missing `actions: read`, a renamed workflow), so it
69
+ // ends the wait at once as 'unreadable'. Any other failed read is retried. Everything that
70
+ // touches the world is injectable for tests/publish_integration_gate.mjs.
71
+ async function waitForIntegration({
72
+ sha, repo, token, timeoutMs, intervalMs,
73
+ fetchImpl = globalThis.fetch, sleep = (ms) => new Promise((r) => setTimeout(r, ms)), now = Date.now,
74
+ }) {
75
+ const deadline = now() + timeoutMs;
76
+ let last = { verdict: 'wait', run: null, why: 'no read yet' };
77
+ for (;;) {
78
+ try {
79
+ const res = await fetchImpl(runsUrl(repo, sha), {
80
+ headers: { Authorization: `Bearer ${token}`, Accept: 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28' },
81
+ });
82
+ if ([401, 403, 404].includes(res.status)) return { verdict: 'unreadable', run: null, why: `HTTP ${res.status}` };
83
+ if (res.ok) {
84
+ const body = await res.json();
85
+ last = { ...decideIntegrationGate(body && body.workflow_runs), why: null };
86
+ if (last.verdict !== 'wait') return last;
87
+ } else {
88
+ last = { ...last, why: `HTTP ${res.status}` };
89
+ }
90
+ } catch (e) {
91
+ last = { ...last, why: (e && e.message) || String(e) };
92
+ }
93
+ if (now() + intervalMs > deadline) return { verdict: 'timeout', run: last.run, why: last.why };
94
+ await sleep(intervalMs);
95
+ }
96
+ }
97
+
98
+ // The one line per outcome the Actions log shows, and what the rest of the lane reads.
99
+ function reportLines(verdict, { sha, run, why, timeoutMin }) {
100
+ const short = String(sha).slice(0, 8);
101
+ const url = run && run.html_url ? ` ${run.html_url}` : '';
102
+ switch (verdict) {
103
+ case 'go':
104
+ return { go: true, code: 0, line: `integration is green on ${short}${url} — publishing.` };
105
+ case 'superseded':
106
+ return { go: false, code: 0, line: `::notice title=not published — integration was cancelled on ${short}::Usually a newer merge's run replaced it; that merge fires its own publish, which checks its own commit.${url}` };
107
+ case 'red':
108
+ return { go: false, code: 1, line: `::error title=not published — integration is red on ${short}::The integration lane (${run && run.conclusion}) runs the proof that the packaged core can migrate an empty database (task 1002863). A core that fails it must not reach npm. Fix main; the next merge publishes everything.${url}` };
109
+ case 'unreadable':
110
+ return { go: false, code: 1, line: `::error title=not published — cannot read the integration lane::${why}. publish.yml needs 'permissions: actions: read', and the workflow file must still be ${WORKFLOW_FILE}.` };
111
+ default:
112
+ return { go: false, code: 1, line: `::error title=not published — integration had no verdict on ${short} after ${timeoutMin} min::${why ? `Last read: ${why}. ` : ''}Re-run this workflow once integration finishes, or let the next merge publish.${url}` };
113
+ }
114
+ }
115
+
116
+ function argValue(argv, flag) {
117
+ const i = argv.indexOf(flag);
118
+ return i >= 0 && i + 1 < argv.length ? argv[i + 1] : null;
119
+ }
120
+
121
+ async function main(argv = process.argv.slice(2), env = process.env) {
122
+ const sha = String(argValue(argv, '--sha') || '').toLowerCase();
123
+ const repo = String(argValue(argv, '--repo') || env.GITHUB_REPOSITORY || '');
124
+ const token = env.GH_TOKEN || env.GITHUB_TOKEN || '';
125
+ const timeoutMin = Number(argValue(argv, '--timeout-min') || DEFAULT_TIMEOUT_MIN);
126
+ const intervalS = Number(argValue(argv, '--interval-s') || DEFAULT_INTERVAL_S);
127
+ if (!SHA_RE.test(sha) || !REPO_RE.test(repo) || !token || !(timeoutMin > 0) || !(intervalS > 0)) {
128
+ console.error('usage: publish-integration-gate.js --sha <40-hex> --repo <owner/name> [--timeout-min N] [--interval-s N] (token: GH_TOKEN or GITHUB_TOKEN)');
129
+ return 2;
130
+ }
131
+ const result = await waitForIntegration({ sha, repo, token, timeoutMs: timeoutMin * 60000, intervalMs: intervalS * 1000 });
132
+ const out = reportLines(result.verdict, { sha, run: result.run, why: result.why, timeoutMin });
133
+ console.log(out.line);
134
+ if (env.GITHUB_OUTPUT) fs.appendFileSync(env.GITHUB_OUTPUT, `go=${out.go}\n`);
135
+ return out.code;
136
+ }
137
+
138
+ if (require.main === module) {
139
+ main().then((code) => { process.exitCode = code; }, (e) => { console.error(e); process.exitCode = 1; });
140
+ }
141
+
142
+ module.exports = { decideIntegrationGate, waitForIntegration, reportLines, main, WORKFLOW_FILE, runsUrl };
@@ -72,6 +72,7 @@ const PUBLISH_ALLOWLIST = [
72
72
  'modules/discord/',
73
73
  'modules/provisioning/', // instance-hosting control plane — CORE like its sibling dev-box (same do-api/cf-tunnel toolchain, all core). Reclassified IN so the core CLI scripts/gds/provision.js resolves it from the core package post-split, not a MODULE_NOT_FOUND (task 2135). default:false — shipped ≠ enabled.
74
74
  'modules/agents/', // the declarative agent registry + spawn seam (goal 1000038) — CORE: its own manifest declares maintenance.status 'core-maintained', and it consumes the core `grade` port, so a released core without it cannot run the MAS machinery the methodology describes. default:false — shipped is not enabled (ADR 0083), exactly like provisioning + platform-identity below. Omitted at module-creation time (tasks 1002485/1002486): the omission was invisible until tests/agents_spawn.mjs became the first PUBLISHABLE file to import it and the R89 boundary guard caught it (task 1002489).
75
+ 'modules/npm-release/', // the deploy page's "where is the work" + version history (task 1002622). default:false — on only in the hall that publishes the core. MUST be here: modules/hall-ui/ ships as one subtree, so a released core without this carries deploy.js's loader and the #nr-work mounts with no routes behind them — the copy-desk asymmetry above.
75
76
  'modules/platform-identity/', // hub identity provider (ADR 0141) — ships in core, enabled ONLY on the hub (cloudbongos.com). MUST be here or the hub's enabledModules() throws "unknown module" and provision.js's federation load MODULE_NOT_FOUNDs (task 1002235, same rationale as provisioning above). default:false — shipped ≠ enabled; a vanilla/self-hosted instance never turns it on.
76
77
  // --- neutral (customer-0) config starters + their schemas (the filled-in *.json twins are denied) ---
77
78
  'config/branding.neutral.json',