@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.
- package/.bongos-core.json +104 -49
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +8 -0
- package/clients/bongos-client/index.cjs +8 -0
- package/clients/bongos-client/index.d.ts +13 -0
- package/clients/bongos-client/index.mjs +8 -0
- package/docs/adr/0161-publish-on-merge.md +1 -1
- package/docs/adr/0361-merges-publish-as-candidates-release-decides-what-others-are-offered.md +59 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +258 -4
- package/docs/api-reference.md +7 -3
- package/docs/copy-inventory.md +59 -53
- package/docs/copy-registry.json +120 -66
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-inventory.json +2 -1
- package/docs/page-readings.json +86 -81
- package/docs/recipes/private-npm-distribution.md +2 -0
- package/docs/recipes/upgrading-the-core.md +1 -1
- package/modules/autonomy/cadence.js +3 -0
- package/modules/autonomy/db.js +136 -0
- package/modules/autonomy/fence.js +88 -3
- package/modules/autonomy/migrations/autonomy_005_builder_scope.sql +55 -0
- package/modules/autonomy/routes/autonomy.js +123 -3
- package/modules/government/catalog.js +9 -1
- package/modules/government/migrations/government_022_autonomy_run.sql +38 -0
- package/modules/hall-ui/public/gate.html +5 -2
- package/modules/hall-ui/public/gate.js +73 -4
- package/modules/hall-ui/public/settings-autobongos.js +156 -0
- package/modules/hall-ui/public/settings.html +19 -0
- package/modules/hall-ui/public/settings.js +1 -0
- package/modules/lifecycle/module.json +2 -1
- package/modules/lifecycle/routes/lifecycle.js +7 -0
- package/modules/lifecycle/workflow-dispatch.js +45 -0
- package/modules/npm-release/module.json +2 -1
- package/modules/npm-release/public/work.js +74 -0
- package/modules/npm-release/release.js +129 -0
- package/modules/npm-release/routes/release.js +65 -0
- package/modules/npm-release/work.js +16 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +16 -0
- package/scripts/gds/provision-core-upgrade.js +30 -2
- package/scripts/gds/release-core.js +80 -0
- package/scripts/gds/update-channel.js +12 -3
- package/src/bongos/core-update.js +17 -8
- package/src/module-api.js +1 -1
- package/tests/autonomy_builder_scope.mjs +474 -0
- package/tests/autonomy_fence_priority.mjs +3 -1
- package/tests/core_update_banner.mjs +41 -5
- package/tests/core_upgrade_runner.mjs +66 -1
- package/tests/npm_release_release.mjs +195 -0
- package/tests/release_core.mjs +110 -0
- 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',
|
|
@@ -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 };
|
|
@@ -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.
|
|
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.
|
|
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.
|
|
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",
|