@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
|
@@ -25,6 +25,12 @@
|
|
|
25
25
|
// OWN function (scripts/gds/update-channel.js) and is imported, not copied: the
|
|
26
26
|
// banner must never offer a version the runner would then refuse, and ADR 0280
|
|
27
27
|
// is the record of what a second copy of this contract costs when it drifts.
|
|
28
|
+
//
|
|
29
|
+
// ONLY RELEASED VERSIONS ARE OFFERED (task 1004298, ADR 0361). Merges publish as candidates; a
|
|
30
|
+
// version reaches other projects when the owner presses Release, which moves the registry's
|
|
31
|
+
// `latest` label. So the banner offers versions at or below `latest` (versionsForFollow, the same
|
|
32
|
+
// function the sweep uses), and with no readable label it has no answer rather than offering a
|
|
33
|
+
// candidate. The platform hall runs ahead of `latest` by design, so it simply reads "current".
|
|
28
34
|
|
|
29
35
|
const https = require('node:https');
|
|
30
36
|
const {
|
|
@@ -32,6 +38,7 @@ const {
|
|
|
32
38
|
DEFAULT_CHANNEL,
|
|
33
39
|
normalizeChannel,
|
|
34
40
|
resolveChannelTarget,
|
|
41
|
+
versionsForFollow,
|
|
35
42
|
} = require('../../scripts/gds/update-channel.js');
|
|
36
43
|
|
|
37
44
|
const REGISTRY_ORIGIN = 'https://registry.npmjs.org';
|
|
@@ -50,9 +57,9 @@ const FETCH_TIMEOUT_MS = 4000;
|
|
|
50
57
|
* same field for the same reason; the failure it prevents is a project that is
|
|
51
58
|
* months behind being told, confidently, that it is current.
|
|
52
59
|
*/
|
|
53
|
-
function coreUpdateVerdict({ running, available, channel } = {}) {
|
|
60
|
+
function coreUpdateVerdict({ running, available, released, channel } = {}) {
|
|
54
61
|
const ch = normalizeChannel(channel == null ? DEFAULT_CHANNEL : channel);
|
|
55
|
-
const versions =
|
|
62
|
+
const versions = versionsForFollow({ follow: 'released', available, released });
|
|
56
63
|
if (!running || typeof running !== 'string' || !versions.length) {
|
|
57
64
|
return { running: running || null, latest: null, behind: false, reading: 'no_answer', channel: ch };
|
|
58
65
|
}
|
|
@@ -88,7 +95,9 @@ function fetchPublishedVersions({ pkg = CORE_PKG, origin = REGISTRY_ORIGIN, time
|
|
|
88
95
|
try {
|
|
89
96
|
const doc = JSON.parse(body);
|
|
90
97
|
const versions = Object.keys((doc && doc.versions) || {});
|
|
91
|
-
|
|
98
|
+
const tag = doc && doc['dist-tags'] && doc['dist-tags'].latest;
|
|
99
|
+
const released = typeof tag === 'string' ? tag : null;
|
|
100
|
+
done(versions.length ? { ok: true, versions, released } : { ok: false, versions: [], error: 'no versions in packument' });
|
|
92
101
|
} catch (e) { done({ ok: false, versions: [], error: `unparseable packument: ${e.message}` }); }
|
|
93
102
|
});
|
|
94
103
|
});
|
|
@@ -103,13 +112,13 @@ function fetchPublishedVersions({ pkg = CORE_PKG, origin = REGISTRY_ORIGIN, time
|
|
|
103
112
|
|
|
104
113
|
// Process-local cache. Deliberately not a table: it is a derived fact with a
|
|
105
114
|
// six-hour life, and a restart re-deriving it costs one request.
|
|
106
|
-
const cache = { at: 0, versions: [], ok: false, inflight: null };
|
|
115
|
+
const cache = { at: 0, versions: [], released: null, ok: false, inflight: null };
|
|
107
116
|
|
|
108
117
|
|
|
109
118
|
|
|
110
119
|
async function publishedVersions({ now = Date.now, ttlMs = TTL_MS, fetch = fetchPublishedVersions, store = cache } = {}) {
|
|
111
120
|
const t = now();
|
|
112
|
-
if (store.ok && t - store.at < ttlMs) return { ok: true, versions: store.versions, cached: true };
|
|
121
|
+
if (store.ok && t - store.at < ttlMs) return { ok: true, versions: store.versions, released: store.released, cached: true };
|
|
113
122
|
// IN-FLIGHT DE-DUPE. Without it, every request arriving during a cold-cache
|
|
114
123
|
// window starts its OWN registry read — the cache only collapses them once the
|
|
115
124
|
// first one has landed. Small, because the audience is archon-only and the TTL
|
|
@@ -119,8 +128,8 @@ async function publishedVersions({ now = Date.now, ttlMs = TTL_MS, fetch = fetch
|
|
|
119
128
|
store.inflight = (async () => {
|
|
120
129
|
try {
|
|
121
130
|
const r = await fetch();
|
|
122
|
-
if (r.ok) { store.ok = true; store.at = t; store.versions = r.versions; }
|
|
123
|
-
return { ok: r.ok, versions: r.versions, cached: false, error: r.error };
|
|
131
|
+
if (r.ok) { store.ok = true; store.at = t; store.versions = r.versions; store.released = r.released || null; }
|
|
132
|
+
return { ok: r.ok, versions: r.versions, released: r.released || null, cached: false, error: r.error };
|
|
124
133
|
} finally {
|
|
125
134
|
store.inflight = null;
|
|
126
135
|
}
|
|
@@ -141,7 +150,7 @@ async function publishedVersions({ now = Date.now, ttlMs = TTL_MS, fetch = fetch
|
|
|
141
150
|
async function readCoreUpdate({ running, channel, get, ...deps } = {}) {
|
|
142
151
|
const fetch = deps.fetch || (get ? () => fetchPublishedVersions({ get }) : fetchPublishedVersions);
|
|
143
152
|
const r = await publishedVersions({ ...deps, fetch });
|
|
144
|
-
return coreUpdateVerdict({ running, available: r.ok ? r.versions : [], channel });
|
|
153
|
+
return coreUpdateVerdict({ running, available: r.ok ? r.versions : [], released: r.ok ? r.released : null, channel });
|
|
145
154
|
}
|
|
146
155
|
|
|
147
156
|
// readCoreUpgrades — the instance's own core-upgrade ledger, newest first: when the
|
package/src/module-api.js
CHANGED
|
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
75
75
|
// MAJOR (see allowBoxScope below): passes the request through untouched.
|
|
76
76
|
function deprecatedNoopMiddleware(_req, _res, next) { next(); }
|
|
77
77
|
|
|
78
|
-
const CORE_VERSION = '1.20.
|
|
78
|
+
const CORE_VERSION = '1.20.80'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
79
79
|
|
|
80
80
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
81
81
|
// Usage: const log = api.logger('discord'); log.info('mounted');
|
|
@@ -21,8 +21,10 @@ const CU = require_(path.join(ROOT, 'src/bongos/core-update.js'));
|
|
|
21
21
|
// through it rather than reaching past it into internals that no route uses.
|
|
22
22
|
// `store` is per-call so the unit assertions never share the module's own cache.
|
|
23
23
|
const freshStore = () => ({ at: 0, versions: [], ok: false, inflight: null });
|
|
24
|
-
|
|
25
|
-
|
|
24
|
+
// `released` is the registry's `latest` label. It defaults to the newest version listed, which is
|
|
25
|
+
// what it was before merges published as candidates (task 1004298); the candidate tests pass it.
|
|
26
|
+
const verdictFor = (running, available, channel, released = available[available.length - 1]) =>
|
|
27
|
+
CU.readCoreUpdate({ running, channel, store: freshStore(), fetch: async () => ({ ok: true, versions: available, released }) });
|
|
26
28
|
const SHELL = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/shell.js'), 'utf8');
|
|
27
29
|
|
|
28
30
|
// ── the verdict ────────────────────────────────────────────────────────────────
|
|
@@ -53,6 +55,40 @@ test('a prerelease ahead is not offered', async () => {
|
|
|
53
55
|
assert.equal(v.behind, false);
|
|
54
56
|
});
|
|
55
57
|
|
|
58
|
+
// ── only released versions are offered (task 1004298, ADR 0361) ──────────────────
|
|
59
|
+
|
|
60
|
+
test('a candidate newer than the release is NOT offered; the release is', async () => {
|
|
61
|
+
const v = await verdictFor('1.19.861', ['1.19.861', '1.19.865', '1.19.871'], undefined, '1.19.865');
|
|
62
|
+
assert.equal(v.latest, '1.19.865', 'the newest RELEASED version, not the newest published');
|
|
63
|
+
assert.equal(v.behind, true);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('a project already on the release is current, however many candidates follow it', async () => {
|
|
67
|
+
const v = await verdictFor('1.19.865', ['1.19.865', '1.19.870', '1.19.871'], undefined, '1.19.865');
|
|
68
|
+
assert.equal(v.behind, false);
|
|
69
|
+
assert.equal(v.latest, null);
|
|
70
|
+
assert.equal(v.reading, 'ok');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('the platform hall, running ahead of the release, reads current — never "behind" a version older than it', async () => {
|
|
74
|
+
const v = await verdictFor('1.19.871', ['1.19.865', '1.19.871'], undefined, '1.19.865');
|
|
75
|
+
assert.equal(v.behind, false);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('a registry that names no release is NO ANSWER, never the newest candidate', async () => {
|
|
79
|
+
const v = await verdictFor('1.19.861', ['1.19.861', '1.19.871'], undefined, null);
|
|
80
|
+
assert.equal(v.reading, 'no_answer');
|
|
81
|
+
assert.equal(v.latest, null);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('the packument\'s `latest` label is what bounds the offer', async () => {
|
|
85
|
+
const v = await CU.readCoreUpdate({
|
|
86
|
+
running: '1.19.861', store: freshStore(),
|
|
87
|
+
get: fakeGet((res) => { res._body = JSON.stringify({ 'dist-tags': { latest: '1.19.865', next: '1.19.871' }, versions: { '1.19.861': {}, '1.19.865': {}, '1.19.871': {} } }); }),
|
|
88
|
+
});
|
|
89
|
+
assert.equal(v.latest, '1.19.865');
|
|
90
|
+
});
|
|
91
|
+
|
|
56
92
|
test('NO ANSWER is not "up to date" — the distinction the banner turns on', async () => {
|
|
57
93
|
// A registry that cannot be reached, and a box that cannot name its own core:
|
|
58
94
|
// two different roads to the same must-say-nothing state.
|
|
@@ -96,7 +132,7 @@ test('a real packument is parsed, and its newest version reaches the verdict', a
|
|
|
96
132
|
// Drives the ACTUAL https parsing path, only the socket is fake.
|
|
97
133
|
const v = await CU.readCoreUpdate({
|
|
98
134
|
running: '1.19.861', store: freshStore(),
|
|
99
|
-
get: fakeGet((res) => { res._body = JSON.stringify({ versions: { '1.19.861': {}, '1.19.871': {} } }); }),
|
|
135
|
+
get: fakeGet((res) => { res._body = JSON.stringify({ 'dist-tags': { latest: '1.19.871' }, versions: { '1.19.861': {}, '1.19.871': {} } }); }),
|
|
100
136
|
});
|
|
101
137
|
assert.equal(v.reading, 'ok');
|
|
102
138
|
assert.equal(v.latest, '1.19.871');
|
|
@@ -120,7 +156,7 @@ test('the cache spares the registry a second read inside the TTL', async () => {
|
|
|
120
156
|
test('concurrent cold-cache reads make ONE outbound call, not one each', async () => {
|
|
121
157
|
const store = freshStore();
|
|
122
158
|
let calls = 0;
|
|
123
|
-
const fetch = () => { calls++; return new Promise((r) => setTimeout(() => r({ ok: true, versions: ['1.19.871'] }), 5)); };
|
|
159
|
+
const fetch = () => { calls++; return new Promise((r) => setTimeout(() => r({ ok: true, versions: ['1.19.871'], released: '1.19.871' }), 5)); };
|
|
124
160
|
const args = { running: '1.19.861', store, fetch, now: () => 1 };
|
|
125
161
|
const all = await Promise.all([CU.readCoreUpdate(args), CU.readCoreUpdate(args), CU.readCoreUpdate(args)]);
|
|
126
162
|
assert.equal(calls, 1, 'three simultaneous cold reads share one registry call');
|
|
@@ -179,7 +215,7 @@ test('a real request through the assembled router returns the verdict, not a 500
|
|
|
179
215
|
|
|
180
216
|
// Warm the module's OWN cache through the public door, so the route's own
|
|
181
217
|
// dependency-free call is served from it and the test never touches the network.
|
|
182
|
-
await CU.readCoreUpdate({ running: '1.19.861', fetch: async () => ({ ok: true, versions: ['1.19.861', '1.19.871'] }) });
|
|
218
|
+
await CU.readCoreUpdate({ running: '1.19.861', fetch: async () => ({ ok: true, versions: ['1.19.861', '1.19.871'], released: '1.19.871' }) });
|
|
183
219
|
const express = r2('express');
|
|
184
220
|
const app = express();
|
|
185
221
|
app.use(r2(routePath)());
|
|
@@ -1416,7 +1416,14 @@ console.log('\nthe project\'s own update rule (task 1004445):');
|
|
|
1416
1416
|
const realChannel = require('../scripts/gds/update-channel.js');
|
|
1417
1417
|
const withRealChannel = (over) => {
|
|
1418
1418
|
const d = makeDeps(over);
|
|
1419
|
-
|
|
1419
|
+
// readReleaseTags is stubbed too: the real one shells out to npm. `released` defaults to the newest
|
|
1420
|
+
// version listed, which is what `latest` was before candidates (task 1004298).
|
|
1421
|
+
const released = over.released !== undefined ? over.released : over.available[over.available.length - 1];
|
|
1422
|
+
d.updateChannel = {
|
|
1423
|
+
...realChannel,
|
|
1424
|
+
listAvailableVersions: () => ({ ok: true, versions: over.available }),
|
|
1425
|
+
readReleaseTags: () => (released ? { ok: true, released, tags: { latest: released } } : { ok: false, error: 'no latest label', released: null, tags: {} }),
|
|
1426
|
+
};
|
|
1420
1427
|
return d;
|
|
1421
1428
|
};
|
|
1422
1429
|
|
|
@@ -1528,5 +1535,63 @@ await t('the drain loop writes the class beside the sentence (behaviour: tests/p
|
|
|
1528
1535
|
assert.match(src, /SET failure_class = \$2, failure_reason = \$3/);
|
|
1529
1536
|
});
|
|
1530
1537
|
|
|
1538
|
+
console.log('\nwho is offered a candidate (task 1004298, ADR 0361):');
|
|
1539
|
+
|
|
1540
|
+
await t('the platform hall (control-plane) is moved to a candidate newer than the release', async () => {
|
|
1541
|
+
const d = withRealChannel({ installed: '1.19.1081', served: '1.19.1083', available: ['1.19.1081', '1.19.1082', '1.19.1083'], released: '1.19.1082' });
|
|
1542
|
+
const r = await R.coreUpgradeInstance(inst({ hosting_shape: 'control-plane' }), d, intent({ target_version: '1.19.1083' }));
|
|
1543
|
+
assert.ok(!(r.error && /not a published|unknown version|outside/.test(r.error)), `the candidate must be admitted: ${r.error}`);
|
|
1544
|
+
assert.ok(d.ran.some((c) => /upgrade\.js/.test(c) && /--to 1\.19\.1083/.test(c)), 'the upgrade was invoked for the candidate');
|
|
1545
|
+
});
|
|
1546
|
+
|
|
1547
|
+
await t('any other project is refused a candidate the owner has not released', async () => {
|
|
1548
|
+
const d = withRealChannel({ installed: '1.19.1081', served: '1.19.1081', available: ['1.19.1081', '1.19.1082', '1.19.1083'], released: '1.19.1082' });
|
|
1549
|
+
const r = await R.coreUpgradeInstance(inst({ hosting_shape: 'co-tenant', slug: 'demo' }), d, intent({ target_version: '1.19.1083' }));
|
|
1550
|
+
assert.equal(r.ok, false);
|
|
1551
|
+
assert.ok(!d.ran.some((c) => /upgrade\.js/.test(c)), 'and nothing was run');
|
|
1552
|
+
});
|
|
1553
|
+
|
|
1554
|
+
await t('…and takes the released version', async () => {
|
|
1555
|
+
const d = withRealChannel({ installed: '1.19.1081', served: '1.19.1082', available: ['1.19.1081', '1.19.1082', '1.19.1083'], released: '1.19.1082' });
|
|
1556
|
+
const r = await R.coreUpgradeInstance(inst({ hosting_shape: 'co-tenant', slug: 'demo' }), d, intent({ target_version: '1.19.1082' }));
|
|
1557
|
+
assert.ok(d.ran.some((c) => /upgrade\.js/.test(c) && /--to 1\.19\.1082/.test(c)), `the release was invoked: ${r.error}`);
|
|
1558
|
+
});
|
|
1559
|
+
|
|
1560
|
+
await t('a project that follows releases is offered nothing when the registry names no release', async () => {
|
|
1561
|
+
const queries = [];
|
|
1562
|
+
await R.snapshotCoreVersions(inst({ hosting_shape: 'co-tenant', slug: 'demo' }), withRealChannel({ served: '1.19.1081', available: ['1.19.1081', '1.19.1083'], released: null, queries }));
|
|
1563
|
+
const q = queries.find((x) => /provisioning_core_version_snapshot/.test(x.sql));
|
|
1564
|
+
assert.equal(q.params[3], null, 'no recommended version on a guess');
|
|
1565
|
+
});
|
|
1566
|
+
|
|
1567
|
+
await t('the snapshot offers a tenant the release and the platform hall the newest candidate', async () => {
|
|
1568
|
+
const available = ['1.19.1081', '1.19.1082', '1.19.1083'];
|
|
1569
|
+
const offered = async (hosting_shape) => {
|
|
1570
|
+
const queries = [];
|
|
1571
|
+
await R.snapshotCoreVersions(inst({ hosting_shape, slug: hosting_shape }), withRealChannel({ served: '1.19.1081', available, released: '1.19.1082', queries }));
|
|
1572
|
+
return queries.find((x) => /provisioning_core_version_snapshot/.test(x.sql)).params[3];
|
|
1573
|
+
};
|
|
1574
|
+
assert.equal(await offered('co-tenant'), '1.19.1082');
|
|
1575
|
+
assert.equal(await offered('control-plane'), '1.19.1083');
|
|
1576
|
+
});
|
|
1577
|
+
|
|
1578
|
+
await t('a pass over several projects reads the released label ONCE, not once per project', async () => {
|
|
1579
|
+
const d = withRealChannel({ served: '1.19.1081', available: ['1.19.1081', '1.19.1082', '1.19.1083'], released: '1.19.1082', queries: [] });
|
|
1580
|
+
let reads = 0;
|
|
1581
|
+
const real = d.updateChannel.readReleaseTags;
|
|
1582
|
+
d.updateChannel.readReleaseTags = (...a) => { reads++; return real(...a); };
|
|
1583
|
+
for (const slug of ['a', 'b', 'c']) await R.snapshotCoreVersions(inst({ hosting_shape: 'co-tenant', slug }), d);
|
|
1584
|
+
assert.equal(reads, 1, `three projects, one npm view — read ${reads} times`);
|
|
1585
|
+
});
|
|
1586
|
+
|
|
1587
|
+
await t('a FAILED label read is not kept: the next project asks again', async () => {
|
|
1588
|
+
const d = withRealChannel({ served: '1.19.1081', available: ['1.19.1081', '1.19.1083'], released: null, queries: [] });
|
|
1589
|
+
let reads = 0;
|
|
1590
|
+
const real = d.updateChannel.readReleaseTags;
|
|
1591
|
+
d.updateChannel.readReleaseTags = (...a) => { reads++; return real(...a); };
|
|
1592
|
+
for (const slug of ['a', 'b']) await R.snapshotCoreVersions(inst({ hosting_shape: 'co-tenant', slug }), d);
|
|
1593
|
+
assert.equal(reads, 2);
|
|
1594
|
+
});
|
|
1595
|
+
|
|
1531
1596
|
console.log(`\ncore_upgrade_runner: ${passed} passed, ${failed} failed`);
|
|
1532
1597
|
if (failed) process.exit(1);
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
// tests/npm_release_release.mjs — the deploy page's Release button (task 1004298, ADR 0361).
|
|
2
|
+
//
|
|
3
|
+
// Merges publish as candidates; Release on /deploy makes a version the one every other project is
|
|
4
|
+
// offered. Pinned here: the evidence beside each button (how long a version ran on this hall, and
|
|
5
|
+
// whether it was rolled back), what a press does and refuses, the GitHub call it makes, and the
|
|
6
|
+
// route's gate.
|
|
7
|
+
|
|
8
|
+
import assert from 'node:assert/strict';
|
|
9
|
+
import { test } from 'node:test';
|
|
10
|
+
import { createRequire } from 'node:module';
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
|
|
13
|
+
const require = createRequire(import.meta.url);
|
|
14
|
+
const { releaseCandidates, requestRelease } = require('../modules/npm-release/release.js');
|
|
15
|
+
const gp = require('../modules/lifecycle/workflow-dispatch.js');
|
|
16
|
+
const buildRouter = require('../modules/npm-release/routes/release.js');
|
|
17
|
+
|
|
18
|
+
const H = 3600000;
|
|
19
|
+
const NOW = Date.parse('2026-10-01T12:00:00Z');
|
|
20
|
+
const at = (hoursAgo) => new Date(NOW - hoursAgo * H).toISOString();
|
|
21
|
+
|
|
22
|
+
// ── the evidence ─────────────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
const VERSIONS = ['1.20.70', '1.20.71', '1.20.72', '1.20.73', '1.20.74'];
|
|
25
|
+
|
|
26
|
+
test('candidates are the versions newer than the release and no newer than this hall runs, newest first', () => {
|
|
27
|
+
const c = releaseCandidates({ rows: [], versions: VERSIONS, released: '1.20.71', live: '1.20.73', now: NOW });
|
|
28
|
+
assert.deepEqual(c.map((x) => x.version), ['1.20.73', '1.20.72']);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('with no release yet, everything up to what this hall runs is a candidate', () => {
|
|
32
|
+
const c = releaseCandidates({ rows: [], versions: VERSIONS, released: null, live: '1.20.71', now: NOW });
|
|
33
|
+
assert.deepEqual(c.map((x) => x.version), ['1.20.71', '1.20.70']);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('the version still running here has run from when it went in until now', () => {
|
|
37
|
+
const rows = [
|
|
38
|
+
{ to_version: '1.20.72', applied_at: at(30), note: 'auto-subscription' },
|
|
39
|
+
{ to_version: '1.20.73', applied_at: at(6), note: 'auto-subscription' },
|
|
40
|
+
];
|
|
41
|
+
const [top, older] = releaseCandidates({ rows, versions: VERSIONS, released: '1.20.71', live: '1.20.73', now: NOW });
|
|
42
|
+
assert.equal(top.version, '1.20.73');
|
|
43
|
+
assert.equal(top.still_running, true);
|
|
44
|
+
assert.equal(top.hours_on_hall, 6);
|
|
45
|
+
assert.equal(older.still_running, false);
|
|
46
|
+
assert.equal(older.hours_on_hall, 24, 'it ran until the next version replaced it, not until now');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('a rolled-back move to a version is shown against that version only', () => {
|
|
50
|
+
const rows = [
|
|
51
|
+
{ to_version: '1.20.72', applied_at: at(30), note: '' },
|
|
52
|
+
{ to_version: '1.20.72', applied_at: at(10), note: 'rolled_back: health check failed (attempted 1.20.72 → 1.20.73, restored 1.20.72)' },
|
|
53
|
+
{ to_version: '1.20.73', applied_at: at(5), note: 'auto-subscription' },
|
|
54
|
+
];
|
|
55
|
+
const c = releaseCandidates({ rows, versions: VERSIONS, released: '1.20.71', live: '1.20.73', now: NOW });
|
|
56
|
+
assert.equal(c.find((x) => x.version === '1.20.73').rolled_back, true);
|
|
57
|
+
assert.equal(c.find((x) => x.version === '1.20.72').rolled_back, false);
|
|
58
|
+
assert.equal(c.find((x) => x.version === '1.20.72').hours_on_hall, 25, 'a rollback row is not a move away');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('a version the ledger never shows going in has no evidence, not a made-up duration', () => {
|
|
62
|
+
const [c] = releaseCandidates({ rows: [], versions: VERSIONS, released: '1.20.72', live: '1.20.73', now: NOW });
|
|
63
|
+
assert.equal(c.on_hall_since, null);
|
|
64
|
+
assert.equal(c.hours_on_hall, null);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('no live version means no candidates (a package this hall does not run)', () => {
|
|
68
|
+
assert.deepEqual(releaseCandidates({ rows: [], versions: VERSIONS, released: null, live: null, now: NOW }), []);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// ── a press ──────────────────────────────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
const REG = { ok: true, versions: VERSIONS, latest: '1.20.71' };
|
|
74
|
+
|
|
75
|
+
test('a releasable version dispatches publish.yml\'s release job with that version, and answers 202', async () => {
|
|
76
|
+
const calls = [];
|
|
77
|
+
const r = await requestRelease({ version: '1.20.73', registry: REG, dispatch: async (a) => { calls.push(a); return { ok: true }; } });
|
|
78
|
+
assert.equal(r.status, 202);
|
|
79
|
+
assert.deepEqual(calls, [{ workflow: 'publish.yml', ref: 'main', inputs: { release_version: '1.20.73' } }]);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('an older version, an unpublished one and a non-version are refused before GitHub is asked', async () => {
|
|
83
|
+
const dispatch = async () => { throw new Error('must not dispatch'); };
|
|
84
|
+
for (const version of ['1.20.70', '1.20.99', 'latest']) {
|
|
85
|
+
const r = await requestRelease({ version, registry: REG, dispatch });
|
|
86
|
+
assert.equal(r.status, 409, version);
|
|
87
|
+
assert.equal(r.body.error.code, 'not_releasable');
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('the version already released is a no-op, not a GitHub run', async () => {
|
|
92
|
+
const r = await requestRelease({ version: '1.20.71', registry: REG, dispatch: async () => { throw new Error('must not dispatch'); } });
|
|
93
|
+
assert.equal(r.status, 200);
|
|
94
|
+
assert.equal(r.body.noop, true);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('an unreadable registry and a missing dispatcher both release nothing, and say so', async () => {
|
|
98
|
+
assert.equal((await requestRelease({ version: '1.20.73', registry: { ok: false }, dispatch: async () => ({ ok: true }) })).status, 503);
|
|
99
|
+
assert.equal((await requestRelease({ version: '1.20.73', registry: REG, dispatch: null })).body.error.code, 'dispatch_unavailable');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('GitHub refusing for want of Actions: write is named as exactly that', async () => {
|
|
103
|
+
const r = await requestRelease({ version: '1.20.73', registry: REG, dispatch: async () => ({ ok: false, code: 'REFUSED', status: 403 }) });
|
|
104
|
+
assert.equal(r.status, 502);
|
|
105
|
+
assert.match(r.body.error.message, /Actions: write/);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// ── the GitHub call ──────────────────────────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
const REPO = { owner: 'someowner', repo: 'somerepo' };
|
|
111
|
+
|
|
112
|
+
test('dispatchWorkflow POSTs ref + inputs to the workflow\'s dispatches endpoint', async () => {
|
|
113
|
+
let captured = null;
|
|
114
|
+
const fetchImpl = async (url, opts) => { captured = { url, opts, body: JSON.parse(opts.body) }; return { ok: false, status: 204 }; };
|
|
115
|
+
const r = await gp.dispatchWorkflow({ workflow: 'publish.yml', ref: 'main', inputs: { release_version: '1.20.73' } }, { token: 'T', repoInfo: REPO, fetchImpl });
|
|
116
|
+
assert.deepEqual(r, { ok: true });
|
|
117
|
+
assert.equal(captured.url, 'https://api.github.com/repos/someowner/somerepo/actions/workflows/publish.yml/dispatches');
|
|
118
|
+
assert.equal(captured.opts.method, 'POST');
|
|
119
|
+
assert.deepEqual(captured.body, { ref: 'main', inputs: { release_version: '1.20.73' } });
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('dispatchWorkflow starts only publish.yml, and refuses an unsafe ref, without calling GitHub otherwise', async () => {
|
|
123
|
+
const fetchImpl = async () => { throw new Error('must not fetch'); };
|
|
124
|
+
for (const args of [{ workflow: '../x.yml' }, { workflow: 'publish.yml', ref: 'main; rm' }, { workflow: 'publish' }, { workflow: 'unit.yml' }, { workflow: 'deploy-prod.yml' }]) {
|
|
125
|
+
assert.equal((await gp.dispatchWorkflow(args, { token: 'T', repoInfo: REPO, fetchImpl })).code, 'BAD_INPUT', JSON.stringify(args));
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('dispatchWorkflow: no credential is UNCONFIGURED; a 403 is REFUSED with its status; a throw is UNREACHABLE', async () => {
|
|
130
|
+
// resolveToken also tries the GitHub App, which this process has no key for, then the PAT env.
|
|
131
|
+
const saved = process.env.GITHUB_PUSH_TOKEN;
|
|
132
|
+
delete process.env.GITHUB_PUSH_TOKEN;
|
|
133
|
+
try {
|
|
134
|
+
assert.equal((await gp.dispatchWorkflow({ workflow: 'publish.yml' }, { token: null, repoInfo: REPO, fetchImpl: async () => { throw new Error('must not fetch'); } })).code, 'UNCONFIGURED');
|
|
135
|
+
} finally {
|
|
136
|
+
if (saved !== undefined) process.env.GITHUB_PUSH_TOKEN = saved;
|
|
137
|
+
}
|
|
138
|
+
assert.deepEqual(await gp.dispatchWorkflow({ workflow: 'publish.yml' }, { token: 'T', repoInfo: REPO, fetchImpl: async () => ({ ok: false, status: 403 }) }), { ok: false, code: 'REFUSED', status: 403 });
|
|
139
|
+
assert.equal((await gp.dispatchWorkflow({ workflow: 'publish.yml' }, { token: 'T', repoInfo: REPO, fetchImpl: async () => { throw new Error('down'); } })).code, 'UNREACHABLE');
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// ── the route ────────────────────────────────────────────────────────────────────
|
|
143
|
+
|
|
144
|
+
test('the route is gated like the deploy page (core.pin.move), never by rank', () => {
|
|
145
|
+
const router = buildRouter();
|
|
146
|
+
const layer = router.stack.find((l) => l.route && l.route.path === '/npm-release/release');
|
|
147
|
+
assert.ok(layer.route.methods.post);
|
|
148
|
+
assert.equal(layer.route.stack.length, 3, 'requireBuilder, the permission gate, then the handler');
|
|
149
|
+
const src = fs.readFileSync(new URL('../modules/npm-release/routes/release.js', import.meta.url), 'utf8');
|
|
150
|
+
assert.match(src, /api\.requireBuilder,\s*\n\s*api\.requirePermission\('core\.pin\.move'\)/);
|
|
151
|
+
assert.doesNotMatch(src, /api\.requireRank\(/);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
const handlerOf = (opts) => buildRouter(opts).stack.find((l) => l.route).route.stack.at(-1).handle;
|
|
155
|
+
const fakeRes = () => ({ code: 200, body: null, status(s) { this.code = s; return this; }, json(v) { this.body = v; return this; } });
|
|
156
|
+
|
|
157
|
+
test('the handler refuses a body that is not { version: x.y.z }', async () => {
|
|
158
|
+
const h = handlerOf({ readRegistry: async () => { throw new Error('must not read'); } });
|
|
159
|
+
for (const body of [{}, { version: 'latest' }, { version: '1.20.73', extra: 1 }]) {
|
|
160
|
+
const res = fakeRes();
|
|
161
|
+
await h({ body, builder: { id: 1 } }, res);
|
|
162
|
+
assert.equal(res.code, 400, JSON.stringify(body));
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test('the handler refuses on a deploy page for another package', async () => {
|
|
167
|
+
const res = fakeRes();
|
|
168
|
+
await handlerOf({ pkg: () => 'left-pad', readRegistry: async () => { throw new Error('must not read'); } })({ body: { version: '1.20.73' }, builder: { id: 1 } }, res);
|
|
169
|
+
assert.equal(res.code, 409);
|
|
170
|
+
assert.equal(res.body.error.code, 'not_the_core');
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test('the handler dispatches through the lifecycle port and relays the 202', async () => {
|
|
174
|
+
const calls = [];
|
|
175
|
+
const res = fakeRes();
|
|
176
|
+
await handlerOf({
|
|
177
|
+
readRegistry: async () => ({ registry: REG }),
|
|
178
|
+
port: () => ({ dispatchWorkflow: async (a) => { calls.push(a); return { ok: true }; } }),
|
|
179
|
+
})({ body: { version: '1.20.73' }, builder: { id: 1 } }, res);
|
|
180
|
+
assert.equal(res.code, 202);
|
|
181
|
+
assert.equal(calls[0].inputs.release_version, '1.20.73');
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test('the handler with no lifecycle port releases nothing (503)', async () => {
|
|
185
|
+
const res = fakeRes();
|
|
186
|
+
await handlerOf({ readRegistry: async () => ({ registry: REG }), port: () => null })({ body: { version: '1.20.73' }, builder: { id: 1 } }, res);
|
|
187
|
+
assert.equal(res.code, 503);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test('the page renders the Release section and posts to the route', () => {
|
|
191
|
+
const src = fs.readFileSync(new URL('../modules/npm-release/public/work.js', import.meta.url), 'utf8');
|
|
192
|
+
assert.match(src, /\$\{releaseHtml\(data\)\}/);
|
|
193
|
+
assert.match(src, /client\.request\('POST', `\$\{API\}\/npm-release\/release`, \{ body: \{ version \} \}\)/);
|
|
194
|
+
assert.match(src, /window\.confirm\(/, 'a release is confirmed before it is sent');
|
|
195
|
+
});
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// tests/release_core.mjs — may this version be released? (task 1004298, ADR 0361)
|
|
2
|
+
//
|
|
3
|
+
// release-core.js is what publish.yml's release job asks before it moves the registry's `latest`
|
|
4
|
+
// label — the label every project other than the platform hall is offered. These pin each way it
|
|
5
|
+
// says no, and that a refusal never reaches the label.
|
|
6
|
+
|
|
7
|
+
import { test } from 'node:test';
|
|
8
|
+
import { strict as assert } from 'node:assert';
|
|
9
|
+
import { createRequire } from 'node:module';
|
|
10
|
+
import fs from 'node:fs';
|
|
11
|
+
import os from 'node:os';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import { fileURLToPath } from 'node:url';
|
|
14
|
+
|
|
15
|
+
const require = createRequire(import.meta.url);
|
|
16
|
+
const { releaseDecision, main } = require('../scripts/gds/release-core.js');
|
|
17
|
+
|
|
18
|
+
const VERSIONS = ['1.20.70', '1.20.75', '1.20.80', '1.20.81-rc.1'];
|
|
19
|
+
|
|
20
|
+
test('a published version newer than the release goes', () => {
|
|
21
|
+
const d = releaseDecision({ version: '1.20.80', versions: VERSIONS, latest: '1.20.70' });
|
|
22
|
+
assert.equal(d.go, true);
|
|
23
|
+
assert.match(d.reason, /was 1\.20\.70/);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('with no release yet, any published stable version goes', () => {
|
|
27
|
+
assert.equal(releaseDecision({ version: '1.20.75', versions: VERSIONS, latest: null }).go, true);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('the version already released is a harmless no-op, not a refusal', () => {
|
|
31
|
+
const d = releaseDecision({ version: '1.20.75', versions: VERSIONS, latest: '1.20.75' });
|
|
32
|
+
assert.equal(d.go, false);
|
|
33
|
+
assert.equal(d.noop, true);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('an OLDER version is refused: it would move every project backwards', () => {
|
|
37
|
+
const d = releaseDecision({ version: '1.20.70', versions: VERSIONS, latest: '1.20.75' });
|
|
38
|
+
assert.equal(d.go, false);
|
|
39
|
+
assert.equal(d.noop, false);
|
|
40
|
+
assert.match(d.reason, /backwards/);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('a version that was never published is refused', () => {
|
|
44
|
+
const d = releaseDecision({ version: '1.20.99', versions: VERSIONS, latest: '1.20.70' });
|
|
45
|
+
assert.equal(d.go, false);
|
|
46
|
+
assert.match(d.reason, /not published/);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('anything that is not a plain x.y.z is refused before it reaches a command line', () => {
|
|
50
|
+
for (const v of ['', '1.20', 'latest', '1.20.80; rm -rf /', '1.20.81-rc.1', null, undefined]) {
|
|
51
|
+
assert.equal(releaseDecision({ version: v, versions: VERSIONS, latest: '1.20.70' }).go, false, String(v));
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const run = (argv, registry) => {
|
|
56
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'release-core-'));
|
|
57
|
+
const out = path.join(dir, 'out');
|
|
58
|
+
const lines = [];
|
|
59
|
+
const code = main(argv, { env: { GITHUB_OUTPUT: out }, read: () => registry, log: (s) => lines.push(s), err: (s) => lines.push(s) });
|
|
60
|
+
return { code, output: fs.existsSync(out) ? fs.readFileSync(out, 'utf8') : '', lines };
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
test('the CLI writes go=true and exits 0 for a release', () => {
|
|
64
|
+
const r = run(['--version', '1.20.80'], { ok: true, versions: VERSIONS, latest: '1.20.75' });
|
|
65
|
+
assert.equal(r.code, 0);
|
|
66
|
+
assert.equal(r.output, 'go=true\n');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('the CLI writes go=false and exits 1 for a refusal, so the label never moves', () => {
|
|
70
|
+
const r = run(['--version', '1.20.70'], { ok: true, versions: VERSIONS, latest: '1.20.75' });
|
|
71
|
+
assert.equal(r.code, 1);
|
|
72
|
+
assert.equal(r.output, 'go=false\n');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('the CLI writes go=false and exits 0 for a double press', () => {
|
|
76
|
+
const r = run(['--version', '1.20.75'], { ok: true, versions: VERSIONS, latest: '1.20.75' });
|
|
77
|
+
assert.equal(r.code, 0);
|
|
78
|
+
assert.equal(r.output, 'go=false\n');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('an unreadable registry is a refusal, never a guess', () => {
|
|
82
|
+
const r = run(['--version', '1.20.80'], { ok: false, error: 'E503' });
|
|
83
|
+
assert.equal(r.code, 1);
|
|
84
|
+
assert.equal(r.output, 'go=false\n');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// ── the workflow wires it as described ─────────────────────────────────────────
|
|
88
|
+
|
|
89
|
+
const YML = fs.readFileSync(fileURLToPath(new URL('../.github/workflows/publish.yml', import.meta.url)), 'utf8');
|
|
90
|
+
|
|
91
|
+
test('publish.yml: the release job asks release-core.js and moves `latest` only on go', () => {
|
|
92
|
+
assert.match(YML, /release_version:/, 'the dispatch input exists');
|
|
93
|
+
assert.match(YML, /node scripts\/gds\/release-core\.js --version "\$RELEASE_VERSION"/);
|
|
94
|
+
assert.match(YML, /if: steps\.decide\.outputs\.go == 'true'\s+run: npm dist-tag add "@bongos\/core@\$RELEASE_VERSION" latest/);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('publish.yml: the version reaches the shell through env, never inline ${{ }} interpolation', () => {
|
|
98
|
+
assert.doesNotMatch(YML, /release-core\.js --version "\$\{\{/);
|
|
99
|
+
assert.doesNotMatch(YML, /dist-tag add "@bongos\/core@\$\{\{/);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('publish.yml: a release dispatch never also runs the publish job', () => {
|
|
103
|
+
assert.match(YML, /github\.event_name == 'workflow_dispatch' && inputs\.release_version == ''/);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('publish.yml: candidates publish under `next` only when the owner has armed it', () => {
|
|
107
|
+
assert.match(YML, /CANDIDATES: \$\{\{ vars\.PUBLISH_AS_CANDIDATE \}\}/);
|
|
108
|
+
assert.match(YML, /if \[ "\$CANDIDATES" = "1" \]; then TAG_ARGS="--tag next"; fi/);
|
|
109
|
+
assert.match(YML, /npm publish "\$TGZ" --access public \$TAG_ARGS/);
|
|
110
|
+
});
|
package/tests/update_channel.mjs
CHANGED
|
@@ -348,6 +348,13 @@ t('normalizeFollow: released and candidates are the words; anything else reads a
|
|
|
348
348
|
for (const v of [undefined, null, '', 'every', 'latest', 'next', 42]) assert.equal(uc.normalizeFollow(v), 'released', String(v));
|
|
349
349
|
});
|
|
350
350
|
|
|
351
|
+
t('followForShape: only the platform hall (control-plane) follows candidates (task 1004298)', () => {
|
|
352
|
+
assert.equal(uc.followForShape('control-plane'), 'candidates');
|
|
353
|
+
for (const shape of ['co-tenant', 'cloud-host', 'byo-host', undefined, null, 'bogus']) {
|
|
354
|
+
assert.equal(uc.followForShape(shape), 'released', `${shape} follows releases`);
|
|
355
|
+
}
|
|
356
|
+
});
|
|
357
|
+
|
|
351
358
|
t('versionsForFollow: candidates takes every version; released stops at the released label', () => {
|
|
352
359
|
const available = ['1.19.1038', '1.19.1039', '1.19.1040', '1.19.1041'];
|
|
353
360
|
assert.deepEqual(uc.versionsForFollow({ follow: 'candidates', available, released: '1.19.1039' }), available);
|