@bongos/core 1.20.79 → 1.20.81

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 (53) hide show
  1. package/.bongos-core.json +104 -49
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +8 -0
  4. package/clients/bongos-client/index.cjs +8 -0
  5. package/clients/bongos-client/index.d.ts +13 -0
  6. package/clients/bongos-client/index.mjs +8 -0
  7. package/docs/adr/0161-publish-on-merge.md +1 -1
  8. package/docs/adr/0361-merges-publish-as-candidates-release-decides-what-others-are-offered.md +59 -0
  9. package/docs/adr/README.md +1 -0
  10. package/docs/api/openapi.json +258 -4
  11. package/docs/api-reference.md +7 -3
  12. package/docs/copy-inventory.md +59 -53
  13. package/docs/copy-registry.json +120 -66
  14. package/docs/module-api-changelog.md +4 -0
  15. package/docs/page-inventory.json +2 -1
  16. package/docs/page-readings.json +86 -81
  17. package/docs/recipes/private-npm-distribution.md +2 -0
  18. package/docs/recipes/upgrading-the-core.md +1 -1
  19. package/modules/autonomy/cadence.js +3 -0
  20. package/modules/autonomy/db.js +136 -0
  21. package/modules/autonomy/fence.js +88 -3
  22. package/modules/autonomy/migrations/autonomy_005_builder_scope.sql +55 -0
  23. package/modules/autonomy/routes/autonomy.js +123 -3
  24. package/modules/government/catalog.js +9 -1
  25. package/modules/government/migrations/government_022_autonomy_run.sql +38 -0
  26. package/modules/hall-ui/public/gate.html +5 -2
  27. package/modules/hall-ui/public/gate.js +73 -4
  28. package/modules/hall-ui/public/settings-autobongos.js +156 -0
  29. package/modules/hall-ui/public/settings.html +19 -0
  30. package/modules/hall-ui/public/settings.js +1 -0
  31. package/modules/lifecycle/module.json +2 -1
  32. package/modules/lifecycle/routes/lifecycle.js +7 -0
  33. package/modules/lifecycle/workflow-dispatch.js +45 -0
  34. package/modules/npm-release/module.json +2 -1
  35. package/modules/npm-release/public/work.js +74 -0
  36. package/modules/npm-release/release.js +129 -0
  37. package/modules/npm-release/routes/release.js +65 -0
  38. package/modules/npm-release/work.js +16 -0
  39. package/package-lock.json +2 -2
  40. package/package.json +1 -1
  41. package/release-notes.json +16 -0
  42. package/scripts/gds/provision-core-upgrade.js +30 -2
  43. package/scripts/gds/release-core.js +80 -0
  44. package/scripts/gds/update-channel.js +12 -3
  45. package/src/bongos/core-update.js +17 -8
  46. package/src/module-api.js +1 -1
  47. package/tests/autonomy_builder_scope.mjs +474 -0
  48. package/tests/autonomy_fence_priority.mjs +3 -1
  49. package/tests/core_update_banner.mjs +41 -5
  50. package/tests/core_upgrade_runner.mjs +66 -1
  51. package/tests/npm_release_release.mjs +195 -0
  52. package/tests/release_core.mjs +110 -0
  53. package/tests/update_channel.mjs +7 -0
