@bongos/core 1.19.1038 → 1.19.1040
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 +98 -38
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +4 -0
- package/clients/bongos-client/index.cjs +4 -0
- package/clients/bongos-client/index.d.ts +4 -0
- package/clients/bongos-client/index.mjs +4 -0
- package/docs/api/openapi.json +39 -1
- package/docs/api-reference.md +7 -1
- package/docs/copy-inventory.md +2 -2
- package/docs/copy-registry.json +2 -2
- package/docs/file-map.md +1 -0
- package/docs/module-api-changelog.md +5 -1
- package/docs/recipes/core-release-pipeline.md +1 -1
- package/docs/recipes/private-npm-distribution.md +1 -1
- package/modules/hall-ui/public/deploy.html +9 -0
- package/modules/hall-ui/public/deploy.js +14 -1
- package/modules/hall-ui/public/deploy.states.json +14 -0
- package/modules/lifecycle/conflict-resolve.js +9 -1
- package/modules/npm-release/module.json +19 -0
- package/modules/npm-release/public/work.css +8 -0
- package/modules/npm-release/public/work.js +261 -0
- package/modules/npm-release/registry.js +271 -0
- package/modules/npm-release/routes/work.js +46 -0
- package/modules/npm-release/work.js +252 -0
- package/modules/provisioning/starter-bundles.js +6 -2
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +24 -0
- package/scripts/gds/exec-path-guard.js +1 -1
- package/scripts/gds/install-git-hooks.js +18 -2
- package/scripts/gds/plain-cards.js +5 -0
- package/scripts/gds/publish-integration-gate.js +142 -0
- package/scripts/gds/publish-manifest.js +1 -0
- package/scripts/gds/ship-land.js +72 -6
- package/src/bongos/module-scope-map.js +5 -0
- package/src/module-api.js +1 -1
- package/tests/conflict_resolve.mjs +15 -1
- package/tests/merge_driver_register.mjs +28 -0
- package/tests/module_loader.mjs +1 -1
- package/tests/npm_release_page.mjs +173 -0
- package/tests/npm_release_work.mjs +350 -0
- package/tests/publish_integration_gate.mjs +198 -0
- package/tests/render_check.mjs +3 -2
- package/tests/ship_land_ci_not_started.mjs +168 -0
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
// tests/npm_release_work.mjs — the npm-release module's "where is the work?" reading
|
|
2
|
+
// (task 1002622): which stage every recent task has reached, and what each version carried.
|
|
3
|
+
//
|
|
4
|
+
// WHAT IS AT RISK. The owner's complaint was a deploy page that named none of the fifteen
|
|
5
|
+
// tasks sitting between the version the hall ran and the newest one. The failure modes that
|
|
6
|
+
// would bring that back are quiet ones: a task put in the wrong stage, a version whose notes
|
|
7
|
+
// could not be read rendered as a version with no work, or a registry that did not answer
|
|
8
|
+
// rendered as "nothing new". Each case below pins one of them.
|
|
9
|
+
//
|
|
10
|
+
// THE TRANSPORT IS FAKED, THE PARSING IS NOT. `get` returns a real stream of a real gzipped
|
|
11
|
+
// tar built here, so the tar reader, gunzip, chunk boundaries and the registry's JSON are all
|
|
12
|
+
// exercised as they run in production. Only the network is missing.
|
|
13
|
+
//
|
|
14
|
+
// Run: node tests/npm_release_work.mjs
|
|
15
|
+
import assert from 'node:assert/strict';
|
|
16
|
+
import { test } from 'node:test';
|
|
17
|
+
import { createRequire } from 'node:module';
|
|
18
|
+
import { EventEmitter } from 'node:events';
|
|
19
|
+
import { Readable } from 'node:stream';
|
|
20
|
+
import zlib from 'node:zlib';
|
|
21
|
+
|
|
22
|
+
const require = createRequire(import.meta.url);
|
|
23
|
+
const { readWork } = require('../modules/npm-release/work.js');
|
|
24
|
+
const buildRouter = require('../modules/npm-release/routes/work.js');
|
|
25
|
+
|
|
26
|
+
const ORIGIN = 'https://registry.npmjs.org';
|
|
27
|
+
const PKG = '@bongos/core';
|
|
28
|
+
|
|
29
|
+
// ---- a real tarball, built by hand ---------------------------------------------------
|
|
30
|
+
|
|
31
|
+
function tarHeader(name, size, type = '0') {
|
|
32
|
+
const h = Buffer.alloc(512, 0);
|
|
33
|
+
h.write(name.slice(0, 100), 0, 'utf8');
|
|
34
|
+
h.write('0000644\0', 100, 'ascii');
|
|
35
|
+
h.write('0000000\0', 108, 'ascii');
|
|
36
|
+
h.write('0000000\0', 116, 'ascii');
|
|
37
|
+
h.write(`${size.toString(8).padStart(11, '0')}\0`, 124, 'ascii');
|
|
38
|
+
h.write('00000000000\0', 136, 'ascii');
|
|
39
|
+
h.write(' ', 148, 'ascii');
|
|
40
|
+
h.write(type, 156, 'ascii');
|
|
41
|
+
h.write('ustar\0', 257, 'ascii');
|
|
42
|
+
h.write('00', 263, 'ascii');
|
|
43
|
+
let sum = 0;
|
|
44
|
+
for (const b of h) sum += b;
|
|
45
|
+
h.write(`${sum.toString(8).padStart(6, '0')}\0 `, 148, 'ascii');
|
|
46
|
+
return h;
|
|
47
|
+
}
|
|
48
|
+
function tarEntry(name, data, type = '0') {
|
|
49
|
+
const body = Buffer.isBuffer(data) ? data : Buffer.from(data);
|
|
50
|
+
const pad = Buffer.alloc((512 - (body.length % 512)) % 512, 0);
|
|
51
|
+
return Buffer.concat([tarHeader(name, body.length, type), body, pad]);
|
|
52
|
+
}
|
|
53
|
+
function paxEntry(path) {
|
|
54
|
+
// A pax record is "<len> <key>=<value>\n", where <len> counts its own digits too.
|
|
55
|
+
const rec = (k, v) => {
|
|
56
|
+
const body = ` ${k}=${v}\n`;
|
|
57
|
+
let n = body.length + 1;
|
|
58
|
+
while (String(n).length + body.length !== n) n = String(n).length + body.length;
|
|
59
|
+
return `${n}${body}`;
|
|
60
|
+
};
|
|
61
|
+
return tarEntry('PaxHeader/x', rec('path', path), 'x');
|
|
62
|
+
}
|
|
63
|
+
/** A gzipped npm-shaped tarball. `notes` null leaves release-notes.json out. */
|
|
64
|
+
function tarball({ notes, padBytes = 40000, pax = false } = {}) {
|
|
65
|
+
const parts = [
|
|
66
|
+
tarEntry('package/package.json', JSON.stringify({ name: PKG })),
|
|
67
|
+
// A large file BEFORE the notes, so the reader has to skip across many chunks.
|
|
68
|
+
tarEntry('package/big.js', Buffer.alloc(padBytes, 97)),
|
|
69
|
+
];
|
|
70
|
+
if (notes) {
|
|
71
|
+
const json = JSON.stringify(notes);
|
|
72
|
+
if (pax) parts.push(paxEntry('package/release-notes.json'), tarEntry('package/SHORTNAME', json));
|
|
73
|
+
else parts.push(tarEntry('package/release-notes.json', json));
|
|
74
|
+
}
|
|
75
|
+
parts.push(Buffer.alloc(1024, 0));
|
|
76
|
+
return zlib.gzipSync(Buffer.concat(parts));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ---- a fake registry -----------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
function packument({ versions, latest, times }) {
|
|
82
|
+
const doc = { name: PKG, 'dist-tags': { latest }, versions: {}, time: {} };
|
|
83
|
+
for (const v of versions) {
|
|
84
|
+
doc.versions[v] = { version: v, dist: { tarball: `${ORIGIN}/@bongos/core/-/core-${v}.tgz` } };
|
|
85
|
+
doc.time[v] = times[v];
|
|
86
|
+
}
|
|
87
|
+
return doc;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** `get` answering from `routes` (url → { status, body }), recording every URL asked for. */
|
|
91
|
+
function fakeGet(routes) {
|
|
92
|
+
const asked = [];
|
|
93
|
+
const get = (url, opts, cb) => {
|
|
94
|
+
asked.push(url);
|
|
95
|
+
const req = new EventEmitter();
|
|
96
|
+
req.destroy = () => {};
|
|
97
|
+
setImmediate(() => {
|
|
98
|
+
const r = routes[url];
|
|
99
|
+
if (!r) { req.emit('error', new Error(`no route for ${url}`)); return; }
|
|
100
|
+
const body = Buffer.isBuffer(r.body) ? r.body : Buffer.from(typeof r.body === 'string' ? r.body : JSON.stringify(r.body));
|
|
101
|
+
// Small chunks, so a tar header lands across a chunk boundary.
|
|
102
|
+
const chunks = [];
|
|
103
|
+
for (let i = 0; i < body.length; i += 777) chunks.push(body.subarray(i, i + 777));
|
|
104
|
+
const res = Readable.from(chunks);
|
|
105
|
+
res.statusCode = r.status || 200;
|
|
106
|
+
cb(res);
|
|
107
|
+
});
|
|
108
|
+
return req;
|
|
109
|
+
};
|
|
110
|
+
return { get, asked };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function fakePool(tasks, titles = {}) {
|
|
114
|
+
const queries = [];
|
|
115
|
+
return {
|
|
116
|
+
queries,
|
|
117
|
+
async query(sql, params) {
|
|
118
|
+
queries.push({ sql, params });
|
|
119
|
+
if (/FROM tasks t/.test(sql)) return { rows: tasks };
|
|
120
|
+
if (/WHERE id = ANY/.test(sql)) return { rows: params[0].filter((id) => titles[id]).map((id) => ({ id, title: titles[id] })) };
|
|
121
|
+
throw new Error(`unexpected query: ${sql}`);
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const freshStore = () => ({ packument: { at: 0, value: null, inflight: null }, notes: { version: null, at: 0, value: null, inflight: null } });
|
|
127
|
+
|
|
128
|
+
// Versions 1.0.1 … 1.0.5, published a day apart, ending 2026-09-26T10:00Z.
|
|
129
|
+
const TIMES = {
|
|
130
|
+
'1.0.1': '2026-09-22T10:00:00.000Z',
|
|
131
|
+
'1.0.2': '2026-09-23T10:00:00.000Z',
|
|
132
|
+
'1.0.3': '2026-09-24T10:00:00.000Z',
|
|
133
|
+
'1.0.4': '2026-09-25T10:00:00.000Z',
|
|
134
|
+
'1.0.5': '2026-09-26T10:00:00.000Z',
|
|
135
|
+
};
|
|
136
|
+
const NOTES = {
|
|
137
|
+
'1.0.1': [{ id: '11', text: 'An early fix.' }],
|
|
138
|
+
'1.0.2': [{ id: '12', text: 'Released and running.' }],
|
|
139
|
+
'1.0.3': [{ id: '13', text: 'Running here.' }, { text: 'A hand-landed change nobody numbered.' }],
|
|
140
|
+
'1.0.4': [],
|
|
141
|
+
'1.0.5': [{ id: '15', text: 'Published, not running yet.' }],
|
|
142
|
+
};
|
|
143
|
+
const INSTALLED_TO_103 = () => ({ '1.0.1': NOTES['1.0.1'], '1.0.2': NOTES['1.0.2'], '1.0.3': NOTES['1.0.3'] });
|
|
144
|
+
|
|
145
|
+
const row = (id, status, at, extra = {}) => ({ id, title: `Task ${id} title`, status, at, value_summary: null, builder: 'Lars', ...extra });
|
|
146
|
+
const TASKS = [
|
|
147
|
+
row('20', 'confirmed', '2026-09-26T11:00:00.000Z'), // done, not merged
|
|
148
|
+
row('15', 'shipped', '2026-09-26T09:00:00.000Z'), // in 1.0.5 > live 1.0.3
|
|
149
|
+
row('13', 'shipped', '2026-09-24T09:00:00.000Z'), // in 1.0.3 = live
|
|
150
|
+
row('12', 'shipped', '2026-09-23T09:00:00.000Z'), // in 1.0.2
|
|
151
|
+
row('21', 'shipped', '2026-09-26T12:00:00.000Z'), // after 1.0.5 went out
|
|
152
|
+
row('22', 'shipped', '2026-09-25T12:00:00.000Z'), // before it, named nowhere
|
|
153
|
+
row('23', 'shipped', '2026-09-24T08:00:00.000Z', { value_summary: 'A hand-landed change nobody numbered.' }),
|
|
154
|
+
];
|
|
155
|
+
|
|
156
|
+
function setup({ latest = '1.0.5', tgz = tarball({ notes: NOTES }), pkStatus = 200, tgzStatus = 200, installed = INSTALLED_TO_103, tasks = TASKS, titles = {} } = {}) {
|
|
157
|
+
const versions = Object.keys(TIMES);
|
|
158
|
+
const routes = {
|
|
159
|
+
[`${ORIGIN}/@bongos%2fcore`]: { status: pkStatus, body: pkStatus === 200 ? packument({ versions, latest, times: TIMES }) : 'nope' },
|
|
160
|
+
[`${ORIGIN}/@bongos/core/-/core-1.0.5.tgz`]: { status: tgzStatus, body: tgz },
|
|
161
|
+
};
|
|
162
|
+
const fg = fakeGet(routes);
|
|
163
|
+
const pool = fakePool(tasks, titles);
|
|
164
|
+
const store = freshStore();
|
|
165
|
+
const read = (over = {}) => readWork({ pool, pkg: PKG, live: '1.0.3', get: fg.get, store, installedNotes: installed, now: () => Date.parse('2026-09-26T13:00:00Z'), ...over });
|
|
166
|
+
return { read, asked: fg.asked, pool, store };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const ids = (list) => list.map((t) => t.id);
|
|
170
|
+
|
|
171
|
+
// ---- the stages ---------------------------------------------------------------------
|
|
172
|
+
|
|
173
|
+
test('every task lands in the stage it has actually reached', async () => {
|
|
174
|
+
const { read } = setup();
|
|
175
|
+
const d = await read();
|
|
176
|
+
assert.deepEqual(ids(d.stages.not_merged), ['20']);
|
|
177
|
+
assert.deepEqual(ids(d.stages.not_live), ['15']);
|
|
178
|
+
assert.deepEqual(ids(d.stages.released), ['13', '12', '23']);
|
|
179
|
+
assert.deepEqual(ids(d.stages.unversioned), ['21']);
|
|
180
|
+
assert.deepEqual(ids(d.stages.no_version), ['22']);
|
|
181
|
+
assert.deepEqual(d.stages.live_unreleased, []);
|
|
182
|
+
assert.equal(d.live, '1.0.3');
|
|
183
|
+
assert.equal(d.newest, '1.0.5');
|
|
184
|
+
assert.equal(d.released, '1.0.5');
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test('a task published before this hall ran it says it was already offered', async () => {
|
|
188
|
+
const d = await setup().read();
|
|
189
|
+
const t = d.stages.not_live[0];
|
|
190
|
+
assert.equal(t.version, '1.0.5');
|
|
191
|
+
assert.equal(t.offered, true);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test('work running here but not released is its own stage', async () => {
|
|
195
|
+
// `latest` still on 1.0.2: 1.0.3 runs here and has not been released.
|
|
196
|
+
const d = await setup({ latest: '1.0.2' }).read();
|
|
197
|
+
assert.deepEqual(ids(d.stages.live_unreleased), ['13', '23']);
|
|
198
|
+
assert.deepEqual(ids(d.stages.released), ['12']);
|
|
199
|
+
assert.equal(d.stages.not_live[0].offered, undefined, 'not offered: 1.0.5 is past the release');
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test('a merge with no task id is matched on the task\'s own ship summary', async () => {
|
|
203
|
+
const d = await setup().read();
|
|
204
|
+
const t = d.stages.released.find((x) => x.id === '23');
|
|
205
|
+
assert.equal(t.version, '1.0.3');
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test('with no running version (another package), nothing is "not live"', async () => {
|
|
209
|
+
const d = await setup().read({ live: null });
|
|
210
|
+
assert.deepEqual(d.stages.not_live, []);
|
|
211
|
+
assert.ok(ids(d.stages.released).includes('15'));
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
// ---- where the notes come from --------------------------------------------------------
|
|
215
|
+
|
|
216
|
+
test('notes past the installed copy are read out of the newest package', async () => {
|
|
217
|
+
const { read, asked } = setup();
|
|
218
|
+
const d = await read();
|
|
219
|
+
assert.ok(asked.includes(`${ORIGIN}/@bongos/core/-/core-1.0.5.tgz`));
|
|
220
|
+
assert.deepEqual(d.notes.source, { from: 'package', version: '1.0.5' });
|
|
221
|
+
assert.equal(d.notes.covers_to, '1.0.5');
|
|
222
|
+
assert.equal(d.notes.error, null);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test('a pax long-name header is followed to the notes file', async () => {
|
|
226
|
+
const d = await setup({ tgz: tarball({ notes: NOTES, pax: true }) }).read();
|
|
227
|
+
assert.equal(d.notes.covers_to, '1.0.5');
|
|
228
|
+
assert.deepEqual(ids(d.stages.not_live), ['15']);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test('when the installed notes already reach the newest version, nothing is downloaded', async () => {
|
|
232
|
+
const { read, asked } = setup({ installed: () => NOTES });
|
|
233
|
+
const d = await read();
|
|
234
|
+
assert.ok(!asked.some((u) => u.endsWith('.tgz')), 'no tarball download');
|
|
235
|
+
assert.deepEqual(d.notes.source, { from: 'installed', version: '1.0.5' });
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
test('a package without notes is an error, not an empty history', async () => {
|
|
239
|
+
const d = await setup({ tgz: tarball({ notes: null }) }).read();
|
|
240
|
+
assert.match(d.notes.error, /without release notes/);
|
|
241
|
+
// The installed copy stands in: the work up to 1.0.3 is still placed.
|
|
242
|
+
assert.deepEqual(d.notes.source, { from: 'installed', version: '1.0.3' });
|
|
243
|
+
assert.ok(ids(d.stages.released).includes('13'));
|
|
244
|
+
// 1.0.5 is listed as unread, never as "no work".
|
|
245
|
+
const v = d.history.versions.find((x) => x.version === '1.0.5');
|
|
246
|
+
assert.equal(v.entries, null);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test('a download that fails says so', async () => {
|
|
250
|
+
const d = await setup({ tgzStatus: 503 }).read();
|
|
251
|
+
assert.match(d.notes.error, /could not download 1\.0\.5/);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test('a registry that does not answer is reported, and the installed notes still place work', async () => {
|
|
255
|
+
const d = await setup({ pkStatus: 500 }).read();
|
|
256
|
+
assert.equal(d.registry.ok, false);
|
|
257
|
+
assert.match(d.registry.error, /500/);
|
|
258
|
+
assert.equal(d.newest, null);
|
|
259
|
+
assert.equal(d.released, null);
|
|
260
|
+
assert.ok(ids(d.stages.released).includes('13'), '1.0.3 work is still placed from the installed notes');
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
test('the package download is only ever followed on the registry\'s own origin', async () => {
|
|
264
|
+
const versions = Object.keys(TIMES);
|
|
265
|
+
const doc = packument({ versions, latest: '1.0.5', times: TIMES });
|
|
266
|
+
doc.versions['1.0.5'].dist.tarball = 'https://evil.example/core.tgz';
|
|
267
|
+
const fg = fakeGet({ [`${ORIGIN}/@bongos%2fcore`]: { body: doc } });
|
|
268
|
+
const d = await readWork({ pool: fakePool(TASKS), pkg: PKG, live: '1.0.3', get: fg.get, store: freshStore(), installedNotes: INSTALLED_TO_103 });
|
|
269
|
+
assert.ok(!fg.asked.some((u) => u.includes('evil.example')));
|
|
270
|
+
assert.match(d.notes.error, /no download/);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
// ---- the cache --------------------------------------------------------------------------
|
|
274
|
+
|
|
275
|
+
test('a second read within minutes asks the registry nothing new', async () => {
|
|
276
|
+
const { read, asked } = setup();
|
|
277
|
+
await read();
|
|
278
|
+
const before = asked.length;
|
|
279
|
+
await read();
|
|
280
|
+
assert.equal(asked.length, before);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
test('a failed notes read is not retried on every page load', async () => {
|
|
284
|
+
const { read, asked } = setup({ tgzStatus: 503 });
|
|
285
|
+
await read();
|
|
286
|
+
await read();
|
|
287
|
+
assert.equal(asked.filter((u) => u.endsWith('.tgz')).length, 1);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
// ---- the version history ----------------------------------------------------------------
|
|
291
|
+
|
|
292
|
+
test('the history lists each version\'s tasks newest first, with titles from the ledger', async () => {
|
|
293
|
+
const d = await setup({ titles: { 11: 'Old fix title' } }).read();
|
|
294
|
+
const v = d.history.versions;
|
|
295
|
+
assert.deepEqual(v.map((x) => x.version), ['1.0.5', '1.0.4', '1.0.3', '1.0.2', '1.0.1']);
|
|
296
|
+
assert.equal(v[0].released, true);
|
|
297
|
+
assert.equal(v[2].live, true);
|
|
298
|
+
assert.deepEqual(v[1].entries, [], 'a housekeeping-only version is an empty list, not unread');
|
|
299
|
+
assert.equal(v[0].entries[0].title, 'Task 15 title');
|
|
300
|
+
assert.equal(v[4].entries[0].title, 'Old fix title', 'a task outside the window is looked up by id');
|
|
301
|
+
assert.equal(v[0].published_at, TIMES['1.0.5']);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
test('the history pages with before + limit', async () => {
|
|
305
|
+
const { read } = setup();
|
|
306
|
+
const first = await read({ limit: 2 });
|
|
307
|
+
assert.deepEqual(first.history.versions.map((x) => x.version), ['1.0.5', '1.0.4']);
|
|
308
|
+
assert.equal(first.history.more, true);
|
|
309
|
+
const next = await read({ limit: 2, before: '1.0.4' });
|
|
310
|
+
assert.deepEqual(next.history.versions.map((x) => x.version), ['1.0.3', '1.0.2']);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
test('the ledger window is clamped', async () => {
|
|
314
|
+
const { read, pool } = setup();
|
|
315
|
+
const windows = () => pool.queries.filter((q) => /FROM tasks t/.test(q.sql)).map((q) => q.params[0]);
|
|
316
|
+
await read({ days: '9999' });
|
|
317
|
+
await read({ days: 'nonsense' });
|
|
318
|
+
assert.deepEqual(windows(), [60, 14]);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
// ---- the route ------------------------------------------------------------------------
|
|
322
|
+
|
|
323
|
+
function routeLayers() {
|
|
324
|
+
const router = buildRouter({ read: async (args) => ({ ok: true, args }) });
|
|
325
|
+
const layer = router.stack.find((l) => l.route && l.route.path === '/npm-release/work');
|
|
326
|
+
return layer.route.stack.map((s) => s.handle);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
test('the route is gated like the deploy page: a builder holding core.pin.move', async () => {
|
|
330
|
+
const handles = routeLayers();
|
|
331
|
+
assert.equal(handles.length, 3, 'requireBuilder, the permission gate, then the handler');
|
|
332
|
+
const src = (await import('node:fs')).readFileSync(new URL('../modules/npm-release/routes/work.js', import.meta.url), 'utf8');
|
|
333
|
+
assert.match(src, /api\.requireBuilder,\s*\n\s*api\.requirePermission\('core\.pin\.move'\)/);
|
|
334
|
+
assert.doesNotMatch(src, /api\.requireRank\(/);
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
test('the handler passes the query through and answers 500 plainly when the read throws', async () => {
|
|
338
|
+
const [, , ok] = routeLayers();
|
|
339
|
+
let body = null;
|
|
340
|
+
await ok({ query: { days: '7', before: '1.0.3', limit: '5' } }, { json(v) { body = v; } });
|
|
341
|
+
assert.equal(body.args.days, '7');
|
|
342
|
+
assert.equal(body.args.before, '1.0.3');
|
|
343
|
+
|
|
344
|
+
const router = buildRouter({ read: async () => { throw new Error('boom'); } });
|
|
345
|
+
const h = router.stack[0].route.stack.at(-1).handle;
|
|
346
|
+
let status = 0; let err = null;
|
|
347
|
+
await h({ query: {} }, { status(s) { status = s; return this; }, json(v) { err = v; } });
|
|
348
|
+
assert.equal(status, 500);
|
|
349
|
+
assert.equal(err.error.code, 'work_read_failed');
|
|
350
|
+
});
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
// tests/publish_integration_gate.mjs — npm publishing waits for the integration lane's
|
|
2
|
+
// verdict on the commit it releases (task 1004290).
|
|
3
|
+
//
|
|
4
|
+
// WHY. publish.yml fires on `unit`, which is DB-free by design. The proof that the
|
|
5
|
+
// PACKAGED core can migrate an empty database (tests/consumer_layout_boot.mjs, task
|
|
6
|
+
// 1002863) runs in integration.yml, and until this gate its verdict arrived after the
|
|
7
|
+
// publish and gated nothing. What is pinned here:
|
|
8
|
+
// 1. the verdicts, from the real list-workflow-runs payload shape
|
|
9
|
+
// 2. the wait: polls while running, stops at once on an unreadable API, times out loudly
|
|
10
|
+
// 3. the lane is WIRED: actions:read, the step sits before any install/pack/publish, and
|
|
11
|
+
// every later armed step carries the integration go — so a new step cannot slip past
|
|
12
|
+
//
|
|
13
|
+
// Network-free: fetch, sleep and the clock are injected.
|
|
14
|
+
//
|
|
15
|
+
// Run: node tests/publish_integration_gate.mjs
|
|
16
|
+
|
|
17
|
+
import { strict as assert } from 'node:assert';
|
|
18
|
+
import { createRequire } from 'node:module';
|
|
19
|
+
import fs from 'node:fs';
|
|
20
|
+
import os from 'node:os';
|
|
21
|
+
import path from 'node:path';
|
|
22
|
+
|
|
23
|
+
const require = createRequire(import.meta.url);
|
|
24
|
+
const gate = require('../scripts/gds/publish-integration-gate.js');
|
|
25
|
+
const { decideIntegrationGate, waitForIntegration, reportLines, main, runsUrl } = gate;
|
|
26
|
+
|
|
27
|
+
let passed = 0;
|
|
28
|
+
let failed = 0;
|
|
29
|
+
async function test(name, fn) {
|
|
30
|
+
try { await fn(); passed++; console.log(` ok ${name}`); }
|
|
31
|
+
catch (err) { failed++; console.log(` FAIL ${name}`); console.log(` ${err.stack || err.message}`); }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const SHA = '078e8b7b'.padEnd(40, '0');
|
|
35
|
+
// The fields GitHub's list-workflow-runs answers with that the gate reads (from a real
|
|
36
|
+
// integration run on main, 2026-09-26: run 36210...; trimmed to what matters).
|
|
37
|
+
const ghRun = (run_number, status, conclusion) => ({
|
|
38
|
+
id: 36210000000 + run_number, run_number, event: 'push', head_sha: SHA, status, conclusion,
|
|
39
|
+
created_at: `2026-09-26T03:${String(run_number % 60).padStart(2, '0')}:00Z`,
|
|
40
|
+
html_url: `https://github.com/example-org/example-repo/actions/runs/${36210000000 + run_number}`,
|
|
41
|
+
});
|
|
42
|
+
const okJson = (body) => ({ ok: true, status: 200, json: async () => body });
|
|
43
|
+
|
|
44
|
+
// ---- 1. the verdicts ----
|
|
45
|
+
|
|
46
|
+
await test('decideIntegrationGate: success → go, running or absent → wait, cancelled → superseded, anything else → red', async () => {
|
|
47
|
+
assert.equal(decideIntegrationGate([ghRun(1, 'completed', 'success')]).verdict, 'go');
|
|
48
|
+
assert.equal(decideIntegrationGate([]).verdict, 'wait', 'the run has not been created yet');
|
|
49
|
+
assert.equal(decideIntegrationGate(undefined).verdict, 'wait');
|
|
50
|
+
for (const status of ['queued', 'in_progress', 'waiting', 'pending']) {
|
|
51
|
+
assert.equal(decideIntegrationGate([ghRun(1, status, null)]).verdict, 'wait', status);
|
|
52
|
+
}
|
|
53
|
+
assert.equal(decideIntegrationGate([ghRun(1, 'completed', 'cancelled')]).verdict, 'superseded');
|
|
54
|
+
for (const c of ['failure', 'timed_out', 'startup_failure', 'action_required', 'skipped', 'neutral', 'stale', null]) {
|
|
55
|
+
assert.equal(decideIntegrationGate([ghRun(1, 'completed', c)]).verdict, 'red', `fail-closed on ${c}`);
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
await test('decideIntegrationGate: the NEWEST run on the sha decides, whatever order the API lists them in', async () => {
|
|
60
|
+
assert.equal(decideIntegrationGate([ghRun(5, 'completed', 'failure'), ghRun(7, 'completed', 'success')]).verdict, 'go');
|
|
61
|
+
assert.equal(decideIntegrationGate([ghRun(7, 'completed', 'failure'), ghRun(5, 'completed', 'success')]).verdict, 'red');
|
|
62
|
+
assert.equal(decideIntegrationGate([ghRun(8, 'in_progress', null), ghRun(7, 'completed', 'success')]).verdict, 'wait');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
await test('only PUSH runs are asked for: the weekly scheduled run carries HEAD\'s sha but tests only the parked set', async () => {
|
|
66
|
+
const u = runsUrl('example-org/example-repo', SHA);
|
|
67
|
+
assert.match(u, /\/actions\/workflows\/integration\.yml\/runs\?/);
|
|
68
|
+
assert.match(u, new RegExp(`head_sha=${SHA}`));
|
|
69
|
+
assert.match(u, /event=push/);
|
|
70
|
+
assert.ok(fs.existsSync(new URL('../.github/workflows/integration.yml', import.meta.url)), 'the workflow the gate names exists');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// ---- 2. the wait ----
|
|
74
|
+
|
|
75
|
+
function clock() {
|
|
76
|
+
let t = 0;
|
|
77
|
+
return { now: () => t, sleep: async (ms) => { t += ms; } };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
await test('waitForIntegration: polls while integration runs, then publishes on green', async () => {
|
|
81
|
+
const c = clock();
|
|
82
|
+
const replies = [okJson({ workflow_runs: [] }), okJson({ workflow_runs: [ghRun(3, 'in_progress', null)] }),
|
|
83
|
+
okJson({ workflow_runs: [ghRun(3, 'completed', 'success')] })];
|
|
84
|
+
const asked = [];
|
|
85
|
+
const r = await waitForIntegration({
|
|
86
|
+
sha: SHA, repo: 'example-org/example-repo', token: 't', timeoutMs: 600000, intervalMs: 20000,
|
|
87
|
+
fetchImpl: async (url, init) => { asked.push({ url, auth: init.headers.Authorization }); return replies.shift(); },
|
|
88
|
+
...c,
|
|
89
|
+
});
|
|
90
|
+
assert.equal(r.verdict, 'go');
|
|
91
|
+
assert.equal(asked.length, 3);
|
|
92
|
+
assert.equal(asked[0].auth, 'Bearer t');
|
|
93
|
+
assert.equal(c.now(), 40000, 'slept between reads, not after the verdict');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
await test('waitForIntegration: a 403/404 ends the wait at once (waiting cannot grant a permission)', async () => {
|
|
97
|
+
for (const status of [401, 403, 404]) {
|
|
98
|
+
const c = clock();
|
|
99
|
+
let calls = 0;
|
|
100
|
+
const r = await waitForIntegration({
|
|
101
|
+
sha: SHA, repo: 'o/r', token: 't', timeoutMs: 600000, intervalMs: 20000,
|
|
102
|
+
fetchImpl: async () => { calls++; return { ok: false, status, json: async () => ({}) }; }, ...c,
|
|
103
|
+
});
|
|
104
|
+
assert.equal(r.verdict, 'unreadable', String(status));
|
|
105
|
+
assert.equal(calls, 1);
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
await test('waitForIntegration: flaky reads are retried, and a lane with no verdict times out rather than publishing', async () => {
|
|
110
|
+
const c = clock();
|
|
111
|
+
let calls = 0;
|
|
112
|
+
const r = await waitForIntegration({
|
|
113
|
+
sha: SHA, repo: 'o/r', token: 't', timeoutMs: 60000, intervalMs: 20000,
|
|
114
|
+
fetchImpl: async () => {
|
|
115
|
+
calls++;
|
|
116
|
+
if (calls === 1) throw new Error('ECONNRESET');
|
|
117
|
+
if (calls === 2) return { ok: false, status: 502, json: async () => ({}) };
|
|
118
|
+
return okJson({ workflow_runs: [ghRun(3, 'in_progress', null)] });
|
|
119
|
+
},
|
|
120
|
+
...c,
|
|
121
|
+
});
|
|
122
|
+
assert.equal(r.verdict, 'timeout');
|
|
123
|
+
assert.ok(calls >= 3, 'kept reading through the transient failures');
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
await test('reportLines: only a green lane says go; red, unreadable and timeout exit non-zero', async () => {
|
|
127
|
+
const base = { sha: SHA, run: ghRun(3, 'completed', 'failure'), why: 'HTTP 403', timeoutMin: 12 };
|
|
128
|
+
assert.deepEqual([reportLines('go', base).go, reportLines('go', base).code], [true, 0]);
|
|
129
|
+
assert.deepEqual([reportLines('superseded', base).go, reportLines('superseded', base).code], [false, 0]);
|
|
130
|
+
for (const v of ['red', 'unreadable', 'timeout']) {
|
|
131
|
+
const o = reportLines(v, base);
|
|
132
|
+
assert.equal(o.go, false, v);
|
|
133
|
+
assert.equal(o.code, 1, v);
|
|
134
|
+
assert.match(o.line, /^::error title=not published/, v);
|
|
135
|
+
assert.doesNotMatch(o.line.split('::')[1], /,/, `${v}: a comma in the title would split the annotation's properties`);
|
|
136
|
+
}
|
|
137
|
+
assert.match(reportLines('red', base).line, /task 1002863/, 'says what the red lane was proving');
|
|
138
|
+
assert.match(reportLines('unreadable', base).line, /actions: read/);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
await test('main: writes go=… to GITHUB_OUTPUT and refuses bad arguments with exit 2', async () => {
|
|
142
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'integ-gate-'));
|
|
143
|
+
try {
|
|
144
|
+
const out = path.join(dir, 'out');
|
|
145
|
+
const log = console.log;
|
|
146
|
+
const err = console.error;
|
|
147
|
+
console.log = () => {};
|
|
148
|
+
console.error = () => {};
|
|
149
|
+
try {
|
|
150
|
+
assert.equal(await main(['--sha', 'nope', '--repo', 'o/r'], { GH_TOKEN: 't', GITHUB_OUTPUT: out }), 2);
|
|
151
|
+
assert.equal(await main(['--sha', SHA, '--repo', 'o/r'], { GITHUB_OUTPUT: out }), 2, 'no token');
|
|
152
|
+
const realFetch = globalThis.fetch;
|
|
153
|
+
globalThis.fetch = async () => okJson({ workflow_runs: [ghRun(3, 'completed', 'success')] });
|
|
154
|
+
try {
|
|
155
|
+
assert.equal(await main(['--sha', SHA, '--repo', 'o/r'], { GH_TOKEN: 't', GITHUB_OUTPUT: out }), 0);
|
|
156
|
+
} finally { globalThis.fetch = realFetch; }
|
|
157
|
+
} finally { console.log = log; console.error = err; }
|
|
158
|
+
assert.equal(fs.readFileSync(out, 'utf8'), 'go=true\n');
|
|
159
|
+
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// ---- 3. the wiring ----
|
|
163
|
+
|
|
164
|
+
const YML = fs.readFileSync(new URL('../.github/workflows/publish.yml', import.meta.url), 'utf8');
|
|
165
|
+
// Split the job into steps at each ` - ` list item (the job's step indentation).
|
|
166
|
+
const steps = YML.split(/\n(?= - (?:name|uses):)/).slice(1);
|
|
167
|
+
const stepIndex = (re) => steps.findIndex((s) => re.test(s));
|
|
168
|
+
|
|
169
|
+
await test('publish.yml can read the integration runs, and asks about the commit it packages', async () => {
|
|
170
|
+
assert.match(YML, /^permissions:[\s\S]*?^\s+actions: read$/m, 'the job token needs actions:read to list runs');
|
|
171
|
+
assert.doesNotMatch(YML, /actions: write/, 'read only');
|
|
172
|
+
const g = steps[stepIndex(/id: integ\n/)];
|
|
173
|
+
assert.ok(g, 'the integration gate step exists');
|
|
174
|
+
assert.match(g, /node scripts\/gds\/publish-integration-gate\.js --sha "\$\(git rev-parse HEAD\)"/, 'HEAD — the checkout is ref: main, not the trigger sha');
|
|
175
|
+
assert.match(g, /GH_TOKEN: \$\{\{ github\.token \}\}/);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
await test('publish.yml: the gate runs before anything is installed, bumped, packed, published, recorded or tagged', async () => {
|
|
179
|
+
const at = stepIndex(/id: integ\n/);
|
|
180
|
+
assert.ok(at > stepIndex(/id: relhead\n/), 'after the cascade terminator (a carrier HEAD needs no gate)');
|
|
181
|
+
for (const re of [/actions\/setup-node@/, /run: npm ci\b/, /id: ver\n/, /id: bump\n/, /id: pack\n/, /id: pub\n/, /git push origin HEAD:main/, /git tag "core-v/]) {
|
|
182
|
+
const i = stepIndex(re);
|
|
183
|
+
assert.ok(i > at, `${re} must come after the integration gate (at ${i}, gate at ${at})`);
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
await test('publish.yml: every armed step after the gate also requires its go — no step can slip past it', async () => {
|
|
188
|
+
const at = stepIndex(/id: integ\n/);
|
|
189
|
+
const later = steps.slice(at + 1).filter((s) => /steps\.armed\.outputs\.armed == 'true'/.test(s));
|
|
190
|
+
assert.ok(later.length >= 9, `expected the install..tag chain after the gate, found ${later.length}`);
|
|
191
|
+
for (const s of later) {
|
|
192
|
+
const name = (/- (?:name: (.*)|uses: (.*))/.exec(s) || [])[1] || s.slice(0, 60);
|
|
193
|
+
assert.match(s, /steps\.integ\.outputs\.go == 'true'/, `"${name}" runs without the integration gate`);
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
console.log(`\npublish integration gate (task 1004290) tests: ${passed} passed, ${failed} failed`);
|
|
198
|
+
process.exitCode = failed > 0 ? 1 : 0;
|
package/tests/render_check.mjs
CHANGED
|
@@ -34,7 +34,8 @@ test('the page a builder changed comes before the pages a shared file reaches, s
|
|
|
34
34
|
|
|
35
35
|
test('a path that is not a rendered surface, or a page with no states file, reaches nothing', () => {
|
|
36
36
|
assert.deepEqual(names(['src/bongos/db.js', 'modules/hall-ui/public/goal-map.states.json', 'modules/hall-ui/routes/x.js']), []);
|
|
37
|
-
|
|
37
|
+
// blockers, not deploy: /deploy gained a states file with the npm-release module (task 1002622).
|
|
38
|
+
assert.deepEqual(names(['modules/hall-ui/public/blockers.html']), [], 'a page without a states file does not fall back to the pages that link to it');
|
|
38
39
|
});
|
|
39
40
|
|
|
40
41
|
test('fitFindings names every element from the structured facts, once per element across renders, with its largest measure', () => {
|
|
@@ -102,7 +103,7 @@ test('the CLI renders nothing, and says why, when no page a states file describe
|
|
|
102
103
|
const none = cli('src/bongos/db.js');
|
|
103
104
|
assert.equal(none.status, 0);
|
|
104
105
|
assert.match(none.stdout, /no rendered surface changed/);
|
|
105
|
-
const noStates = cli('modules/hall-ui/public/
|
|
106
|
+
const noStates = cli('modules/hall-ui/public/blockers.html');
|
|
106
107
|
assert.equal(noStates.status, 0);
|
|
107
108
|
assert.match(noStates.stdout, /1 rendered-surface file\(s\) changed, but no <page>\.states\.json reaches them/);
|
|
108
109
|
});
|