@bongos/core 1.21.124 → 1.21.126

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 (44) hide show
  1. package/.bongos-core.json +91 -41
  2. package/bin/bongos.js +9 -0
  3. package/docs/api/openapi.json +4 -4
  4. package/docs/copy-inventory.md +128 -126
  5. package/docs/copy-registry.json +188 -170
  6. package/docs/module-api-changelog.md +4 -0
  7. package/docs/page-inventory.json +2 -1
  8. package/docs/page-readings.json +152 -143
  9. package/modules/hall-ui/public/goal-forest-lib.js +36 -0
  10. package/modules/hall-ui/public/goal-forest-render.js +50 -10
  11. package/modules/hall-ui/public/goals-page.js +19 -22
  12. package/modules/hall-ui/public/goals-render.js +6 -16
  13. package/modules/hall-ui/public/goals.css +13 -4
  14. package/modules/hall-ui/public/goals.html +22 -24
  15. package/modules/hall-ui/public/goals.states.json +19 -1
  16. package/modules/hall-ui/records/goal-dependency-forest.md +4 -3
  17. package/modules/onboarding/howto-guide.js +259 -0
  18. package/modules/platform-identity/invite-authz.js +1 -1
  19. package/modules/provisioning/catalog-bridge.js +1 -1
  20. package/modules/provisioning/provisioning.js +1 -1
  21. package/modules/provisioning/route-helpers.js +50 -0
  22. package/modules/provisioning/routes/disconnect.js +6 -5
  23. package/modules/provisioning/routes/env-manifest.js +11 -27
  24. package/modules/provisioning/routes/provisioning.js +58 -75
  25. package/package-lock.json +2 -2
  26. package/package.json +1 -1
  27. package/release-notes.json +24 -0
  28. package/scripts/gds/core-pin-sync.js +233 -0
  29. package/scripts/gds/module-assess-tests.js +40 -11
  30. package/scripts/gds/module-assess-version.js +10 -2
  31. package/scripts/gds/module-test-runner/README.md +44 -0
  32. package/scripts/gds/module-test-runner/module-tests.yml +87 -0
  33. package/scripts/gds/module-test-runner.js +358 -0
  34. package/src/module-api.js +1 -1
  35. package/tests/core_pin_sync.mjs +237 -0
  36. package/tests/goal_forest_lib.mjs +28 -0
  37. package/tests/goal_forest_page.mjs +106 -11
  38. package/tests/howto_guide.mjs +96 -0
  39. package/tests/module_assess_price_parity.mjs +2 -2
  40. package/tests/module_assess_score.mjs +13 -3
  41. package/tests/module_test_runner.mjs +294 -0
  42. package/tests/project_catalog_routes.mjs +1 -1
  43. package/tests/provisioning_module_selection.mjs +1 -1
  44. package/tests/provisioning_project_owner_gate.mjs +271 -0
@@ -33,13 +33,16 @@
33
33
  // (owner, 2026-09-30): never on the control plane, where the secrets live. The
34
34
  // store path (recordTestsSignal) refuses unless the process declares it is that
35
35
  // environment with MODULE_TEST_SANDBOX=1 — set only on the isolated test box, no
36
- // secrets on disk, which is its own task. `--dir` stays open: it runs a module
36
+ // secrets on disk: GitHub's free runners, driven by module-test-runner.js (task 1004460). `--dir` stays open: it runs a module
37
37
  // from your own checkout, i.e. code you already chose to run.
38
38
  //
39
39
  // node scripts/gds/module-assess-tests.js <store_module_versions.id> run + record
40
40
  // node scripts/gds/module-assess-tests.js --dir modules/<key> dry run on a
41
41
  // local module
42
42
  // (prints; no DB)
43
+ // node scripts/gds/module-assess-tests.js --tarball <f> --key <k> --version-id N --nonce X --json
44
+ // the testing environment's mode: no DB, prints the
45
+ // signal JSON (module-test-runner.js; task 1004460)
43
46
 
44
47
  const fs = require('node:fs');
45
48
  const path = require('node:path');
@@ -177,6 +180,18 @@ async function insertSignal(versionId, s, { db }) {
177
180
  return row;
178
181
  }
179
182
 
183
+ const notScored = (e) => ({ outcome: 'not_scored', score: null, sample_size: null, reason: `the tests could not run (${e.code || 'error'})`, detail: {} });
184
+
185
+ // Verify a tarball and run its tests: the signal fields, with a tarball that fails
186
+ // verification as not_scored. No DB — this is what the separate testing
187
+ // environment runs (`--tarball`, module-test-runner.js); assessTests keeps the gate.
188
+ async function assessTarball(tgz, key, opts = {}) {
189
+ const { verifyModuleArtifact } = require('./module-artifact');
190
+ const v = await verifyModuleArtifact(tgz, { key, includeFiles: true });
191
+ if (!v.ok) return { outcome: 'not_scored', score: null, sample_size: null, reason: `the tarball failed verification (${v.code}): ${v.message}`, detail: {} };
192
+ return assessTests(key, v.files, opts);
193
+ }
194
+
180
195
  // Read, verify and test one published version, then record the result. A tarball
181
196
  // that is missing or fails verification is recorded as not_scored — the part could
182
197
  // not be measured, which is not the same as the module failing its tests.