@@ -0,0 +1,156 @@
1
+ // modules/hall-ui/public/settings-autobongos.js — the "Your Autobongos runner"
2
+ // panel on /builders/settings (task 1004501).
3
+ //
4
+ // WHY THE PANEL EXISTS. The fence used to be one switch and one goal list for
5
+ // every runner on the instance. Owner ruling, 2026-10-01: each builder says which
6
+ // work THEIR runner takes, and a builder who has said nothing gets no autonomous
7
+ // work. This is where they say it.
8
+ //
9
+ // WHAT IT READS. GET /autonomy/fence answers with the caller's EFFECTIVE fence: the
10
+ // project allowlist narrowed to their picks, plus `scope` (what they picked, whether
11
+ // they may run a runner, whether the owner paused them) and `project` (the
12
+ // unnarrowed allowlist they choose from). One read, so the panel cannot disagree
13
+ // with what the builder's runner is told. The server decides every one of those
14
+ // facts; this page only draws them.
15
+ //
16
+ // WHAT IT WRITES. PUT /autonomy/me/goals with the whole list, on Save. The route
17
+ // refuses a goal that is not allowlisted (409), and it is behind `autonomy.run`, so
18
+ // a builder who may not run a runner is told so here instead of shown a form that
19
+ // would fail.
20
+ //
21
+ // Its own file on the settings-interaction.js precedent: settings.js sits against
22
+ // the 1500-line ratchet, and this panel shares nothing with the rest of the page.
23
+
24
+ (() => {
25
+ 'use strict';
26
+
27
+ const API = '/api/bongos';
28
+ const { escapeHtml, $ } = window.OTB;
29
+ const { emptyStateHtml } = window.OTBKit;
30
+ const toast = (msg) => window.OTB.toast(msg);
31
+ const api = window.BongosClient.createClient({ baseUrl: '', credentials: 'same-origin', throwOnError: false });
32
+
33
+ const toAuth = () => window.location.assign(`${API}/auth/web/start`);
34
+
35
+ // The lines above the form: each one says why the runner might be idle, in the
36
+ // order the server checks them, so the first line a builder reads is the reason.
37
+ function noticesHtml(fence) {
38
+ const project = fence.project || {};
39
+ const scope = fence.scope || {};
40
+ const out = [];
41
+ if (project.enabled !== true) {
42
+ out.push(`The project's runner switch is off right now${project.paused_reason ? `: ${escapeHtml(project.paused_reason)}` : ''}. Your choices are kept and apply when it is back on.`);
43
+ }
44
+ if (scope.owner_paused) {
45
+ out.push(`The owner paused your runner${scope.owner_paused_reason ? `: ${escapeHtml(scope.owner_paused_reason)}` : ''}. Only the owner can resume it.`);
46
+ }
47
+ return out.map((t) => `<p class="prow__hint">${t}</p>`).join('');
48
+ }
49
+
50
+ // Goal names, by id. The fence carries only ids and the owner's note, so the
51
+ // names come from GET /goals — the list the Goals page draws for this same
52
+ // builder, so it names only goals they can already see. Best-effort: a failed
53
+ // read leaves the map empty and a row falls back to its number.
54
+ let goalNames = new Map();
55
+ async function loadGoalNames() {
56
+ try {
57
+ const res = await api.request('GET', `${API}/goals`);
58
+ if (res.ok && res.data && Array.isArray(res.data.goals)) {
59
+ goalNames = new Map(res.data.goals.map((g) => [Number(g.id), g.title]));
60
+ }
61
+ } catch (_) { /* names are a nicety; the picker works without them */ }
62
+ }
63
+ const goalLabel = (id) => goalNames.get(Number(id)) || `Goal ${id}`;
64
+
65
+ function render(fence) {
66
+ const body = $('#autobongos-body');
67
+ if (!body) return;
68
+ const scope = fence.scope || {};
69
+ if (scope.may_run !== true) {
70
+ body.innerHTML = emptyStateHtml('Your rank cannot run an Autobongos runner on this project. Ask the owner if you need one.');
71
+ return;
72
+ }
73
+ const goals = (fence.project && Array.isArray(fence.project.goals)) ? fence.project.goals : [];
74
+ if (!goals.length) {
75
+ body.innerHTML = noticesHtml(fence) + emptyStateHtml('The project has not allowed any goals for runners yet, so there is nothing to choose from.');
76
+ return;
77
+ }
78
+ const picked = new Set((scope.goals || []).map(Number));
79
+ body.innerHTML = `${noticesHtml(fence)}
80
+ <form id="autobongos-form">
81
+ <div class="prows">
82
+ ${goals.map((g) => {
83
+ const id = `autobongos-goal-${escapeHtml(String(g.goal_id))}`;
84
+ return `<label class="prow prow--pick" for="${id}">
85
+ <input type="checkbox" id="${id}" name="autobongos-goal" value="${escapeHtml(String(g.goal_id))}"${picked.has(Number(g.goal_id)) ? ' checked' : ''}>
86
+ <span class="prow__main">
87
+ <span class="prow__label">${escapeHtml(goalLabel(g.goal_id))}</span>
88
+ <span class="prow__desc">${escapeHtml([goalNames.has(Number(g.goal_id)) ? `goal ${g.goal_id}` : '', g.note || ''].filter(Boolean).join(' · '))}</span>
89
+ </span>
90
+ </label>`;
91
+ }).join('')}
92
+ </div>
93
+ <div class="settings-card">
94
+ <button type="submit" class="settings-btn" id="autobongos-save">Save</button>
95
+ <span class="settings-card__hint">${picked.size ? `Your runner works ${picked.size} goal${picked.size === 1 ? '' : 's'}.` : 'Your runner takes no work until you choose at least one goal.'}</span>
96
+ </div>
97
+ </form>`;
98
+ $('#autobongos-form').addEventListener('submit', (e) => {
99
+ e.preventDefault();
100
+ const ids = [...e.target.querySelectorAll('input[name="autobongos-goal"]:checked')].map((i) => Number(i.value));
101
+ save(ids);
102
+ });
103
+ }
104
+
105
+ async function load() {
106
+ const res = await api.request('GET', `${API}/autonomy/fence`);
107
+ if (res.status === 401) { toAuth(); return null; }
108
+ if (!res.ok) throw new Error(`fence GET → ${res.status}`);
109
+ return res.data;
110
+ }
111
+
112
+ async function save(goalIds) {
113
+ const status = $('#autobongos-status');
114
+ const btn = $('#autobongos-save');
115
+ if (status) status.textContent = 'Saving…';
116
+ if (btn) btn.disabled = true;
117
+ try {
118
+ const res = await api.request('PUT', `${API}/autonomy/me/goals`, { body: { goal_ids: goalIds } });
119
+ if (res.status === 401) { toAuth(); return; }
120
+ if (!res.ok) {
121
+ const env = res.data && res.data.error;
122
+ throw new Error((env && env.message) || `could not save (${res.status})`);
123
+ }
124
+ const msg = goalIds.length ? `Saved — your runner works ${goalIds.length} goal${goalIds.length === 1 ? '' : 's'}.` : 'Saved — your runner takes no work.';
125
+ if (status) status.textContent = msg;
126
+ toast(msg);
127
+ const fence = await load();
128
+ if (fence) render(fence);
129
+ } catch (err) {
130
+ console.error('[settings] save autobongos goals failed', err);
131
+ if (status) status.textContent = `Could not save: ${err.message}`;
132
+ toast('Could not save your runner’s goals. Try again.');
133
+ if (btn) btn.disabled = false;
134
+ }
135
+ }
136
+
137
+ async function setup() {
138
+ const section = $('#autobongos-scroll');
139
+ if (!section) return;
140
+ try {
141
+ const [fence] = await Promise.all([load(), loadGoalNames()]);
142
+ if (!fence) return;
143
+ render(fence);
144
+ section.hidden = false;
145
+ } catch (err) {
146
+ // Its own try/catch, like every other panel: a failing read shows this
147
+ // panel's own error rather than taking the settings page down with it.
148
+ console.error('[settings] failed to load autobongos scope', err);
149
+ section.hidden = false;
150
+ $('#autobongos-body').innerHTML =
151
+ '<p class="voices-empty">Your runner settings could not be loaded. Reload the page to try again.</p>';
152
+ }
153
+ }
154
+
155
+ setup();
156
+ })();
@@ -163,6 +163,23 @@
163
163
  <p class="section-note">Ending a session takes effect immediately; the token stops working on its very next request.</p>
