@bongos/core 1.20.78 → 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 +74 -39
- 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 +4 -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/modules/provisioning/render-standup.js +2 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +16 -0
- package/scripts/gds/init.js +16 -6
- 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/init.mjs +21 -5
- package/tests/npm_release_release.mjs +195 -0
- package/tests/release_core.mjs +110 -0
- package/tests/update_channel.mjs +7 -0
- package/tests/upgrade_migrate.mjs +1 -1
|
@@ -97,7 +97,7 @@ A **major** is never automatic; prereleases are never auto-targeted.
|
|
|
97
97
|
| `released` *(default)* | versions at or below the registry's `latest` label, what the owner has released | every project |
|
|
98
98
|
| `candidates` | every published version, as soon as it is published | **the platform's own hall only** |
|
|
99
99
|
|
|
100
|
-
An instance that follows releases and whose `latest` label cannot be read is **skipped**, never upgraded on a guess.
|
|
100
|
+
An instance that follows releases and whose `latest` label cannot be read is **skipped**, never upgraded on a guess. Once merges publish as candidates (task 1004298, [ADR 0361](../adr/0361-merges-publish-as-candidates-release-decides-what-others-are-offered.md), switched on by the repo variable `PUBLISH_AS_CANDIDATE=1`), `latest` moves only when the owner presses **Release** on /deploy, and the two follows part. Until that variable is set, `latest` is the newest version and they pick the same target. Set the platform's own entry to `"follow": "candidates"` **before** setting it, or the platform stops taking new versions the moment it is set. The platform's runner for hosted projects needs no roster entry: the `control-plane` row follows candidates and every other project follows releases.
|
|
101
101
|
|
|
102
102
|
**Crossing a minor from /deploy** (task [1004405](https://cloudbongos.com/builders#/task/1004405)): set the project's update rule to **Fixes and new releases** (`minor`), or press **Allow new releases and move** on the refused move — either is the explicit "this crosses a minor version" choice. That only works once the box runs a core whose runner reads the rule (core 1.20.29 or later, task [1004445](https://cloudbongos.com/builders#/task/1004445)). A box on anything older runs the old runner, which bounds every move to `patch` whatever the rule says, so its **first** crossing is the manual `upgrade.js --to <version>` above; every later one goes through /deploy.
|
|
103
103
|
|
|
@@ -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
|
}
|
|
@@ -57,7 +57,8 @@ const SERVICE_RE = /^srv-[a-z0-9]{8,64}$/;
|
|
|
57
57
|
// The hall is never the app (ADR 0345 D1). A command that runs the Bongos core itself puts
|
|
58
58
|
// a second, database-less copy of the project's hall on Render, where it never answers and
|
|
59
59
|
// the deploy times out — measured live on project 43 (task 1004483). It cannot see through
|
|
60
|
-
//
|
|
60
|
+
// `npm start`, so the scaffold no longer puts the hall there: it is `npm run hall`, and
|
|
61
|
+
// `npm start` is left for the app (task 1004484, scripts/gds/init.js).
|
|
61
62
|
const HALL_COMMAND_RE = /platform-server\.js|node_modules[\\/]@bongos[\\/]core/i;
|
|
62
63
|
const HALL_REFUSAL = 'That command starts the project’s Builders’ hall, which we already host — not your app. Give the command that starts your app.';
|
|
63
64
|
function badRequest(body) {
|
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
|
@@ -8609,5 +8609,21 @@
|
|
|
8609
8609
|
"id": "1004297",
|
|
8610
8610
|
"text": "Each project can now be set to take every new version (Bongos) or only released ones (everyone else), a version that fails is no longer retried in a loop, and the deploy page shows each upgrade, any rollback, and when the auto"
|
|
8611
8611
|
}
|
|
8612
|
+
],
|
|
8613
|
+
"1.20.79": [
|
|
8614
|
+
{
|
|
8615
|
+
"id": "1004484",
|
|
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
|
+
}
|
|
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
|
+
}
|
|
8612
8628
|
]
|
|
8613
8629
|
}
|
package/scripts/gds/init.js
CHANGED
|
@@ -126,20 +126,24 @@ function coreVersionSafe() {
|
|
|
126
126
|
}
|
|
127
127
|
|
|
128
128
|
// The npm scripts a STANDALONE greenfield instance runs. They MIRROR how the control
|
|
129
|
-
// plane runs a standalone instance (scripts/gds/provision.js): `
|
|
129
|
+
// plane runs a standalone instance (scripts/gds/provision.js): `hall` = the systemd
|
|
130
130
|
// ExecStart (`node <core>/src/platform-server.js`), `migrate` = standaloneMigrateCmd
|
|
131
|
-
// (`bash <core>/scripts/migrate.sh`). Keeping them identical means `npm
|
|
131
|
+
// (`bash <core>/scripts/migrate.sh`). Keeping them identical means `npm run hall` /
|
|
132
132
|
// `npm run migrate` on the instance behave like production, and `bongos upgrade`'s
|
|
133
133
|
// `npm run migrate` step resolves to the core's migrate.sh (ADR 0108).
|
|
134
|
+
// The hall is deliberately NOT `start` (task 1004484): `npm start` belongs to the
|
|
135
|
+
// project's own app, and a host like Render reads it as "how this app starts" — given the
|
|
136
|
+
// hall there, the deploy has no database and times out. Nothing runs an instance's
|
|
137
|
+
// `npm start`: the box unit and `bongos dev` both start platform-server.js directly.
|
|
134
138
|
const INSTANCE_CORE_PKG_DIR = 'node_modules/@bongos/core';
|
|
135
|
-
const
|
|
139
|
+
const INSTANCE_HALL_SCRIPT = `node ${INSTANCE_CORE_PKG_DIR}/src/platform-server.js`;
|
|
136
140
|
const INSTANCE_MIGRATE_SCRIPT = `bash ${INSTANCE_CORE_PKG_DIR}/scripts/migrate.sh`;
|
|
137
141
|
|
|
138
142
|
// PURE (exported for tests) — build/augment the greenfield instance package.json so a
|
|
139
143
|
// fresh instance is `npm ci`-installable and runnable (ADR 0108 greenfield gap 1/4,
|
|
140
144
|
// task 2053): the @bongos/core dependency (the `file:` tarball pin when a tarball is
|
|
141
145
|
// vendored, else the declared caret range — the CARET RESOLVES NOWHERE on npm, which is
|
|
142
|
-
// exactly why the vendored path pins `file:`) plus the
|
|
146
|
+
// exactly why the vendored path pins `file:`) plus the hall/migrate npm scripts a
|
|
143
147
|
// standalone instance runs. Preserves any pre-existing fields + scripts (fills only what
|
|
144
148
|
// is missing); greenfield OWNS the core dep, so it is always (re)set to `coreDep`. Never
|
|
145
149
|
// spawns npm — generating the lockfile is a separate imperative step in main().
|
|
@@ -155,7 +159,13 @@ function buildInstancePackageJson(existing, { name = 'cloudbongos-instance', cor
|
|
|
155
159
|
if (!pkg.name) pkg.name = name;
|
|
156
160
|
if (pkg.private === undefined) pkg.private = true;
|
|
157
161
|
pkg.scripts = { ...(isObj(pkg.scripts) ? pkg.scripts : {}) };
|
|
158
|
-
|
|
162
|
+
// A scaffold from before task 1004484 wrote the hall as `start`; move exactly that
|
|
163
|
+
// command (never an owner's own start) to `hall`, unless they already have a `hall`.
|
|
164
|
+
if (pkg.scripts.start === INSTANCE_HALL_SCRIPT && !pkg.scripts.hall) {
|
|
165
|
+
pkg.scripts.hall = pkg.scripts.start;
|
|
166
|
+
delete pkg.scripts.start;
|
|
167
|
+
}
|
|
168
|
+
if (!pkg.scripts.hall) pkg.scripts.hall = INSTANCE_HALL_SCRIPT;
|
|
159
169
|
if (!pkg.scripts.migrate) pkg.scripts.migrate = INSTANCE_MIGRATE_SCRIPT;
|
|
160
170
|
pkg.dependencies = {
|
|
161
171
|
...(isObj(moduleDeps) ? moduleDeps : {}),
|
|
@@ -1136,7 +1146,7 @@ module.exports = {
|
|
|
1136
1146
|
buildReadmeSection, appendReadmeSection, layerReadme, README_MARKER,
|
|
1137
1147
|
KNOWN_MODULES, HOSTING_SHAPES, CORE_PKG,
|
|
1138
1148
|
// greenfield installability (ADR 0108 gap 1/4, task 2053)
|
|
1139
|
-
buildInstancePackageJson, coreVersionSafe,
|
|
1149
|
+
buildInstancePackageJson, coreVersionSafe, INSTANCE_HALL_SCRIPT, INSTANCE_MIGRATE_SCRIPT,
|
|
1140
1150
|
// enabled-module dep composition (ADR 0138 / task 2160)
|
|
1141
1151
|
moduleDeclaredDeps, enabledModuleDeps,
|
|
1142
1152
|
};
|
|
@@ -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
|