@bongos/core 1.20.79 → 1.20.81

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/.bongos-core.json +104 -49
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +8 -0
  4. package/clients/bongos-client/index.cjs +8 -0
  5. package/clients/bongos-client/index.d.ts +13 -0
  6. package/clients/bongos-client/index.mjs +8 -0
  7. package/docs/adr/0161-publish-on-merge.md +1 -1
  8. package/docs/adr/0361-merges-publish-as-candidates-release-decides-what-others-are-offered.md +59 -0
  9. package/docs/adr/README.md +1 -0
  10. package/docs/api/openapi.json +258 -4
  11. package/docs/api-reference.md +7 -3
  12. package/docs/copy-inventory.md +59 -53
  13. package/docs/copy-registry.json +120 -66
  14. package/docs/module-api-changelog.md +4 -0
  15. package/docs/page-inventory.json +2 -1
  16. package/docs/page-readings.json +86 -81
  17. package/docs/recipes/private-npm-distribution.md +2 -0
  18. package/docs/recipes/upgrading-the-core.md +1 -1
  19. package/modules/autonomy/cadence.js +3 -0
  20. package/modules/autonomy/db.js +136 -0
  21. package/modules/autonomy/fence.js +88 -3
  22. package/modules/autonomy/migrations/autonomy_005_builder_scope.sql +55 -0
  23. package/modules/autonomy/routes/autonomy.js +123 -3
  24. package/modules/government/catalog.js +9 -1
  25. package/modules/government/migrations/government_022_autonomy_run.sql +38 -0
  26. package/modules/hall-ui/public/gate.html +5 -2
  27. package/modules/hall-ui/public/gate.js +73 -4
  28. package/modules/hall-ui/public/settings-autobongos.js +156 -0
  29. package/modules/hall-ui/public/settings.html +19 -0
  30. package/modules/hall-ui/public/settings.js +1 -0
  31. package/modules/lifecycle/module.json +2 -1
  32. package/modules/lifecycle/routes/lifecycle.js +7 -0
  33. package/modules/lifecycle/workflow-dispatch.js +45 -0
  34. package/modules/npm-release/module.json +2 -1
  35. package/modules/npm-release/public/work.js +74 -0
  36. package/modules/npm-release/release.js +129 -0
  37. package/modules/npm-release/routes/release.js +65 -0
  38. package/modules/npm-release/work.js +16 -0
  39. package/package-lock.json +2 -2
  40. package/package.json +1 -1
  41. package/release-notes.json +16 -0
  42. package/scripts/gds/provision-core-upgrade.js +30 -2
  43. package/scripts/gds/release-core.js +80 -0
  44. package/scripts/gds/update-channel.js +12 -3
  45. package/src/bongos/core-update.js +17 -8
  46. package/src/module-api.js +1 -1
  47. package/tests/autonomy_builder_scope.mjs +474 -0
  48. package/tests/autonomy_fence_priority.mjs +3 -1
  49. package/tests/core_update_banner.mjs +41 -5
  50. package/tests/core_upgrade_runner.mjs +66 -1
  51. package/tests/npm_release_release.mjs +195 -0
  52. package/tests/release_core.mjs +110 -0
  53. package/tests/update_channel.mjs +7 -0
@@ -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
+ });
@@ -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);