164
164
  </section>
165
165
 
166
+ <!-- YOUR AUTOBONGOS RUNNER — which goals your own unattended runner
167
+ works (task 1004501). Rendered by settings-autobongos.js against
168
+ GET /autonomy/fence (your effective fence) and PUT
169
+ /autonomy/me/goals. Picking nothing means your runner takes no
170
+ work. The project allowlist and the master switch stay the
171
+ owner's, on the Gate page. -->
172
+ <section class="scroll" id="autobongos-scroll" aria-labelledby="autobongos-h" data-group="access" hidden>
173
+ <h2 id="autobongos-h" class="scroll__h">Your Autobongos runner</h2>
174
+ <p class="scroll__lede">
175
+ Choose which goals your own runner works while you are away. You can
176
+ only choose from the goals the project allows. If you choose none, your
177
+ runner takes no work.
178
+ </p>
179
+ <div id="autobongos-body" aria-live="polite"></div>
180
+ <p class="settings-status" id="autobongos-status" role="status" aria-live="polite"></p>
181
+ </section>
182
+
166
183
  <!-- IMAGE KEY — per-builder Gemini key (task 1013 / ADR 0073).
167
184
  GET/PUT/DELETE /api/bongos/me/art-key/own. Write-only (set/replace/
168
185
  remove); the full key is never shown again after save (masked,
@@ -425,5 +442,7 @@
425
442
  <script src="/builders/settings-render.js?v=2026-09-16-render-levels"></script>
426
443
  <!-- task 1004296: Settings → Software update. Own file on the same precedent. -->
427
444
  <script src="/builders/settings-software-update.js?v=2026-09-26-update"></script>
445
+ <!-- task 1004501: Settings → Your Autobongos runner. Own file on the same precedent. -->
446
+ <script src="/builders/settings-autobongos.js?v=2026-10-01-autobongos"></script>
428
447
  </body>
429
448
  </html>
@@ -1066,6 +1066,7 @@
1066
1066
  'privacy': 'account-hub-scroll',
1067
1067
  'disciplines': 'craft-scroll',
1068
1068
  'cli': 'cli-scroll',
1069
+ 'autobongos': 'autobongos-scroll',
1069
1070
  'art-key': 'art-key',
1070
1071
  'interaction': 'interaction-scroll',
1071
1072
  'wandering': 'wandering-scroll',
@@ -41,7 +41,8 @@
41
41
  "provides": [
42
42
  "lifecycle",
43
43
  "lifecycle.kickoffSeed",
44
- "lifecycle.removals"
44
+ "lifecycle.removals",
45
+ "lifecycle.workflowDispatch"
45
46
  ],
46
47
  "consumes": []
47
48
  }