@@ -185,21 +200,16 @@ async function recordTestsSignal(versionId, { db, storeDir, root, timeoutMs, exe
185
200
  if (refused) return { ok: false, ...refused };
186
201
  const pool = db || require('../../src/bongos/pool').pool;
187
202
  const { versionArtifactFile } = require('../../src/bongos/module-store');
188
- const { verifyModuleArtifact } = require('./module-artifact');
189
203
  const { rows: [ver] } = await pool.query(
190
204
  'SELECT id, module_key, version, artifact_path FROM store_module_versions WHERE id = $1', [versionId]);
191
205
  if (!ver) return { ok: false, code: 'version_not_found', message: `no store_module_versions row ${versionId}` };
192
206
 
193
207
  let signal;
194
208
  try {
195
- const file = versionArtifactFile(ver, storeDir ? { dir: storeDir } : {});
196
- const tgz = await fs.promises.readFile(file);
197
- const v = await verifyModuleArtifact(tgz, { key: ver.module_key, includeFiles: true });
198
- signal = v.ok
199
- ? await assessTests(ver.module_key, v.files, { root, timeoutMs, exec, env })
200
- : { outcome: 'not_scored', score: null, sample_size: null, reason: `the tarball failed verification (${v.code}): ${v.message}`, detail: {} };
209
+ const tgz = await fs.promises.readFile(versionArtifactFile(ver, storeDir ? { dir: storeDir } : {}));
210
+ signal = await assessTarball(tgz, ver.module_key, { root, timeoutMs, exec, env });
201
211
  } catch (e) {
202
- signal = { outcome: 'not_scored', score: null, sample_size: null, reason: `the tests could not run (${e.code || 'error'})`, detail: {} };
212
+ signal = notScored(e);
203
213
  }
204
214
  const row = await insertSignal(ver.id, signal, { db: pool });
205
215
  return { ok: true, version: ver, signal: row };
@@ -220,7 +230,26 @@ function readModuleDir(dir) {
220
230
  return out;
221
231
  }
222
232
 
233
+ // The value after a flag, or null.
234
+ const flag = (argv, name) => { const i = argv.indexOf(name); return i === -1 ? null : (argv[i + 1] || ''); };
235
+
223
236
  async function main(argv) {
237
+ // DB-free, for the separate testing environment: verify + run + print the signal
238
+ // JSON (with the run's version id and nonce, which the control plane checks when
239
+ // it pulls the result). The gate still applies: without MODULE_TEST_SANDBOX=1 and
240
+ // a secret-free environment this refuses before anything is read.
241
+ const tarball = flag(argv, '--tarball');
242
+ if (tarball !== null) {
243
+ const key = flag(argv, '--key');
244
+ if (!tarball || !key) { console.error('usage: --tarball <file> --key <module key> [--version-id N --nonce X] --json'); return 2; }
245
+ const refused = runRefusal();
246
+ if (refused) { console.error(refused.message); return 1; }
247
+ let s;
248
+ try { s = await assessTarball(fs.readFileSync(tarball), key); }
249
+ catch (e) { s = notScored(e); }
250
+ console.log(JSON.stringify({ version_id: flag(argv, '--version-id'), nonce: flag(argv, '--nonce'), ...s }));
251
+ return 0;
252
+ }
224
253
  const i = argv.indexOf('--dir');
225
254
  if (i !== -1) {
226
255
  const dir = path.resolve(argv[i + 1] || '');
@@ -231,7 +260,7 @@ async function main(argv) {
231
260
  }
232
261
  const id = argv[0];
233
262
  if (!/^\d+$/.test(String(id || ''))) {
234
- console.error('usage: node scripts/gds/module-assess-tests.js <store_module_versions.id> | --dir modules/<key>');
263
+ console.error('usage: node scripts/gds/module-assess-tests.js <store_module_versions.id> | --dir modules/<key> | --tarball <file> --key <k> --json');
235
264
  return 2;
236
265
  }
237
266
  const res = await recordTestsSignal(id);
@@ -246,4 +275,4 @@ if (require.main === module) {
246
275
  main(process.argv.slice(2)).then((code) => process.exit(code), (e) => { console.error(e.stack || e.message); process.exit(1); });
247
276
  }
248
277
 
249
- module.exports = { discoverTestFiles, assessTests, recordTestsSignal, insertSignal, runRefusal, testEnv, SANDBOX_PREFIX };
278
+ module.exports = { discoverTestFiles, assessTests, assessTarball, recordTestsSignal, insertSignal, runRefusal, testEnv, SANDBOX_PREFIX };
@@ -7,6 +7,8 @@
7
7
  // this after it answers the author. Tests is recorded `pending`, and only when the
8
8
  // version has no Tests result yet: a published module's tests run only in the
9
9
  // separate test environment, never on the control plane (module-assess-tests.js).
10
+ // When the runner is configured (module-test-runner.js) the tests are dispatched to
11
+ // it here, and its result is pulled back into the row later.
10
12
  // The Security gate reads the tarball and asks npm about the declared dependencies
11
13
  // — it never runs the module's code, so it is safe here. Docs is one Sonnet call over
12
14
  // the HOWTO.md text (module-assess-docs.js) — also no module code. Then the score is
@@ -27,12 +29,18 @@ const { recomposeScore } = require('./module-assess-score');
27
29
  // the score. `recordSecurity` / `recordDocs` are injectable so tests need no npm
28
30
  // and no model. Never throws for a failed measurement — that is recorded as the signal's outcome — and resolves
29
31
  // { ok: false, code } only when the version does not exist.
30
- async function assessVersion(versionId, { db, recordSecurity, recordDocs } = {}) {
32
+ async function assessVersion(versionId, { db, recordSecurity, recordDocs, dispatchTests } = {}) {
31
33
  const pool = db || require('../../src/bongos/pool').pool;
32
34
  const { rows: [ver] } = await pool.query(
33
- 'SELECT id, module_key, version FROM store_module_versions WHERE id = $1', [versionId]);
35
+ 'SELECT id, module_key, version, artifact_path FROM store_module_versions WHERE id = $1', [versionId]);
34
36
  if (!ver) return { ok: false, code: 'version_not_found', message: `no store_module_versions row ${versionId}` };
35
37
 
38
+ // When the separate test environment is configured, send the run there first
39
+ // (module-test-runner.js; task 1004460): it records a pending row carrying the
40
+ // run's nonce, so the plain pending below then has nothing to add. Unconfigured
41
+ // (or a failed dispatch), nothing is sent and Tests stays plain pending.
42
+ await (dispatchTests || require('./module-test-runner').maybeDispatch)(ver, { db: pool });
43
+
36
44
  // Only a version with no Tests result yet: a re-assess must never hide a real
37
45
  // score behind a newer "pending".
38
46
  await pool.query(
@@ -0,0 +1,44 @@
1
+ # Module test runner — setup (task 1004460)
2
+
3
+ A published store module's own tests run on GitHub's free runners, in a separate
4
+ **private** repo, never on the control plane (owner decision, 2026-10-14). The
5
+ control plane sends each run and pulls its result back; the runner never gets a
6
+ database credential. How and why: the header of
7
+ [`scripts/gds/module-test-runner.js`](../module-test-runner.js).
8
+
9
+ Until the three steps below are done, nothing changes: a new version's Tests part
10
+ stays `pending`.
11
+
12
+ ## Owner steps (one time)
13
+
14
+ 1. **Create the private repo** on GitHub (any name, e.g. `<you>/bongos-module-tests`),
15
+ with a `main` branch. Copy [`module-tests.yml`](module-tests.yml) into it as
16
+ `.github/workflows/module-tests.yml`. Keep it private: run logs show test output.
17
+ 2. **Create a fine-grained token** (GitHub → Settings → Developer settings →
18
+ Fine-grained tokens) with **Repository access: only that repo**, and permissions
19
+ **Actions: Read and write** and **Contents: Read and write**. Nothing else.
20
+ 3. **Set it on the control plane** in the web env file, then restart the web service:
21
+ ```
22
+ MODULE_TEST_REPO=<owner>/<repo>
23
+ MODULE_TEST_TOKEN=<the token>
24
+ ```
25
+ Optional: `MODULE_TEST_CORE_REPO=<owner>/<name>` if the server's git `origin` is not
26
+ the public core repo, and `MODULE_TEST_REF=<branch>` if the test repo's workflow
27
+ lives on a branch other than `main`.
28
+
29
+ On the runner, a module's tests run as a separate user with no admin rights and
30
+ no network access (only the machine's own loopback), after the core is installed.
31
+
32
+ Cost: free within GitHub's included Actions minutes for a private repo
33
+ (about 2,000 a month on the free plan); each run is a few minutes.
34
+
35
+ ## Running it by hand
36
+
37
+ ```
38
+ node scripts/gds/module-test-runner.js dispatch <store_module_versions.id>
39
+ node scripts/gds/module-test-runner.js collect
40
+ ```
41
+
42
+ `dispatch` only sends a run when the version has no Tests result yet (or only a
43
+ pending one with no run). The web process collects on its own every five minutes
44
+ while runs are pending; after a restart, `collect` picks up anything left.
@@ -0,0 +1,87 @@
1
+ # The separate testing environment for a published module's own tests (task 1004460).
2
+ #
3
+ # THIS FILE DOES NOT RUN IN THE CORE REPO. Copy it to .github/workflows/module-tests.yml
4
+ # in the owner's separate PRIVATE test repo (see README.md beside it). The control
5
+ # plane (scripts/gds/module-test-runner.js) dispatches it and pulls the result back;
6
+ # nothing here ever holds a DB credential or a control-plane secret.
7
+ #
8
+ # Two jobs on purpose: `fetch` can read the repo (where the tarball waits on a
9
+ # throwaway branch) but runs no module code; `test` runs the module's code with a
10
+ # GITHUB_TOKEN that has NO permissions, so a module that scrapes its token still
11
+ # cannot read another run's tarball. Inside `test`, the module's code runs as an
12
+ # unprivileged user with its network cut (see "Cut the network for module code").
13
+ name: module-tests
14
+ run-name: module-tests ${{ inputs.version_id }} ${{ inputs.nonce }}
15
+
16
+ on:
17
+ workflow_dispatch:
18
+ inputs:
19
+ version_id: { description: store_module_versions.id, required: true, type: string }
20
+ module_key: { description: the module's key, required: true, type: string }
21
+ nonce: { description: this run's nonce (32 hex), required: true, type: string }
22
+ core_repo: { description: owner/name of the public core, required: true, type: string }
23
+ core_ref: { description: the core commit to test against, required: true, type: string }
24
+
25
+ permissions: {}
26
+
27
+ jobs:
28
+ fetch:
29
+ runs-on: ubuntu-latest
30
+ permissions: { contents: read }
31
+ timeout-minutes: 5
32
+ steps:
33
+ - name: Check the inputs
34
+ env: { NONCE: "${{ inputs.nonce }}", VERSION_ID: "${{ inputs.version_id }}" }
35
+ run: |
36
+ [[ "$NONCE" =~ ^[0-9a-f]{32}$ ]] || { echo "bad nonce"; exit 1; }
37
+ [[ "$VERSION_ID" =~ ^[0-9]+$ ]] || { echo "bad version id"; exit 1; }
38
+ - uses: actions/checkout@v4
39
+ with: { ref: "module-test/${{ inputs.nonce }}", path: tarball, persist-credentials: false }
40
+ - uses: actions/upload-artifact@v4
41
+ with: { name: "tarball-${{ inputs.nonce }}", path: tarball/module.tgz, retention-days: 1 }
42
+
43
+ test:
44
+ needs: fetch
45
+ runs-on: ubuntu-latest
46
+ permissions: {}
47
+ timeout-minutes: 30
48
+ steps:
49
+ - uses: actions/checkout@v4
50
+ with: { repository: "${{ inputs.core_repo }}", ref: "${{ inputs.core_ref }}", path: core, persist-credentials: false }
51
+ - uses: actions/setup-node@v4
52
+ with: { node-version: 22 }
53
+ - uses: actions/download-artifact@v4
54
+ with: { name: "tarball-${{ inputs.nonce }}", path: in }
55
+ - name: Install the core
56
+ working-directory: core
57
+ run: npm ci --no-audit --no-fund
58
+ # Module code never runs as `runner` (which has passwordless sudo and the
59
+ # runner's own files): it runs as `moduletest`, a fresh user with no sudo, and
60
+ # the firewall rejects every packet that user sends except on loopback. So a
61
+ # module's tests get no network egress, and cannot lift the block themselves.
62
+ # The install above was the last step that needed the network.
63
+ - name: Cut the network for module code
64
+ run: |
65
+ sudo useradd --create-home --shell /bin/bash moduletest
66
+ sudo mv core in /home/moduletest/
67
+ sudo chown -R moduletest:moduletest /home/moduletest
68
+ for t in iptables ip6tables; do
69
+ sudo "$t" -A OUTPUT -m owner --uid-owner moduletest -o lo -j ACCEPT
70
+ sudo "$t" -A OUTPUT -m owner --uid-owner moduletest -j REJECT
71
+ done
72
+ if sudo -u moduletest -H curl -sS --max-time 5 https://github.com >/dev/null 2>&1; then
73
+ echo "the network block did not hold"; exit 1
74
+ fi
75
+ - name: Run the module's own tests
76
+ env:
77
+ VERSION_ID: "${{ inputs.version_id }}"
78
+ MODULE_KEY: "${{ inputs.module_key }}"
79
+ NONCE: "${{ inputs.nonce }}"
80
+ run: |
81
+ mkdir -p out
82
+ NODE_DIR="$(dirname "$(command -v node)")"
83
+ sudo -u moduletest -H env -i PATH="$NODE_DIR:/usr/bin:/bin" HOME=/home/moduletest MODULE_TEST_SANDBOX=1 \
84
+ bash -c 'cd /home/moduletest/core && exec node scripts/gds/module-assess-tests.js --tarball ../in/module.tgz --key "$1" --version-id "$2" --nonce "$3" --json' \
85
+ _ "$MODULE_KEY" "$VERSION_ID" "$NONCE" > out/signal.json
86
+ - uses: actions/upload-artifact@v4
87
+ with: { name: "module-tests-${{ inputs.version_id }}-${{ inputs.nonce }}", path: out/signal.json, retention-days: 3 }
@@ -0,0 +1,358 @@
1
+ #!/usr/bin/env node
2
+ // scripts/gds/module-test-runner.js — the separate testing environment for a
3
+ // published module's OWN tests (task 1004460; ADR 0343). The owner chose GitHub's
4
+ // free runners (2026-10-14): no new server, nothing bought.
5
+ //
6
+ // WHY A SEPARATE PRIVATE REPO. A module's tests are third-party code, so they never
7
+ // run on the control plane (module-assess-tests.js refuses outside
8
+ // MODULE_TEST_SANDBOX=1), and never in this repo's own Actions either (.github/ is
9
+ // protected substrate, and its runs can reach this repo's secrets). They run in a
10
+ // separate PRIVATE repo the owner creates, holding only the workflow in
11
+ // scripts/gds/module-test-runner/ — private so run logs and inputs stay non-public.
12
+ //
13
+ // THE ROUND TRIP. Nothing on the runner ever holds a DB credential:
14
+ // 1. dispatch — the control plane pushes the version's tarball to a throwaway
15
+ // orphan branch `module-test/<nonce>` in the test repo and dispatches the
16
+ // workflow with { version_id, module_key, nonce, core_repo, core_ref }. The
17
+ // nonce goes into the pending Tests row's detail.runner.
18
+ // 2. the run — a `fetch` job (contents: read, no module code) copies the tarball
19
+ // into the run as an artifact; a separate `test` job (permissions: {}) checks
20
+ // out the core at core_ref, runs module-assess-tests.js --tarball with
21
+ // MODULE_TEST_SANDBOX=1, and uploads the signal JSON as artifact
22
+ // module-tests-<version_id>-<nonce>.
23
+ // 3. collect — the control plane PULLS: it finds the run whose run-name carries
24
+ // the pending row's nonce, takes only that run's artifact of that exact name,
25
+ // checks the payload names the same version and nonce, inserts the signal row
26
+ // itself, recomposes the score, and deletes the branch.
27
+ //
28
+ // WHY A PER-RUN BRANCH, NOT A DOWNLOAD TICKET. A ticket would mean a new
29
+ // unauthenticated route on the control plane (and a change to the protected
30
+ // route-rank-check), i.e. a fresh inbound path to the server holding the secrets.
31
+ // The branch keeps every byte flowing OUT from the control plane. The cost: a paid
32
+ // module's tarball sits in the private test repo for the length of one run. It is
33
+ // safe from OTHER modules' runs because the only job that can read the repo's
34
+ // contents (`fetch`) runs no module code, and the job that does (`test`) gets a
35
+ // GITHUB_TOKEN with no permissions — so a module that scrapes its runner's token
36
+ // still cannot read another run's branch. A module author can always make their own
37
+ // tests pass, so the score is never trust-critical; what this protects is the
38
+ // secrets and other authors' code.
39
+ //
40
+ // Configured by three control-plane env vars (all unset = off; Tests then stays
41
+ // `pending`, exactly as before this task):
42
+ // MODULE_TEST_REPO owner/name of the private test repo
43
+ // MODULE_TEST_TOKEN a fine-grained token scoped to ONLY that repo, with
44
+ // Actions: read & write and Contents: read & write
45
+ // MODULE_TEST_CORE_REPO owner/name of the public core the runner checks out
46
+ // (default: this checkout's `origin`)
47
+ // MODULE_TEST_REF the test repo branch holding the workflow (default main)
48
+ //
49
+ // node scripts/gds/module-test-runner.js dispatch <store_module_versions.id>
50
+ // node scripts/gds/module-test-runner.js collect
51
+
52
+ const fs = require('node:fs');
53
+ const crypto = require('node:crypto');
54
+ const zlib = require('node:zlib');
55
+ const { execFileSync } = require('node:child_process');
56
+
57
+ const WORKFLOW_FILE = 'module-tests.yml';
58
+ const BRANCH_PREFIX = 'module-test/';
59
+ const API = 'https://api.github.com';
60
+ // A run that has not finished this long after dispatch is recorded not_scored.
61
+ const MAX_RUN_AGE_MS = 6 * 60 * 60_000;
62
+ const MAX_SIGNAL_BYTES = 256 * 1024;
63
+ const REPO_RE = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
64
+ const NONCE_RE = /^[0-9a-f]{32}$/;
65
+
66
+ const runName = (versionId, nonce) => `module-tests ${versionId} ${nonce}`;
67
+ const artifactName = (versionId, nonce) => `module-tests-${versionId}-${nonce}`;
68
+
69
+ function originRepo() {
70
+ try {
71
+ const url = execFileSync('git', ['remote', 'get-url', 'origin'], { cwd: __dirname, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
72
+ const m = url.match(/github\.com[/:]([^/]+\/[^/]+?)(?:\.git)?$/);
73
+ return m ? m[1] : null;
74
+ } catch { return null; }
75
+ }
76
+
77
+ // The runner config, or null when it is not set up (the default: Tests stays pending).
78
+ function runnerConfig(env = process.env, { origin = originRepo } = {}) {
79
+ const repo = (env.MODULE_TEST_REPO || '').trim();
80
+ const token = (env.MODULE_TEST_TOKEN || '').trim();
81
+ if (!repo || !token) return null;
82
+ if (!REPO_RE.test(repo)) throw new Error(`MODULE_TEST_REPO must be owner/name, got "${repo}"`);
83
+ const coreRepo = (env.MODULE_TEST_CORE_REPO || '').trim() || origin();
84
+ if (!coreRepo || !REPO_RE.test(coreRepo)) throw new Error('MODULE_TEST_CORE_REPO is unset and the core repo could not be read from git origin');
85
+ const ref = (env.MODULE_TEST_REF || '').trim() || 'main';
86
+ return { repo, token, coreRepo, ref };
87
+ }
88
+
89
+ // One GitHub REST call. Resolves the parsed body (or null for 204); throws with
90
+ // .status on a non-2xx.
91
+ async function gh(cfg, method, urlPath, body, { fetchImpl = fetch } = {}) {
92
+ const res = await fetchImpl(urlPath.startsWith('https://') ? urlPath : `${API}${urlPath}`, {
93
+ method,
94
+ headers: { Authorization: `Bearer ${cfg.token}`, Accept: 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28', 'User-Agent': 'bongos-module-test-runner', ...(body ? { 'Content-Type': 'application/json' } : {}) },
95
+ body: body ? JSON.stringify(body) : undefined,
96
+ });
97
+ if (!res.ok) {
98
+ const text = await res.text().catch(() => '');
99
+ throw Object.assign(new Error(`GitHub ${method} ${urlPath} → ${res.status} ${text.slice(0, 200)}`), { status: res.status });
100
+ }
101
+ return res.status === 204 ? null : res.json();
102
+ }
103
+
104
+ // The commit the control plane is running, so the runner tests against the same core.
105
+ function currentCoreRef() {
106
+ const { buildInfo } = require('../../src/build-info');
107
+ return buildInfo().commit || 'main';
108
+ }
109
+
110
+ function readVersionTarball(ver) {
111
+ const { versionArtifactFile } = require('../../src/bongos/module-store');
112
+ return fs.promises.readFile(versionArtifactFile(ver));
113
+ }
114
+
115
+ // Push the tarball to a throwaway orphan branch and dispatch the workflow.
116
+ // Resolves the runner record kept in the pending row: { nonce, branch, dispatched_at }.
117
+ async function dispatchRun(ver, { cfg, fetchImpl, readTarball = readVersionTarball, coreRef = currentCoreRef, now = () => new Date() } = {}) {
118
+ const tgz = await readTarball(ver);
119
+ const nonce = crypto.randomBytes(16).toString('hex');
120
+ const branch = `${BRANCH_PREFIX}${nonce}`;
121
+ const r = `/repos/${cfg.repo}`;
122
+ const blob = await gh(cfg, 'POST', `${r}/git/blobs`, { content: tgz.toString('base64'), encoding: 'base64' }, { fetchImpl });
123
+ const tree = await gh(cfg, 'POST', `${r}/git/trees`, { tree: [{ path: 'module.tgz', mode: '100644', type: 'blob', sha: blob.sha }] }, { fetchImpl });
124
+ const commit = await gh(cfg, 'POST', `${r}/git/commits`, { message: `module tests: version ${ver.id}`, tree: tree.sha, parents: [] }, { fetchImpl });
125
+ await gh(cfg, 'POST', `${r}/git/refs`, { ref: `refs/heads/${branch}`, sha: commit.sha }, { fetchImpl });
126
+ try {
127
+ await gh(cfg, 'POST', `${r}/actions/workflows/${WORKFLOW_FILE}/dispatches`, {
128
+ ref: cfg.ref || 'main',
129
+ inputs: { version_id: String(ver.id), module_key: ver.module_key, nonce, core_repo: cfg.coreRepo, core_ref: coreRef() },
130
+ }, { fetchImpl });
131
+ } catch (e) {
132
+ await deleteBranch(cfg, branch, { fetchImpl });
133
+ throw e;
134
+ }
135
+ return { nonce, branch, dispatched_at: now().toISOString() };
136
+ }
137
+
138
+ async function deleteBranch(cfg, branch, { fetchImpl } = {}) {
139
+ try { await gh(cfg, 'DELETE', `/repos/${cfg.repo}/git/refs/heads/${branch}`, null, { fetchImpl }); }
140
+ catch (e) { if (e.status !== 404 && e.status !== 422) throw e; }
141
+ }
142
+
143
+ // The newest Tests row for a version, or null.
144
+ async function newestTestsRow(versionId, db) {
145
+ const { rows: [row] } = await db.query(
146
+ `SELECT id, outcome, detail FROM module_assessment_signals
147
+ WHERE version_id = $1 AND part = 'tests' ORDER BY measured_at DESC, id DESC LIMIT 1`, [versionId]);
148
+ return row || null;
149
+ }
150
+
151
+ // Called where a version's Tests is recorded `pending` (module-assess-version.js).
152
+ // Dispatches a run only when the runner is configured and the version has no Tests
153
+ // result yet, or only a pending one no run was sent for — so a re-assess never hides
154
+ // a real score behind a newer pending. Resolves null when nothing was dispatched.
155
+ // Never throws: a failed dispatch is logged and Tests stays plain pending.
156
+ async function maybeDispatch(ver, { db, env = process.env, log = console, ...opts } = {}) {
157
+ let cfg;
158
+ try { cfg = opts.cfg || runnerConfig(env); }
159
+ catch (e) { log.warn(`module test runner misconfigured: ${e.message}`); return null; }
160
+ if (!cfg) return null;
161
+ const newest = await newestTestsRow(ver.id, db);
162
+ if (newest && !(newest.outcome === 'pending' && !(newest.detail && newest.detail.runner))) return null;
163
+ let runner;
164
+ try { runner = await dispatchRun(ver, { cfg, ...opts }); }
165
+ catch (e) { log.warn(`module test run for version ${ver.id} not dispatched: ${e.message}`); return null; }
166
+ await db.query(
167
+ `INSERT INTO module_assessment_signals (version_id, part, outcome, score, sample_size, reason, detail)
168
+ VALUES ($1, 'tests', 'pending', NULL, NULL, $2, $3)`,
169
+ [ver.id, 'running in the separate test environment', JSON.stringify({ runner })]);
170
+ ensureSweeper({ db, cfg, log });
171
+ return runner;
172
+ }
173
+
174
+ // ---------------------------------------------------------------------------
175
+ // The zip a GitHub artifact downloads as. A small reader (stored + deflate) so no
176
+ // zip dependency joins package.json. Returns the named entry's bytes, or null.
177
+ // ---------------------------------------------------------------------------
178
+ function readZipEntry(zip, name, { maxBytes = MAX_SIGNAL_BYTES } = {}) {
179
+ let eocd = -1;
180
+ for (let i = zip.length - 22; i >= Math.max(0, zip.length - 22 - 0xffff); i -= 1) {
181
+ if (zip.readUInt32LE(i) === 0x06054b50) { eocd = i; break; }
182
+ }
183
+ if (eocd < 0) throw new Error('not a zip: no end-of-central-directory record');
184
+ const count = zip.readUInt16LE(eocd + 10);
185
+ let p = zip.readUInt32LE(eocd + 16);
186
+ for (let n = 0; n < count; n += 1) {
187
+ if (p + 46 > zip.length || zip.readUInt32LE(p) !== 0x02014b50) throw new Error('bad zip central directory');
188
+ const method = zip.readUInt16LE(p + 10);
189
+ const compSize = zip.readUInt32LE(p + 20);
190
+ const size = zip.readUInt32LE(p + 24);
191
+ const nameLen = zip.readUInt16LE(p + 28);
192
+ const extraLen = zip.readUInt16LE(p + 30);
193
+ const commentLen = zip.readUInt16LE(p + 32);
194
+ const local = zip.readUInt32LE(p + 42);
195
+ const entryName = zip.toString('utf8', p + 46, p + 46 + nameLen);
196
+ p += 46 + nameLen + extraLen + commentLen;
197
+ if (entryName !== name) continue;
198
+ if (size > maxBytes || compSize > maxBytes) throw new Error(`zip entry ${name} is over ${maxBytes} bytes`);
199
+ if (zip.readUInt32LE(local) !== 0x04034b50) throw new Error('bad zip local header');
200
+ const start = local + 30 + zip.readUInt16LE(local + 26) + zip.readUInt16LE(local + 28);
201
+ const data = zip.subarray(start, start + compSize);
202
+ if (method === 0) return Buffer.from(data);
203
+ if (method === 8) return zlib.inflateRawSync(data, { maxOutputLength: maxBytes });
204
+ throw new Error(`zip entry ${name} uses unsupported method ${method}`);
205
+ }
206
+ return null;
207
+ }
208
+
209
+ // The runner's payload is written by a job that ran third-party code, so it is
210
+ // held to the exact shape insertSignal stores and clipped; it is never trusted to
211
+ // name its own version or nonce (those must match the run it was taken from).
212
+ function validateSignal(raw, { versionId, nonce }) {
213
+ if (!raw || typeof raw !== 'object') throw new Error('the result is not a JSON object');
214
+ if (String(raw.version_id) !== String(versionId) || raw.nonce !== nonce) throw new Error('the result names a different version or run');
215
+ const { outcome } = raw;
216
+ if (!['scored', 'no_data', 'not_scored'].includes(outcome)) throw new Error(`unknown outcome "${outcome}"`);
217
+ let score = null;
218
+ let sample = outcome === 'no_data' ? 0 : null;
219
+ if (outcome === 'scored') {
220
+ if (!Number.isInteger(raw.score) || raw.score < 0 || raw.score > 100) throw new Error('a scored result needs an integer score 0–100');
221
+ if (!Number.isInteger(raw.sample_size) || raw.sample_size < 1 || raw.sample_size > 10_000) throw new Error('a scored result needs a positive sample_size');
222
+ score = raw.score; sample = raw.sample_size;
223
+ }
224
+ const files = Array.isArray(raw.detail && raw.detail.files) ? raw.detail.files.slice(0, 200).map((f) => ({
225
+ file: String((f && f.file) || '').slice(0, 200),
226
+ passed: !!(f && f.passed),
227
+ ...(f && f.timed_out ? { timed_out: true } : {}),
228
+ ...(f && f.tail ? { tail: String(f.tail).slice(0, 600) } : {}),
229
+ })) : [];
230
+ return { outcome, score, sample_size: sample, reason: String(raw.reason || '').slice(0, 500), detail: { files } };
231
+ }
232
+
233
+ // Download one artifact's zip. GitHub answers with a redirect to blob storage; the
234
+ // token is not sent past it.
235
+ async function downloadArtifact(cfg, artifact, { fetchImpl = fetch } = {}) {
236
+ const res = await fetchImpl(artifact.archive_download_url, { headers: { Authorization: `Bearer ${cfg.token}`, 'User-Agent': 'bongos-module-test-runner' }, redirect: 'manual' });
237
+ let final = res;
238
+ if (res.status >= 300 && res.status < 400) final = await fetchImpl(res.headers.get('location'));
239
+ if (!final.ok) throw new Error(`artifact download → ${final.status}`);
240
+ return Buffer.from(await final.arrayBuffer());
241
+ }
242
+
243
+ // Every version whose newest Tests row is a pending run we dispatched. Driven
244
+ // from the versions (one row each) with one lookup per version on core_265's
245
+ // idx_module_assessment_signals_current, so the cost follows the number of
246
+ // versions, never the length of the append-only signal history.
247
+ async function pendingRuns(db) {
248
+ const { rows } = await db.query(
249
+ `SELECT s.id, s.version_id, s.outcome, s.detail
250
+ FROM store_module_versions v
251
+ CROSS JOIN LATERAL (
252
+ SELECT id, version_id, outcome, detail FROM module_assessment_signals
253
+ WHERE version_id = v.id AND part = 'tests'
254
+ ORDER BY measured_at DESC, id DESC LIMIT 1) s
255
+ WHERE s.outcome = 'pending' AND s.detail ? 'runner'`);
256
+ return rows.filter((r) => r.outcome === 'pending' && r.detail && r.detail.runner && NONCE_RE.test(r.detail.runner.nonce || ''));
257
+ }
258
+
259
+ // Pull finished runs' results into module_assessment_signals. Resolves one entry
260
+ // per pending run: { version_id, status, outcome? } — status 'recorded' (the run
261
+ // finished; its result, or why there is none), 'waiting' (still running), or
262
+ // 'expired' (never started or never finished within MAX_RUN_AGE_MS: not_scored).
263
+ async function collectResults({ db, cfg, fetchImpl, now = () => Date.now(), recompose, insert } = {}) {
264
+ const pool = db || require('../../src/bongos/pool').pool;
265
+ const pending = await pendingRuns(pool);
266
+ if (pending.length === 0) return [];
267
+ const { runs = [] } = await gh(cfg, 'GET', `/repos/${cfg.repo}/actions/workflows/${WORKFLOW_FILE}/runs?event=workflow_dispatch&per_page=100`, null, { fetchImpl });
268
+ const doInsert = insert || require('./module-assess-tests').insertSignal;
269
+ const doRecompose = recompose || ((id) => require('./module-assess-score').recomposeScore(id, { db: pool }));
270
+ const out = [];
271
+ for (const p of pending) {
272
+ const { nonce, branch, dispatched_at: at } = p.detail.runner;
273
+ const run = runs.find((r) => r.display_title === runName(p.version_id, nonce));
274
+ const expired = now() - Date.parse(at) > MAX_RUN_AGE_MS;
275
+ let signal = null;
276
+ let status = 'recorded';
277
+ if (!run || run.status !== 'completed') {
278
+ if (!expired) { out.push({ version_id: p.version_id, status: 'waiting' }); continue; }
279
+ signal = { outcome: 'not_scored', score: null, sample_size: null, reason: run ? 'the test run did not finish in time' : 'the test run never started', detail: {} };
280
+ status = 'expired';
281
+ } else {
282
+ try {
283
+ const { artifacts = [] } = await gh(cfg, 'GET', `/repos/${cfg.repo}/actions/runs/${run.id}/artifacts?per_page=100`, null, { fetchImpl });
284
+ const art = artifacts.find((a) => a.name === artifactName(p.version_id, nonce) && !a.expired);
285
+ if (!art) throw new Error(`the test run ended (${run.conclusion}) without a result`);
286
+ const json = readZipEntry(await downloadArtifact(cfg, art, { fetchImpl }), 'signal.json');
287
+ if (!json) throw new Error('the result artifact carries no signal.json');
288
+ signal = validateSignal(JSON.parse(json.toString('utf8')), { versionId: p.version_id, nonce });
289
+ } catch (e) {
290
+ signal = { outcome: 'not_scored', score: null, sample_size: null, reason: String(e.message).slice(0, 500), detail: {} };
291
+ }
292
+ signal.detail = { ...signal.detail, runner: { nonce, run_id: run.id } };
293
+ }
294
+ await doInsert(p.version_id, signal, { db: pool });
295
+ await doRecompose(p.version_id);
296
+ if (branch && branch.startsWith(BRANCH_PREFIX)) await deleteBranch(cfg, branch, { fetchImpl }).catch(() => {});
297
+ out.push({ version_id: p.version_id, status, outcome: signal.outcome });
298
+ }
299
+ return out;
300
+ }
301
+
302
+ // In the web process: once a run is dispatched, sweep for results every few
303
+ // minutes until none are pending, then stop. A restart drops the timer; the next
304
+ // dispatch restarts it, and `collect` below picks up anything left over.
305
+ const SWEEP_MS = 5 * 60_000;
306
+ let sweeper = null;
307
+
308
+ // One sweep. Resolves true to keep sweeping (a run is still out, or the sweep
309
+ // failed and should be tried again), false once nothing is left waiting.
310
+ async function sweepTick({ db, cfg, log = console, collect = collectResults }) {
311
+ try {
312
+ const res = await collect({ db, cfg });
313
+ return res.some((r) => r.status === 'waiting');
314
+ } catch (e) {
315
+ log.warn(`module test result sweep failed: ${e.message}`);
316
+ return true;
317
+ }
318
+ }
319
+
320
+ function ensureSweeper({ db, cfg, log = console }) {
321
+ if (sweeper) return;
322
+ sweeper = setInterval(async () => {
323
+ if (!(await sweepTick({ db, cfg, log })) && sweeper) { clearInterval(sweeper); sweeper = null; }
324
+ }, SWEEP_MS);
325
+ if (sweeper.unref) sweeper.unref();
326
+ }
327
+
328
+ async function main(argv) {
329
+ const cfg = runnerConfig();
330
+ if (!cfg) { console.error('the module test runner is not configured (set MODULE_TEST_REPO and MODULE_TEST_TOKEN)'); return 1; }
331
+ const { pool } = require('../../src/bongos/pool');
332
+ try {
333
+ if (argv[0] === 'dispatch' && /^\d+$/.test(argv[1] || '')) {
334
+ const { rows: [ver] } = await pool.query('SELECT id, module_key, version, artifact_path FROM store_module_versions WHERE id = $1', [argv[1]]);
335
+ if (!ver) { console.error(`no store_module_versions row ${argv[1]}`); return 1; }
336
+ const runner = await maybeDispatch(ver, { db: pool, cfg });
337
+ console.log(runner ? `dispatched version ${ver.id}: run ${runName(ver.id, runner.nonce)}` : `version ${ver.id} already has a Tests result or a run in flight — nothing dispatched`);
338
+ return runner ? 0 : 1;
339
+ }
340
+ if (argv[0] === 'collect') {
341
+ const res = await collectResults({ db: pool, cfg });
342
+ for (const r of res) console.log(`version ${r.version_id}: ${r.status}${r.outcome ? ` (${r.outcome})` : ''}`);
343
+ if (res.length === 0) console.log('no test runs pending');
344
+ return 0;
345
+ }
346
+ console.error('usage: node scripts/gds/module-test-runner.js dispatch <version id> | collect');
347
+ return 2;
348
+ } finally {
349
+ if (sweeper) { clearInterval(sweeper); sweeper = null; }
350
+ await pool.end();
351
+ }
352
+ }
353
+
354
+ if (require.main === module) {
355
+ main(process.argv.slice(2)).then((code) => { process.exitCode = code; }, (e) => { console.error(e.stack || e.message); process.exitCode = 1; });
356
+ }
357
+
358
+ module.exports = { runnerConfig, maybeDispatch, collectResults, sweepTick, readZipEntry, validateSignal, runName, artifactName };
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.21.124'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.21.126'; // 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');