@bongos/core 1.20.79 → 1.20.80
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 +70 -35
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +2 -0
- package/clients/bongos-client/index.cjs +2 -0
- package/clients/bongos-client/index.d.ts +3 -0
- package/clients/bongos-client/index.mjs +2 -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 +60 -2
- package/docs/api-reference.md +3 -2
- package/docs/module-api-changelog.md +2 -0
- package/docs/recipes/private-npm-distribution.md +2 -0
- package/docs/recipes/upgrading-the-core.md +1 -1
- 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 +10 -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/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,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.80",
|
|
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.80",
|
|
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.80",
|
|
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",
|
package/release-notes.json
CHANGED
|
@@ -8615,5 +8615,15 @@
|
|
|
8615
8615
|
"id": "1004484",
|
|
8616
8616
|
"text": "New projects no longer have their 'start' command pointing at the Bongos hall, so when an owner later connects Render with the usual 'npm start', it runs their app instead of a broken second copy of the hall."
|
|
8617
8617
|
}
|
|
8618
|
+
],
|
|
8619
|
+
"1.20.80": [
|
|
8620
|
+
{
|
|
8621
|
+
"id": "1004298",
|
|
8622
|
+
"text": "New versions can now run on Bongos first and reach other projects only when the owner presses Release on the deploy page, which shows how long each version has run on Bongos and whether it was ever rolled back. Switched off un"
|
|
8623
|
+
},
|
|
8624
|
+
{
|
|
8625
|
+
"id": "1003907",
|
|
8626
|
+
"text": "Autobongos has now been proven against the live project: it shipped work by itself with nobody watching, refused every kind of work it is not allowed to touch, and recovered from restarts and network failures on its own. The o"
|
|
8627
|
+
}
|
|
8618
8628
|
]
|
|
8619
8629
|
}
|
|
@@ -166,6 +166,34 @@ function instanceChannel(inst, channelMod) {
|
|
|
166
166
|
return channelMod.normalizeChannel(inst && inst.update_channel != null ? inst.update_channel : channelMod.DEFAULT_CHANNEL);
|
|
167
167
|
}
|
|
168
168
|
|
|
169
|
+
// The versions this project may be moved to: the published ones its FOLLOW admits (task 1004298,
|
|
170
|
+
// ADR 0361). The platform hall (control-plane) takes every candidate; every other project only what
|
|
171
|
+
// has been released, and NOTHING when the registry cannot say which version that is — a project that
|
|
172
|
+
// follows releases is never moved on a guess. A channel module without the follow (an old core
|
|
173
|
+
// resolved at drain time) offers the whole list, which is what every version meant before candidates.
|
|
174
|
+
//
|
|
175
|
+
// The released label is one fact about the registry, the same for every project in a pass, and
|
|
176
|
+
// reading it is a blocking `npm view`. So it is read once per channel module per RELEASE_TAGS_TTL_MS
|
|
177
|
+
// and shared: a pass over N projects costs one extra subprocess, not N. A failed read is not kept.
|
|
178
|
+
const RELEASE_TAGS_TTL_MS = 60 * 1000;
|
|
179
|
+
const releaseTagsMemo = new WeakMap();
|
|
180
|
+
function releaseTagsFor(channelMod, instanceDir, now = Date.now()) {
|
|
181
|
+
const hit = releaseTagsMemo.get(channelMod);
|
|
182
|
+
if (hit && now - hit.at < RELEASE_TAGS_TTL_MS) return hit.tags;
|
|
183
|
+
const tags = channelMod.readReleaseTags({ cwd: instanceDir });
|
|
184
|
+
if (tags && tags.ok) releaseTagsMemo.set(channelMod, { at: now, tags });
|
|
185
|
+
return tags;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function offeredVersions(channelMod, inst, instanceDir) {
|
|
189
|
+
const listed = channelMod.listAvailableVersions({ cwd: instanceDir });
|
|
190
|
+
if (!listed || !listed.ok || typeof channelMod.followForShape !== 'function') return listed;
|
|
191
|
+
if (channelMod.followForShape(inst && inst.hosting_shape) === 'candidates') return listed;
|
|
192
|
+
const tags = releaseTagsFor(channelMod, instanceDir);
|
|
193
|
+
if (!tags.ok) return { ok: false, error: `could not read which version is released (${tags.error})`, versions: [] };
|
|
194
|
+
return { ok: true, versions: channelMod.versionsForFollow({ follow: 'released', available: listed.versions, released: tags.released }) };
|
|
195
|
+
}
|
|
196
|
+
|
|
169
197
|
// A classified refusal (task 1004446): the drain loop stores `failure` beside last_error,
|
|
170
198
|
// so the rest of the self-heal chain acts on a class, not on a sentence. PURE.
|
|
171
199
|
function failed(reason, error, extra = {}) {
|
|
@@ -246,7 +274,7 @@ async function coreUpgradeInstance(inst, deps, intent) {
|
|
|
246
274
|
// pointed at nothing. A row from before the column (undefined) still normalises to that
|
|
247
275
|
// default, so an old runner schema reads exactly as it always did.
|
|
248
276
|
const channel = instanceChannel(inst, channelMod);
|
|
249
|
-
const listed = channelMod
|
|
277
|
+
const listed = offeredVersions(channelMod, inst, instanceDir);
|
|
250
278
|
const available = (listed && listed.versions) || [];
|
|
251
279
|
const fault = targetFault(to, available, installed, channel, channelMod.channelAllows);
|
|
252
280
|
if (fault) return failed(outcome.runnerReason(fault), fault);
|
|
@@ -1013,7 +1041,7 @@ async function snapshotCoreVersions(inst, deps) {
|
|
|
1013
1041
|
const body = probe && !probe.softFailed ? (probe.stdout || '') : null;
|
|
1014
1042
|
const served = parseServedVersion(body);
|
|
1015
1043
|
const schemaPending = parseSchemaPending(body);
|
|
1016
|
-
const listed = channelMod
|
|
1044
|
+
const listed = offeredVersions(channelMod, inst, instanceDir);
|
|
1017
1045
|
const available = (listed && listed.versions) || [];
|
|
1018
1046
|
// What the channel would take FROM the version the box is actually on. Resolving it
|
|
1019
1047
|
// here rather than in the browser is what keeps the button's promise and the
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
// scripts/gds/release-core.js — may this version of @bongos/core be RELEASED? (task 1004298, ADR 0361)
|
|
4
|
+
//
|
|
5
|
+
// Merges publish as candidates (under the `next` label). A version reaches other projects only when
|
|
6
|
+
// the owner presses Release on /deploy, which runs publish.yml's release job, which asks this script
|
|
7
|
+
// and then moves the registry's `latest` label. Every consumer that offers a version to a project
|
|
8
|
+
// other than the platform hall reads `latest` (update-channel.js versionsForFollow), so this label IS
|
|
9
|
+
// the release.
|
|
10
|
+
//
|
|
11
|
+
// WHY A SCRIPT AND NOT A SHELL STEP. The rule has four ways to say no, and a shell `if` chain inside
|
|
12
|
+
// a workflow file is the one place nothing can test it. The decision is pure and unit-tested
|
|
13
|
+
// (tests/release_core.mjs); the workflow only runs it and acts on `go`.
|
|
14
|
+
//
|
|
15
|
+
// THE RULE:
|
|
16
|
+
// - the version must be a plain x.y.z (it is interpolated into an npm command line);
|
|
17
|
+
// - it must be PUBLISHED — Release labels a version, it never builds one;
|
|
18
|
+
// - it must be newer than the current release. Releasing an OLDER version would move every
|
|
19
|
+
// project's offer backwards, which is a rollback for everyone, not a release; that is a
|
|
20
|
+
// different act and needs a person at a terminal, not a button.
|
|
21
|
+
// - releasing the version already released is a no-op, not an error — a double press is harmless.
|
|
22
|
+
//
|
|
23
|
+
// Usage (in publish.yml's release job):
|
|
24
|
+
// node scripts/gds/release-core.js --version 1.20.80
|
|
25
|
+
// Prints the decision; writes go=true|false to $GITHUB_OUTPUT when set. Exit 0 for go and no-op,
|
|
26
|
+
// 1 for a refusal.
|
|
27
|
+
|
|
28
|
+
const fs = require('node:fs');
|
|
29
|
+
const { spawnSync } = require('node:child_process');
|
|
30
|
+
const { CORE_PKG, parseSemver, compareSemver, isStable } = require('./update-channel.js');
|
|
31
|
+
|
|
32
|
+
const VERSION_RE = /^[0-9]+\.[0-9]+\.[0-9]+$/;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* PURE. `versions`: every published version. `latest`: the version the registry labels `latest`
|
|
36
|
+
* now, or null. Returns { go, noop, reason }.
|
|
37
|
+
*/
|
|
38
|
+
function releaseDecision({ version, versions, latest } = {}) {
|
|
39
|
+
const v = String(version == null ? '' : version).trim();
|
|
40
|
+
if (!VERSION_RE.test(v)) return { go: false, noop: false, reason: `"${v}" is not a version (x.y.z)` };
|
|
41
|
+
const list = Array.isArray(versions) ? versions : [];
|
|
42
|
+
if (!list.includes(v)) return { go: false, noop: false, reason: `${v} is not published — Release labels a published version, it never builds one` };
|
|
43
|
+
if (!isStable(v)) return { go: false, noop: false, reason: `${v} is a prerelease` };
|
|
44
|
+
if (latest && parseSemver(latest)) {
|
|
45
|
+
const c = compareSemver(v, latest);
|
|
46
|
+
if (c === 0) return { go: false, noop: true, reason: `${v} is already the release` };
|
|
47
|
+
if (c < 0) return { go: false, noop: false, reason: `${v} is older than the current release ${latest} — releasing it would move every project backwards` };
|
|
48
|
+
}
|
|
49
|
+
return { go: true, noop: false, reason: latest ? `release ${v} (was ${latest})` : `release ${v} (no release yet)` };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function readRegistry({ pkg = CORE_PKG, run = spawnSync } = {}) {
|
|
53
|
+
const res = run('npm', ['view', pkg, 'versions', 'dist-tags', '--json'], { encoding: 'utf8', timeout: 60000 });
|
|
54
|
+
if (!res || res.status !== 0) return { ok: false, error: String((res && res.stderr) || 'npm view failed').trim().split('\n')[0] };
|
|
55
|
+
try {
|
|
56
|
+
const doc = JSON.parse(String(res.stdout || '{}'));
|
|
57
|
+
const versions = Array.isArray(doc.versions) ? doc.versions : (typeof doc.versions === 'string' ? [doc.versions] : []);
|
|
58
|
+
const tags = doc['dist-tags'] || {};
|
|
59
|
+
return { ok: true, versions, latest: typeof tags.latest === 'string' ? tags.latest : null };
|
|
60
|
+
} catch (e) {
|
|
61
|
+
return { ok: false, error: `unparseable npm output: ${e.message}` };
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function main(argv = process.argv.slice(2), { env = process.env, read = readRegistry, log = console.log, err = console.error } = {}) {
|
|
66
|
+
const i = argv.indexOf('--version');
|
|
67
|
+
const version = i >= 0 ? argv[i + 1] : '';
|
|
68
|
+
const out = (go) => { if (env.GITHUB_OUTPUT) fs.appendFileSync(env.GITHUB_OUTPUT, `go=${go}\n`); };
|
|
69
|
+
const reg = read();
|
|
70
|
+
if (!reg.ok) { err(`release-core: could not read the registry (${reg.error})`); out(false); return 1; }
|
|
71
|
+
const d = releaseDecision({ version, versions: reg.versions, latest: reg.latest });
|
|
72
|
+
out(d.go);
|
|
73
|
+
if (d.go || d.noop) { log(`release-core: ${d.reason}`); return 0; }
|
|
74
|
+
err(`release-core: refused — ${d.reason}`);
|
|
75
|
+
return 1;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (require.main === module) process.exit(main());
|
|
79
|
+
|
|
80
|
+
module.exports = { releaseDecision, main };
|
|
@@ -23,9 +23,9 @@
|
|
|
23
23
|
// has released to other projects. DEFAULT, for every project.
|
|
24
24
|
// candidates — every published version, the moment it is published. The platform's own hall, and
|
|
25
25
|
// only it: Bongos runs each version before anyone else is offered it.
|
|
26
|
-
// The channel bounds how FAR a jump may go; the follow bounds which versions exist to jump to.
|
|
27
|
-
//
|
|
28
|
-
//
|
|
26
|
+
// The channel bounds how FAR a jump may go; the follow bounds which versions exist to jump to. Once
|
|
27
|
+
// merges publish as candidates (task 1004298, ADR 0361), `latest` moves only when the owner presses
|
|
28
|
+
// Release, so the two follows part: the platform hall takes each candidate, everyone else the release.
|
|
29
29
|
//
|
|
30
30
|
// WHY the registry as the source (not vendored tarballs): a subscription needs a LIVE source of
|
|
31
31
|
// truth for "what's the newest published core?" — that's `npm view @bongos/core versions` against the
|
|
@@ -139,6 +139,14 @@ function versionsForFollow({ follow, available, released } = {}) {
|
|
|
139
139
|
return list.filter((v) => compareSemver(v, released) <= 0);
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
+
// The follow a provisioned project takes, from its hosting shape (task 1004298, ADR 0361). The
|
|
143
|
+
// control-plane row IS the platform's own hall, the one project that runs every candidate; every other
|
|
144
|
+
// shape follows releases. Derived, not stored: there is exactly one platform hall, and a column an
|
|
145
|
+
// operator could set on a tenant would be one more way to hand a project an unreleased version.
|
|
146
|
+
function followForShape(hostingShape) {
|
|
147
|
+
return hostingShape === 'control-plane' ? 'candidates' : DEFAULT_FOLLOW;
|
|
148
|
+
}
|
|
149
|
+
|
|
142
150
|
// Would `channel` permit an unattended jump from `installed` to `candidate`? True only when candidate
|
|
143
151
|
// is a STABLE release strictly newer than installed AND within the channel's blast radius. Any invalid
|
|
144
152
|
// input, an unknown installed version, a prerelease candidate, or a major bump ⇒ false (fail-closed).
|
|
@@ -463,6 +471,7 @@ module.exports = {
|
|
|
463
471
|
normalizeFollow,
|
|
464
472
|
versionsForFollow,
|
|
465
473
|
readReleaseTags,
|
|
474
|
+
followForShape,
|
|
466
475
|
SUBSCRIPTIONS_REL,
|
|
467
476
|
parseSemver,
|
|
468
477
|
isStable,
|