@@ -53,6 +53,13 @@ function registerSeams() {
53
53
  if (!api.hasProvider('lifecycle')) {
54
54
  api.registerProvider('lifecycle', lifecycle);
55
55
  }
56
+ // The one GitHub act another module may borrow (task 1004298): start a workflow. The npm-release
57
+ // module's Release button runs publish.yml through it. A port of one function rather than the
58
+ // github-push module whole, so the borrower can start a workflow and do nothing else with the
59
+ // server's GitHub credential.
60
+ if (!api.hasProvider('lifecycle.workflowDispatch')) {
61
+ api.registerProvider('lifecycle.workflowDispatch', { dispatchWorkflow: (args) => require('../workflow-dispatch').dispatchWorkflow(args) });
62
+ }
56
63
  if (_cascadeListenerOff) _cascadeListenerOff();
57
64
  // Torn down with its sibling: the factory runs again every time a test rebuilds
58
65
  // the router, and a leaked subscription would fire this cascade twice.
@@ -0,0 +1,45 @@
1
+ 'use strict';
2
+
3
+ // modules/lifecycle/workflow-dispatch.js — the one GitHub act another module may borrow.
4
+ // Lent through the `lifecycle.workflowDispatch` port (routes/lifecycle.js); see ADR 0361.
5
+ // Kept beside github-push.js rather than in it: it shares that file's credential and headers,
6
+ // and nothing else.
7
+
8
+ const { resolveToken, ghHeaders, isSafeBranch } = require('./github-push');
9
+ const { repoInfo: { loadRepoInfo } } = require('../../src/module-api');
10
+
11
+ // dispatchWorkflow — start a GitHub Actions workflow on this repo (task 1004298, ADR 0361). The
12
+ // Release button on /deploy uses it to run publish.yml's release job, so the npm credential never
13
+ // leaves GitHub: the server holds only the GitHub App key it already holds, and the App needs
14
+ // Actions: write for this one call. Returns { ok:true } on GitHub's 204, otherwise
15
+ // { ok:false, code, status? } — UNCONFIGURED (no credential), NO_REPO_INFO, BAD_INPUT, REFUSED
16
+ // (GitHub said no: 403 = the App lacks Actions: write, 404 = no such workflow or no access,
17
+ // 422 = an input the workflow does not declare), UNREACHABLE. Never throws.
18
+ //
19
+ // ONLY THE WORKFLOWS NAMED HERE can be started. The port lends the server's GitHub credential to
20
+ // another module; an allowlist keeps a buggy or future borrower from running any other workflow in
21
+ // the repo. Adding one is a reviewed edit to this line.
22
+ const DISPATCHABLE_WORKFLOWS = Object.freeze(['publish.yml']);
23
+ async function dispatchWorkflow({ workflow, ref = 'main', inputs = {} }, deps = {}) {
24
+ if (!DISPATCHABLE_WORKFLOWS.includes(String(workflow || '')) || !isSafeBranch(ref)) return { ok: false, code: 'BAD_INPUT' };
25
+ let token;
26
+ try { token = await resolveToken(deps); } catch (_) { return { ok: false, code: 'UNCONFIGURED' }; }
27
+ if (!token) return { ok: false, code: 'UNCONFIGURED' };
28
+ const repoInfo = deps.repoInfo || loadRepoInfo();
29
+ if (!repoInfo || repoInfo.error) return { ok: false, code: 'NO_REPO_INFO' };
30
+ const { owner, repo } = repoInfo;
31
+ const doFetch = deps.fetchImpl || fetch;
32
+ try {
33
+ const res = await doFetch(`https://api.github.com/repos/${owner}/${repo}/actions/workflows/${workflow}/dispatches`, {
34
+ method: 'POST',
35
+ headers: { ...ghHeaders(token), 'Content-Type': 'application/json' },
36
+ body: JSON.stringify({ ref, inputs }),
37
+ });
38
+ if (res.status === 204 || res.ok) return { ok: true };
39
+ return { ok: false, code: 'REFUSED', status: res.status };
40
+ } catch (_) {
41
+ return { ok: false, code: 'UNREACHABLE' };
42
+ }
43
+ }
44
+
45
+ module.exports = { dispatchWorkflow };
@@ -16,7 +16,8 @@
16
16
  "routes": [
17
17
  "work",
18
18
  "task-where",
19
- "preview"
19
+ "preview",
20
+ "release"
20
21
  ],
21
22
  "uiSections": [
22
23
  "task-where"
@@ -136,6 +136,77 @@
136
136
  </li>`;
137
137
  }
138
138
 
139
+ // ---- the Release button (task 1004298) ---------------------------------------------------
140
+ //
141
+ // Merges publish as candidates: this hall runs each one, no other project is offered it. Release
142
+ // makes a version the one every other project is offered. Beside each button, the evidence the
143
+ // owner decides on — how long the version has run here, and whether it was ever rolled back —
144
+ // read by the server from this hall's upgrade ledger.
145
+ let releasing = '';
146
+ let releaseNote = null; // { ok, text } — the last press's answer, shown until the next read
147
+
148
+ function hoursText(h) {
149
+ if (h == null) return 'not in this hall\'s upgrade ledger';
150
+ if (h < 1) return 'under an hour';
151
+ if (h < 48) return `${Math.round(h)} hour${Math.round(h) === 1 ? '' : 's'}`;
152
+ return `${Math.round(h / 24)} days`;
153
+ }
154
+
155
+ function candidateRow(c) {
156
+ const ran = c.on_hall_since == null
157
+ ? 'This hall\'s upgrade ledger does not show it going in.'
158
+ : c.still_running ? `Running here for ${hoursText(c.hours_on_hall)}.` : `Ran here for ${hoursText(c.hours_on_hall)} before a newer version replaced it.`;
159
+ const facts = [];
160
+ if (c.rolled_back) facts.push('<span class="ov-fact"><b>was rolled back here</b></span>');
161
+ if (c.still_running) facts.push('<span class="ov-fact">running now</span>');
162
+ const busy = releasing === c.version;
163
+ return `<li class="ov-row ov-row--dense${c.rolled_back ? ' nr-upgrade--back' : ''}">
164
+ <div class="ov-row__main">
165
+ <span class="ov-row__title ov-key">${escapeHtml(c.version)}</span>
166
+ <p class="ov-row__sub">${escapeHtml(ran)}${c.rolled_back ? ' A move to it did not come back healthy at least once, and was undone.' : ''}</p>
167
+ ${facts.length ? `<div class="ov-row__facts">${facts.join('')}</div>` : ''}
168
+ </div>
169
+ <div class="ov-row__right"><button type="button" class="ov-toggle" data-release="${escapeHtml(c.version)}"${releasing ? ' disabled' : ''}>${busy ? 'Starting…' : 'Release'}</button></div>
170
+ </li>`;
171
+ }
172
+
173
+ function releaseHtml(d) {
174
+ const r = d.release;
175
+ if (!r) return '';
176
+ let body;
177
+ if (!r.ok) body = `<p class="ov-row__sub">${escapeHtml(r.error || 'The registry could not be read')}, so nothing can be released right now.</p>`;
178
+ else if (!r.candidates.length) body = `<p class="ov-row__sub">Nothing is waiting: every version this hall runs is already released${d.released ? ` (the release is ${escapeHtml(d.released)})` : ''}.</p>`;
179
+ else body = `<ul class="ov-rows">${r.candidates.map(candidateRow).join('')}</ul>`;
180
+ const note = releaseNote ? `<div class="ov-note${releaseNote.ok ? '' : ' ov-note--warn'}"><p>${escapeHtml(releaseNote.text)}</p></div>` : '';
181
+ const ledger = r.ok && r.ledger_error ? `<p class="ov-row__sub">${escapeHtml(r.ledger_error)}.</p>` : '';
182
+ return `<div class="ov-group" data-release-section>
183
+ <div class="ov-group__head">
184
+ <span class="ov-group__name">Release</span>
185
+ ${r.ok ? `<span class="ov-group__n">${r.candidates.length}</span>` : ''}
186
+ </div>
187
+ <p class="ov-row__sub">New versions run here first and are offered to no other project. Release makes a version, and everything merged before it, what every other project is offered: their Settings, their banner, their automatic updates.${d.released ? ` Released now: ${escapeHtml(d.released)}.` : ''}</p>
188
+ ${note}${ledger}${body}
189
+ </div>`;
190
+ }
191
+
192
+ async function release(version) {
193
+ if (releasing) return;
194
+ if (!window.confirm(`Release ${version} to every project?\n\nEvery other project will be offered ${version} and everything merged before it. This cannot be pressed back; a newer version can be released after it.`)) return;
195
+ releasing = version;
196
+ releaseNote = null;
197
+ paint();
198
+ try {
199
+ const res = await client.request('POST', `${API}/npm-release/release`, { body: { version } });
200
+ const msg = (res.data && (res.data.message || (res.data.error && res.data.error.message))) || `release → ${res.status}`;
201
+ releaseNote = { ok: res.ok, text: msg };
202
+ } catch (e) {
203
+ releaseNote = { ok: false, text: `Nothing was released: ${e.message || e}` };
204
+ } finally {
205
+ releasing = '';
206
+ paint();
207
+ }
208
+ }
209
+
139
210
  function upgradesHtml(d) {
140
211
  const u = d.upgrades;
141
212
  if (!u) return '';
@@ -322,6 +393,7 @@
322
393
  ${problemsHtml(data)}
323
394
  ${figsHtml(stages, data)}
324
395
  ${groups || '<p class="ov-row__sub">Nothing was finished in this window.</p>'}
396
+ ${releaseHtml(data)}
325
397
  ${upgradesHtml(data)}
326
398
  <p class="ov-foot">read ${escapeHtml(new Date(data.checked_at).toLocaleTimeString())} · ${escapeHtml(data.package)} · release notes from ${escapeHtml(data.notes.source ? `${data.notes.source.from === 'package' ? 'the published' : 'this hall\'s'} ${data.notes.source.version} package` : 'nowhere')}</p>`;
327
399
  work.hidden = false;
@@ -398,6 +470,8 @@
398
470
  const ex = t.closest('[data-expand]');
399
471
  if (ex) { expanded.add(ex.getAttribute('data-expand')); paint(); return; }
400
472
  if (t.closest('#nr-older')) { loadOlder(); return; }
473
+ const rel = t.closest('[data-release]');
474
+ if (rel) { release(rel.getAttribute('data-release')); return; }
401
475
  const pv = t.closest('[data-preview]');
402
476
  if (pv) { previewAct(() => previewCall('POST', { version: pv.getAttribute('data-preview') })); return; }
403
477
  if (t.closest('[data-preview-stop]')) previewAct(() => previewCall('DELETE'));
@@ -0,0 +1,129 @@
1
+ 'use strict';
2
+
3
+ // modules/npm-release/release.js — the Release button's half of the deploy page (task 1004298,
4
+ // ADR 0361).
5
+ //
6
+ // Merges publish as CANDIDATES: on npm, run by this (the platform) hall, offered to no other project.
7
+ // Release makes a version the one every other project is offered, by moving the registry's `latest`
8
+ // label. The move itself happens in GitHub (publish.yml's release job), so the npm credential never
9
+ // leaves GitHub; this server only asks GitHub to run it, through the lifecycle module's one-function
10
+ // workflowDispatch port.
11
+ //
12
+ // TWO THINGS LIVE HERE:
13
+ // releaseCandidates — the evidence beside each button: how long the version has run on this hall,
14
+ // and whether a move to it was ever rolled back here. Read from core_upgrades,
15
+ // the ledger `bongos upgrade` writes on every move and rollback.
16
+ // requestRelease — what the button does: the same rule the workflow applies (release-core.js,
17
+ // imported, never restated), then the dispatch.
18
+
19
+ const { releaseDecision } = require('../../scripts/gds/release-core.js');
20
+
21
+ const VERSION_RE = /^\d+\.\d+\.\d+$/;
22
+ // The ledger window the evidence reads. A candidate older than this has been superseded many
23
+ // times over; the page shows the newest few.
24
+ const LEDGER_DAYS = 60;
25
+ const CANDIDATES_SHOWN = 5;
26
+ const WORKFLOW = 'publish.yml';
27
+
28
+ function compareVersions(a, b) {
29
+ const pa = String(a).split('.').map(Number);
30
+ const pb = String(b).split('.').map(Number);
31
+ for (let i = 0; i < 3; i++) if ((pa[i] || 0) !== (pb[i] || 0)) return (pa[i] || 0) - (pb[i] || 0);
32
+ return 0;
33
+ }
34
+
35
+ // The version a rollback row says it ATTEMPTED: "rolled_back: <why> (attempted A → B, restored C)".
36
+ function attemptedIn(note) {
37
+ const m = /\(attempted\s+\S+\s*→\s*(\d+\.\d+\.\d+)/.exec(String(note || ''));
38
+ return m ? m[1] : null;
39
+ }
40
+
41
+ /**
42
+ * PURE. The versions Release can be pressed on, newest first, each with its evidence.
43
+ *
44
+ * A candidate is a published version newer than the release and no newer than what this hall runs
45
+ * (a version this hall never ran has no evidence, and the hall runs every candidate by design).
46
+ *
47
+ * `rows`: core_upgrades rows, any order, { to_version, applied_at, note }. A row whose note begins
48
+ * `rolled_back:` is a rollback; every other row is a move that went in.
49
+ * on_hall_since — when the version first went in here (null: the ledger does not show it);
50
+ * hours_on_hall — from then until this hall moved past it, or until now if it still runs it;
51
+ * rolled_back — a move to it was rolled back here at least once.
52
+ */
53
+ function releaseCandidates({ rows, versions, released, live, now = Date.now() } = {}) {
54
+ if (!VERSION_RE.test(String(live || ''))) return [];
55
+ const list = (Array.isArray(versions) ? versions : [])
56
+ .filter((v) => VERSION_RE.test(v))
57
+ .filter((v) => compareVersions(v, live) <= 0)
58
+ .filter((v) => !released || compareVersions(v, released) > 0)
59
+ .sort(compareVersions)
60
+ .reverse()
61
+ .slice(0, CANDIDATES_SHOWN);
62
+ const ledger = (Array.isArray(rows) ? rows : [])
63
+ .map((r) => ({ to: r.to_version || null, at: r.applied_at ? Date.parse(r.applied_at) : NaN, note: r.note == null ? '' : String(r.note) }))
64
+ .filter((r) => Number.isFinite(r.at))
65
+ .sort((a, b) => a.at - b.at);
66
+ const moves = ledger.filter((r) => !/^rolled_back:/.test(r.note));
67
+ const rollbacks = ledger.filter((r) => /^rolled_back:/.test(r.note));
68
+ return list.map((v) => {
69
+ const i = moves.findIndex((r) => r.to === v);
70
+ const since = i >= 0 ? moves[i].at : null;
71
+ const next = i >= 0 ? moves.slice(i + 1).find((r) => r.to !== v) : null;
72
+ const until = next ? next.at : now;
73
+ return {
74
+ version: v,
75
+ on_hall_since: since == null ? null : new Date(since).toISOString(),
76
+ hours_on_hall: since == null ? null : Math.max(0, Math.round(((until - since) / 3600000) * 10) / 10),
77
+ still_running: v === live,
78
+ rolled_back: rollbacks.some((r) => attemptedIn(r.note) === v),
79
+ };
80
+ });
81
+ }
82
+
83
+ async function readReleaseLedger(pool) {
84
+ try {
85
+ const { rows } = await pool.query(
86
+ `SELECT to_version, applied_at, note
87
+ FROM core_upgrades
88
+ WHERE applied_at > now() - ($1 || ' days')::interval
89
+ ORDER BY applied_at, id`,
90
+ [String(LEDGER_DAYS)]
91
+ );
92
+ return { ok: true, rows };
93
+ } catch (e) {
94
+ return { ok: false, error: e.message, rows: [] };
95
+ }
96
+ }
97
+
98
+ /**
99
+ * What the Release button does. Returns { status, body } for the route to send.
100
+ *
101
+ * The rule is release-core.js's releaseDecision — the one the workflow applies again before it
102
+ * moves anything, so the button cannot promise what the workflow would refuse. Refusals here save a
103
+ * GitHub run; they are not the gate (the workflow is).
104
+ */
105
+ async function requestRelease({ version, registry, dispatch } = {}) {
106
+ if (!registry || !registry.ok) {
107
+ return { status: 503, body: { error: { code: 'registry_unreadable', message: 'The npm registry could not be read, so nothing was released. Try again in a minute.' } } };
108
+ }
109
+ const d = releaseDecision({ version, versions: registry.versions, latest: registry.latest });
110
+ if (d.noop) return { status: 200, body: { ok: true, noop: true, version, message: `${version} is already the release.` } };
111
+ if (!d.go) return { status: 409, body: { error: { code: 'not_releasable', message: `Not released: ${d.reason}. Pick a version from the Release list, which shows only the ones that can be released.` } } };
112
+ if (typeof dispatch !== 'function') {
113
+ return { status: 503, body: { error: { code: 'dispatch_unavailable', message: 'This hall cannot start GitHub workflows (the lifecycle module is not running), so nothing was released.' } } };
114
+ }
115
+ const r = await dispatch({ workflow: WORKFLOW, ref: 'main', inputs: { release_version: version } });
116
+ if (r && r.ok) {
117
+ return { status: 202, body: { ok: true, version, message: `Release of ${version} started. GitHub moves the label in a minute or two; other projects are offered it from then on.` } };
118
+ }
119
+ const why = !r ? 'no answer'
120
+ : r.code === 'UNCONFIGURED' ? 'this server has no GitHub credential'
121
+ : r.code === 'REFUSED' && r.status === 403 ? "GitHub refused: the server's GitHub App needs the Actions: write permission"
122
+ : r.code === 'REFUSED' && r.status === 404 ? 'GitHub could not find publish.yml, or the App cannot see this repository'
123
+ : r.code === 'REFUSED' ? `GitHub refused the request (${r.status})`
124
+ : r.code === 'UNREACHABLE' ? 'GitHub could not be reached'
125
+ : r.code;
126
+ return { status: 502, body: { error: { code: 'dispatch_failed', message: `Nothing was released: ${why}.` } } };
127
+ }
128
+
129
+ module.exports = { releaseCandidates, readReleaseLedger, requestRelease, WORKFLOW };
@@ -0,0 +1,65 @@
1
+ 'use strict';
2
+
3
+ // modules/npm-release/routes/release.js — POST /npm-release/release, the deploy page's Release
4
+ // button (task 1004298, ADR 0361). Body: { version }.
5
+ //
6
+ // It does not move anything itself. It asks GitHub to run publish.yml's release job, which checks
7
+ // the same rule again and moves the registry's `latest` label — the version every project other
8
+ // than the platform hall is offered. So the npm credential never leaves GitHub.
9
+ //
10
+ // GATED LIKE THE PAGE (`core.pin.move`), as an atom: the deploy page is served only to holders of
11
+ // it, and a button wider than its page would be a door nobody sees. Never requireRank —
12
+ // tests/government_require_permission.mjs fails the build on one in a modules/*/routes gate.
13
+ //
14
+ // Only the core's page releases: publish.yml publishes @bongos/core, and another package's
15
+ // deploy page has no workflow here to run.
16
+
17
+ const express = require('express');
18
+ const api = require('../../../src/module-api');
19
+ const { requestRelease } = require('../release');
20
+
21
+ const log = api.logger('npm-release');
22
+ const CORE_PACKAGE = '@bongos/core';
23
+
24
+ // The kernel validator checks presence, type and length; the x.y.z shape is checked by hand below.
25
+ const releaseSchema = { version: { type: 'string', required: true, maxLength: 32 } };
26
+ const VERSION_RE = /^[0-9]+\.[0-9]+\.[0-9]+$/;
27
+
28
+ module.exports = function buildNpmReleaseReleaseRouter({
29
+ pkg = () => process.env.NPM_RELEASE_PACKAGE || CORE_PACKAGE,
30
+ readRegistry = () => api.readPackageRegistry({ pkg: CORE_PACKAGE, haveNotesFor: () => true }),
31
+ port = () => api.resolveOptional && api.resolveOptional('lifecycle.workflowDispatch'),
32
+ } = {}) {
33
+ const router = express.Router();
34
+
35
+ router.post(
36
+ '/npm-release/release',
37
+ api.requireBuilder,
38
+ api.requirePermission('core.pin.move'),
39
+ async (req, res) => {
40
+ if (api.validateOrRespond(req, res, releaseSchema, { strict: true })) return undefined;
41
+ if (!VERSION_RE.test(req.body.version)) {
42
+ return res.status(400).json({ error: { code: 'validation_failed', message: 'version must be x.y.z', details: [{ field: 'version', reason: 'bad_format' }] } });
43
+ }
44
+ if (pkg() !== CORE_PACKAGE) {
45
+ return res.status(409).json({ error: { code: 'not_the_core', message: 'Release runs only on the deploy page of the package this repository publishes (@bongos/core).' } });
46
+ }
47
+ const version = req.body.version;
48
+ try {
49
+ const { registry } = await readRegistry();
50
+ const p = port();
51
+ const dispatch = p && typeof p.dispatchWorkflow === 'function' ? (args) => p.dispatchWorkflow(args) : null;
52
+ const r = await requestRelease({ version, registry, dispatch });
53
+ const who = req.builder && req.builder.id;
54
+ if (r.status === 202) log.info(`release of core ${version} requested by builder ${who}`);
55
+ else log.warn(`release of core ${version} by builder ${who} not started: ${(r.body.error && r.body.error.code) || 'noop'}`);
56
+ return res.status(r.status).json(r.body);
57
+ } catch (e) {
58
+ log.error(`npm-release release failed: ${e && e.message}`);
59
+ return res.status(500).json({ error: { code: 'release_failed', message: 'Nothing was released: the request failed on this server.' } });
60
+ }
61
+ },
62
+ );
63
+
64
+ return router;
65
+ };
@@ -30,6 +30,7 @@
30
30
  const fs = require('node:fs');
31
31
  const path = require('node:path');
32
32
  const api = require('../../src/module-api');
33
+ const { releaseCandidates, readReleaseLedger } = require('./release');
33
34
 
34
35
  // The package this server runs — the one whose "live" version is this process's own core.
35
36
  // Any other package has no "live on this hall" stage: nothing here runs it.
@@ -292,6 +293,18 @@ async function readVersions({ pkg, live, installedNotes, get, now, store }) {
292
293
  return { liveVersion, registry, versions, source, notesError, coversTo, published, released };
293
294
  }
294
295
 
296
+ // The Release section's reading. Fail-soft like the ledger it reads: an unreadable ledger still
297
+ // lists the candidates, with no evidence and the reason, rather than hiding the button.
298
+ async function releaseReading({ pool, registry, released, live, now }) {
299
+ if (!registry.ok) return { ok: false, error: registry.error || 'the registry could not be read', candidates: [] };
300
+ const ledger = await readReleaseLedger(pool);
301
+ return {
302
+ ok: true,
303
+ ledger_error: ledger.ok ? null : `this hall's upgrade ledger could not be read (${ledger.error})`,
304
+ candidates: releaseCandidates({ rows: ledger.rows, versions: registry.versions, released, live, now }),
305
+ };
306
+ }
307
+
295
308
  async function readWork({
296
309
  pool, pkg = configuredPackage(), live, days, before, limit,
297
310
  installedNotes = readInstalledNotes, get, now, store,
@@ -332,6 +345,9 @@ async function readWork({
332
345
  // Only the core's page has a "this hall" to have upgraded: another package is not what this
333
346
  // server runs.
334
347
  upgrades: isCore ? await recentUpgrades(pool) : null,
348
+ // THE RELEASE BUTTON'S EVIDENCE (task 1004298): each version this hall runs that is not yet
349
+ // released, with how long it has run here and whether a move to it was ever rolled back.
350
+ release: isCore ? await releaseReading({ pool, registry, released, live: liveVersion, now: at }) : null,
335
351
  waiting: isCore && registry.ok ? waitingHere({ live: liveVersion, versions: registry.versions, published, now: at }) : null,
336
352
  };
337
353
  }
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.20.79",
3
+ "version": "1.20.81",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.20.79",
9
+ "version": "1.20.81",
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.20.79",
3
+ "version": "1.20.81",
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",