@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
|
@@ -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,
|
|
@@ -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);
|
package/tests/init.mjs
CHANGED
|
@@ -389,15 +389,18 @@ test('layerPackageJson: no package.json → creates one; second run → present,
|
|
|
389
389
|
// ---- greenfield installability: package.json minting + writeConfigs (ADR 0108 gap 1/4, task 2053) ----
|
|
390
390
|
// PURE — no fs, no npm. The imperative vendor + `npm install` lockfile step lives in main()
|
|
391
391
|
// and is deliberately NOT unit-tested (keeps real npm out of the suite, per the task).
|
|
392
|
-
test('buildInstancePackageJson: no package.json → mints a private one with the caret core dep +
|
|
392
|
+
test('buildInstancePackageJson: no package.json → mints a private one with the caret core dep + hall/migrate scripts', () => {
|
|
393
393
|
const pkg = init.buildInstancePackageJson(null, { name: 'demo' });
|
|
394
394
|
assert.equal(pkg.name, 'demo');
|
|
395
395
|
assert.equal(pkg.private, true);
|
|
396
396
|
assert.equal(pkg.dependencies[init.CORE_PKG], init.coreDepRange()); // caret when no tarball vendored
|
|
397
|
-
assert.equal(pkg.scripts.
|
|
397
|
+
assert.equal(pkg.scripts.hall, init.INSTANCE_HALL_SCRIPT);
|
|
398
398
|
assert.equal(pkg.scripts.migrate, init.INSTANCE_MIGRATE_SCRIPT);
|
|
399
|
-
//
|
|
400
|
-
|
|
399
|
+
// `npm start` is left for the project's OWN app (task 1004484): a host like Render reads it
|
|
400
|
+
// as "how this app starts", and the hall there has no database and times out.
|
|
401
|
+
assert.equal(pkg.scripts.start, undefined);
|
|
402
|
+
// the hall/migrate scripts must target the installed core package so `npm ci` output is runnable
|
|
403
|
+
assert.match(pkg.scripts.hall, /node_modules\/@bongos\/core\/src\/platform-server\.js$/);
|
|
401
404
|
assert.match(pkg.scripts.migrate, /node_modules\/@bongos\/core\/scripts\/migrate\.sh$/);
|
|
402
405
|
});
|
|
403
406
|
|
|
@@ -419,6 +422,18 @@ test('buildInstancePackageJson: preserves existing fields + never clobbers a cus
|
|
|
419
422
|
assert.equal(pkg.dependencies[init.CORE_PKG], 'file:vendor/bongos-core-1.17.0.tgz'); // dep (re)pinned
|
|
420
423
|
});
|
|
421
424
|
|
|
425
|
+
test('buildInstancePackageJson: a start that is exactly the old scaffolded hall command moves to "hall" (task 1004484)', () => {
|
|
426
|
+
const old = 'node node_modules/@bongos/core/src/platform-server.js';
|
|
427
|
+
const pkg = init.buildInstancePackageJson({ scripts: { start: old, test: 'node --test' } }, { name: 'demo' });
|
|
428
|
+
assert.equal(pkg.scripts.start, undefined, 'npm start no longer runs the hall');
|
|
429
|
+
assert.equal(pkg.scripts.hall, old);
|
|
430
|
+
assert.equal(pkg.scripts.test, 'node --test');
|
|
431
|
+
// an owner's own "hall" script is never overwritten by the move
|
|
432
|
+
const kept = init.buildInstancePackageJson({ scripts: { start: old, hall: 'node my-hall.js' } }, { name: 'demo' });
|
|
433
|
+
assert.equal(kept.scripts.hall, 'node my-hall.js');
|
|
434
|
+
assert.equal(kept.scripts.start, old, 'with "hall" taken, start is left as the owner has it');
|
|
435
|
+
});
|
|
436
|
+
|
|
422
437
|
// --- enabled-module dep composition (ADR 0138 / task 2160) --------------------
|
|
423
438
|
|
|
424
439
|
test('enabledModuleDeps: merges only the ENABLED modules’ declared deps', () => {
|
|
@@ -475,7 +490,8 @@ test('writeConfigs (greenfield): mints package.json with the caret core dep + st
|
|
|
475
490
|
assert.equal(pkg.name, 'cloud-bongos'); // slugified productName
|
|
476
491
|
assert.equal(pkg.private, true);
|
|
477
492
|
assert.equal(pkg.dependencies[init.CORE_PKG], init.coreDepRange());
|
|
478
|
-
assert.equal(pkg.scripts.
|
|
493
|
+
assert.equal(pkg.scripts.hall, init.INSTANCE_HALL_SCRIPT);
|
|
494
|
+
assert.equal(pkg.scripts.start, undefined);
|
|
479
495
|
assert.equal(pkg.scripts.migrate, init.INSTANCE_MIGRATE_SCRIPT);
|
|
480
496
|
} finally {
|
|
481
497
|
rmSync(dir, { recursive: true, force: true });
|
|
@@ -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
|
+
});
|