@bongos/core 1.21.150 → 1.21.151
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 +15 -15
- package/docs/module-api-changelog.md +2 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +6 -0
- package/scripts/gds/credential-presence.js +10 -0
- package/scripts/gds/module-test-runner/README.md +3 -3
- package/scripts/gds/module-test-runner/module-tests.yml +18 -6
- package/scripts/gds/module-test-runner.js +92 -36
- package/src/module-api.js +1 -1
- package/tests/module_test_runner.mjs +70 -17
package/.bongos-core.json
CHANGED
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
"artifact": "bongos-core",
|
|
3
3
|
"manifest_schema": 1,
|
|
4
4
|
"generator": "scripts/gds/package-core.js",
|
|
5
|
-
"core_version": "1.21.
|
|
6
|
-
"core_contract": "1.21.
|
|
7
|
-
"source_commit": "
|
|
5
|
+
"core_version": "1.21.151",
|
|
6
|
+
"core_contract": "1.21.151",
|
|
7
|
+
"source_commit": "366555c8ca4216d853ce17bab4c85a196abcfe99",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-10-
|
|
9
|
+
"built_at": "2026-10-21T20:09:00.726Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
12
|
"docs_redacted": 608,
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
19
|
"file_count": 3805,
|
|
20
|
-
"tree_sha256": "
|
|
20
|
+
"tree_sha256": "b07285b765ca2b3f7a888f50a72ebdbee4d2dc00b3f60cbeca742b826f325648",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/ask-for-help/SKILL.md",
|
|
@@ -2927,7 +2927,7 @@
|
|
|
2927
2927
|
{
|
|
2928
2928
|
"path": "docs/module-api-changelog.md",
|
|
2929
2929
|
"mode": "0000644",
|
|
2930
|
-
"sha256": "
|
|
2930
|
+
"sha256": "9898532c3685ca8978bd6b50f7dd08d517133ea7ec1f1666f1f2bdec93163f06"
|
|
2931
2931
|
},
|
|
2932
2932
|
{
|
|
2933
2933
|
"path": "docs/modules-contract.md",
|
|
@@ -10552,12 +10552,12 @@
|
|
|
10552
10552
|
{
|
|
10553
10553
|
"path": "package-lock.json",
|
|
10554
10554
|
"mode": "0000644",
|
|
10555
|
-
"sha256": "
|
|
10555
|
+
"sha256": "c3a3f8b630793dca42d403a809b1bc443a971b8b716cbc4528b618a2207e088e"
|
|
10556
10556
|
},
|
|
10557
10557
|
{
|
|
10558
10558
|
"path": "package.json",
|
|
10559
10559
|
"mode": "0000644",
|
|
10560
|
-
"sha256": "
|
|
10560
|
+
"sha256": "bd1bf8f46aa70487096bfe37135040919bfdec1f1b0698166c3b29d8f3cdb94d"
|
|
10561
10561
|
},
|
|
10562
10562
|
{
|
|
10563
10563
|
"path": "public-docs/index.html",
|
|
@@ -10577,7 +10577,7 @@
|
|
|
10577
10577
|
{
|
|
10578
10578
|
"path": "release-notes.json",
|
|
10579
10579
|
"mode": "0000644",
|
|
10580
|
-
"sha256": "
|
|
10580
|
+
"sha256": "53613b258fac54f2e36d9728dd874a94d4635a89557c97532b7bdfd1380fb4e1"
|
|
10581
10581
|
},
|
|
10582
10582
|
{
|
|
10583
10583
|
"path": "scripts/cron/style_guide_audit.sh",
|
|
@@ -10982,7 +10982,7 @@
|
|
|
10982
10982
|
{
|
|
10983
10983
|
"path": "scripts/gds/credential-presence.js",
|
|
10984
10984
|
"mode": "0000644",
|
|
10985
|
-
"sha256": "
|
|
10985
|
+
"sha256": "396e7ac7b19cec00134f4d13d7b130d0ef620f8b16ca61134ddf6df6ace0ea44"
|
|
10986
10986
|
},
|
|
10987
10987
|
{
|
|
10988
10988
|
"path": "scripts/gds/cutover-codemod.js",
|
|
@@ -11492,17 +11492,17 @@
|
|
|
11492
11492
|
{
|
|
11493
11493
|
"path": "scripts/gds/module-test-runner.js",
|
|
11494
11494
|
"mode": "0000644",
|
|
11495
|
-
"sha256": "
|
|
11495
|
+
"sha256": "a7b3b169ead1a43021f7207d5da2974728212e45867f1bacd33c66e9c5d33fe6"
|
|
11496
11496
|
},
|
|
11497
11497
|
{
|
|
11498
11498
|
"path": "scripts/gds/module-test-runner/README.md",
|
|
11499
11499
|
"mode": "0000644",
|
|
11500
|
-
"sha256": "
|
|
11500
|
+
"sha256": "0100c214318a60e6af0259e5194159963c0e879f549652cdd95920d77ac3e790"
|
|
11501
11501
|
},
|
|
11502
11502
|
{
|
|
11503
11503
|
"path": "scripts/gds/module-test-runner/module-tests.yml",
|
|
11504
11504
|
"mode": "0000644",
|
|
11505
|
-
"sha256": "
|
|
11505
|
+
"sha256": "5446be05a510ac398cf89ae0e03d47055c06b8bbe5db218e7e489f63e477cdb1"
|
|
11506
11506
|
},
|
|
11507
11507
|
{
|
|
11508
11508
|
"path": "scripts/gds/module.js",
|
|
@@ -12957,7 +12957,7 @@
|
|
|
12957
12957
|
{
|
|
12958
12958
|
"path": "src/module-api.js",
|
|
12959
12959
|
"mode": "0000644",
|
|
12960
|
-
"sha256": "
|
|
12960
|
+
"sha256": "19d8bd5693481f9d261606976c15bc375eeeb0ef35a6d80a89adb32885d6602f"
|
|
12961
12961
|
},
|
|
12962
12962
|
{
|
|
12963
12963
|
"path": "src/module-loader/catalog.js",
|
|
@@ -16657,7 +16657,7 @@
|
|
|
16657
16657
|
{
|
|
16658
16658
|
"path": "tests/module_test_runner.mjs",
|
|
16659
16659
|
"mode": "0000644",
|
|
16660
|
-
"sha256": "
|
|
16660
|
+
"sha256": "328cf836fd45d28ddfc0630f86801037069396dc7cdd066181375ac3c3a81538"
|
|
16661
16661
|
},
|
|
16662
16662
|
{
|
|
16663
16663
|
"path": "tests/module_toggle_restart.mjs",
|
|
@@ -3137,5 +3137,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
3137
3137
|
landed since 1.21.148 with no explicit bump. run 39329687123. (task 1002620)
|
|
3138
3138
|
1.21.150 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
3139
3139
|
landed since 1.21.149 with no explicit bump. run 39331860470. (task 1002620)
|
|
3140
|
+
1.21.151 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
3141
|
+
landed since 1.21.150 with no explicit bump. run 39667670641. (task 1002620)
|
|
3140
3142
|
---------------------------------------------------------------------------
|
|
3141
3143
|
```
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.21.
|
|
3
|
+
"version": "1.21.151",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.21.
|
|
9
|
+
"version": "1.21.151",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.21.
|
|
3
|
+
"version": "1.21.151",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
package/release-notes.json
CHANGED
|
@@ -9914,5 +9914,11 @@
|
|
|
9914
9914
|
"id": "1004760",
|
|
9915
9915
|
"text": "When a project's GitHub App isn't installed, a refused ship now says so and links the one-click install, instead of a bare 'not proven'."
|
|
9916
9916
|
}
|
|
9917
|
+
],
|
|
9918
|
+
"1.21.151": [
|
|
9919
|
+
{
|
|
9920
|
+
"id": "1004739",
|
|
9921
|
+
"text": "The module test runner can now test against the private core: the server sends its own copy of the core into the private test repo, once per version."
|
|
9922
|
+
}
|
|
9917
9923
|
]
|
|
9918
9924
|
}
|
|
@@ -121,6 +121,16 @@ const CREDENTIALS = [
|
|
|
121
121
|
names: ['METRICS_TOKEN'],
|
|
122
122
|
purpose: 'bearer token for a remote Prometheus scrape of /metrics (src/bongos/metrics.js)',
|
|
123
123
|
},
|
|
124
|
+
{
|
|
125
|
+
id: 'module-test-runner', file: 'web.env', need: 'optional',
|
|
126
|
+
names: ['MODULE_TEST_REPO', 'MODULE_TEST_TOKEN'],
|
|
127
|
+
purpose: "runs store modules' own tests in a separate private GitHub repo (scripts/gds/module-test-runner.js, blocker 1000195); unset leaves Tests pending",
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
id: 'module-test-ref', file: 'web.env', need: 'optional',
|
|
131
|
+
names: ['MODULE_TEST_REF'], notSecret: true,
|
|
132
|
+
purpose: "the test repo branch holding the module-tests workflow (default main)",
|
|
133
|
+
},
|
|
124
134
|
{
|
|
125
135
|
id: 'hub-signing-key', file: 'web.env', need: 'optional',
|
|
126
136
|
names: ['HUB_SIGNING_KEY'], prefixed: true,
|
|
@@ -22,9 +22,9 @@ stays `pending`.
|
|
|
22
22
|
MODULE_TEST_REPO=<owner>/<repo>
|
|
23
23
|
MODULE_TEST_TOKEN=<the token>
|
|
24
24
|
```
|
|
25
|
-
Optional: `
|
|
26
|
-
|
|
27
|
-
|
|
25
|
+
Optional: `MODULE_TEST_REF=<branch>` if the test repo's workflow lives on a
|
|
26
|
+
branch other than `main`. Nothing names the core repo: the runner gets the
|
|
27
|
+
core from the test repo itself (see the header of `module-test-runner.js`).
|
|
28
28
|
|
|
29
29
|
On the runner, a module's tests run as a separate user with no admin rights and
|
|
30
30
|
no network access (only the machine's own loopback), after the core is installed.
|
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
# nothing here ever holds a DB credential or a control-plane secret.
|
|
7
7
|
#
|
|
8
8
|
# Two jobs on purpose: `fetch` can read the repo (where the tarball waits on a
|
|
9
|
-
# throwaway branch
|
|
9
|
+
# throwaway branch, and the core snapshot on core/<sha>) but runs no module code;
|
|
10
|
+
# `test` runs the module's code with a
|
|
10
11
|
# GITHUB_TOKEN that has NO permissions, so a module that scrapes its token still
|
|
11
12
|
# cannot read another run's tarball. Inside `test`, the module's code runs as an
|
|
12
13
|
# unprivileged user with its network cut (see "Cut the network for module code").
|
|
@@ -19,8 +20,7 @@ on:
|
|
|
19
20
|
version_id: { description: store_module_versions.id, required: true, type: string }
|
|
20
21
|
module_key: { description: the module's key, required: true, type: string }
|
|
21
22
|
nonce: { description: this run's nonce (32 hex), required: true, type: string }
|
|
22
|
-
|
|
23
|
-
core_ref: { description: the core commit to test against, required: true, type: string }
|
|
23
|
+
core_ref: { description: the core commit to test against (40 hex; branch core/<sha> holds it), required: true, type: string }
|
|
24
24
|
|
|
25
25
|
permissions: {}
|
|
26
26
|
|
|
@@ -31,14 +31,21 @@ jobs:
|
|
|
31
31
|
timeout-minutes: 5
|
|
32
32
|
steps:
|
|
33
33
|
- name: Check the inputs
|
|
34
|
-
env: { NONCE: "${{ inputs.nonce }}", VERSION_ID: "${{ inputs.version_id }}" }
|
|
34
|
+
env: { NONCE: "${{ inputs.nonce }}", VERSION_ID: "${{ inputs.version_id }}", CORE_REF: "${{ inputs.core_ref }}" }
|
|
35
35
|
run: |
|
|
36
36
|
[[ "$NONCE" =~ ^[0-9a-f]{32}$ ]] || { echo "bad nonce"; exit 1; }
|
|
37
37
|
[[ "$VERSION_ID" =~ ^[0-9]+$ ]] || { echo "bad version id"; exit 1; }
|
|
38
|
+
[[ "$CORE_REF" =~ ^[0-9a-f]{40}$ ]] || { echo "bad core ref"; exit 1; }
|
|
38
39
|
- uses: actions/checkout@v4
|
|
39
40
|
with: { ref: "module-test/${{ inputs.nonce }}", path: tarball, persist-credentials: false }
|
|
41
|
+
# The core repo is private, so the control plane pushes a snapshot of the
|
|
42
|
+
# core it runs to core/<sha> in THIS repo (once per commit; task 1004739).
|
|
43
|
+
- uses: actions/checkout@v4
|
|
44
|
+
with: { ref: "core/${{ inputs.core_ref }}", path: core-snapshot, persist-credentials: false }
|
|
40
45
|
- uses: actions/upload-artifact@v4
|
|
41
46
|
with: { name: "tarball-${{ inputs.nonce }}", path: tarball/module.tgz, retention-days: 1 }
|
|
47
|
+
- uses: actions/upload-artifact@v4
|
|
48
|
+
with: { name: "core-${{ inputs.nonce }}", path: core-snapshot/core.tgz, retention-days: 1, compression-level: 0 }
|
|
42
49
|
|
|
43
50
|
test:
|
|
44
51
|
needs: fetch
|
|
@@ -46,12 +53,17 @@ jobs:
|
|
|
46
53
|
permissions: {}
|
|
47
54
|
timeout-minutes: 30
|
|
48
55
|
steps:
|
|
49
|
-
- uses: actions/checkout@v4
|
|
50
|
-
with: { repository: "${{ inputs.core_repo }}", ref: "${{ inputs.core_ref }}", path: core, persist-credentials: false }
|
|
51
56
|
- uses: actions/setup-node@v4
|
|
52
57
|
with: { node-version: 22 }
|
|
53
58
|
- uses: actions/download-artifact@v4
|
|
54
59
|
with: { name: "tarball-${{ inputs.nonce }}", path: in }
|
|
60
|
+
- uses: actions/download-artifact@v4
|
|
61
|
+
with: { name: "core-${{ inputs.nonce }}", path: core-in }
|
|
62
|
+
- name: Unpack the core
|
|
63
|
+
run: |
|
|
64
|
+
mkdir core
|
|
65
|
+
tar -xzf core-in/core.tgz -C core
|
|
66
|
+
rm -rf core-in
|
|
55
67
|
- name: Install the core
|
|
56
68
|
working-directory: core
|
|
57
69
|
run: npm ci --no-audit --no-fund
|
|
@@ -13,18 +13,31 @@
|
|
|
13
13
|
// THE ROUND TRIP. Nothing on the runner ever holds a DB credential:
|
|
14
14
|
// 1. dispatch — the control plane pushes the version's tarball to a throwaway
|
|
15
15
|
// orphan branch `module-test/<nonce>` in the test repo and dispatches the
|
|
16
|
-
// workflow with { version_id, module_key, nonce,
|
|
17
|
-
//
|
|
16
|
+
// workflow with { version_id, module_key, nonce, core_ref }. The nonce goes
|
|
17
|
+
// into the pending Tests row's detail.runner.
|
|
18
18
|
// 2. the run — a `fetch` job (contents: read, no module code) copies the tarball
|
|
19
|
-
// into the run as
|
|
20
|
-
//
|
|
21
|
-
// MODULE_TEST_SANDBOX=1, and uploads the signal JSON as artifact
|
|
19
|
+
// and the core snapshot into the run as artifacts; a separate `test` job
|
|
20
|
+
// (permissions: {}) unpacks the core, runs module-assess-tests.js --tarball
|
|
21
|
+
// with MODULE_TEST_SANDBOX=1, and uploads the signal JSON as artifact
|
|
22
22
|
// module-tests-<version_id>-<nonce>.
|
|
23
23
|
// 3. collect — the control plane PULLS: it finds the run whose run-name carries
|
|
24
24
|
// the pending row's nonce, takes only that run's artifact of that exact name,
|
|
25
25
|
// checks the payload names the same version and nonce, inserts the signal row
|
|
26
26
|
// itself, recomposes the score, and deletes the branch.
|
|
27
27
|
//
|
|
28
|
+
// WHY THE CORE TRAVELS AS A SNAPSHOT (task 1004739). The core repo is private, so
|
|
29
|
+
// a runner whose token has no permissions cannot check it out by name. Before the
|
|
30
|
+
// first run against a core commit, dispatch pushes `git archive` of that commit
|
|
31
|
+
// (tracked files only, so never node_modules; minus the design mocks in
|
|
32
|
+
// .impeccable/, which nothing runs) to an orphan branch `core/<sha>` in the test
|
|
33
|
+
// repo, with the same token. Once per commit: a later dispatch finds the ref and
|
|
34
|
+
// reuses it. The `fetch` job reads it with its contents: read token like the
|
|
35
|
+
// tarball, so the `test` job still needs no permission and no repo name at all.
|
|
36
|
+
// GitHub refuses a blob over 100 MB and the archive goes up base64-encoded (a
|
|
37
|
+
// third bigger), so a snapshot over MAX_CORE_BYTES fails the dispatch loudly: a
|
|
38
|
+
// dispatch that cannot be sent is logged as an error, not a warning, because the
|
|
39
|
+
// version's Tests part then stays pending until someone fixes the cause.
|
|
40
|
+
//
|
|
28
41
|
// WHY A PER-RUN BRANCH, NOT A DOWNLOAD TICKET. A ticket would mean a new
|
|
29
42
|
// unauthenticated route on the control plane (and a change to the protected
|
|
30
43
|
// route-rank-check), i.e. a fresh inbound path to the server holding the secrets.
|
|
@@ -37,53 +50,50 @@
|
|
|
37
50
|
// tests pass, so the score is never trust-critical; what this protects is the
|
|
38
51
|
// secrets and other authors' code.
|
|
39
52
|
//
|
|
40
|
-
// Configured by
|
|
41
|
-
// `pending`, exactly as before this task):
|
|
53
|
+
// Configured by control-plane env vars (repo + token unset = off; Tests then
|
|
54
|
+
// stays `pending`, exactly as before this task):
|
|
42
55
|
// MODULE_TEST_REPO owner/name of the private test repo
|
|
43
56
|
// MODULE_TEST_TOKEN a fine-grained token scoped to ONLY that repo, with
|
|
44
57
|
// 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
58
|
// MODULE_TEST_REF the test repo branch holding the workflow (default main)
|
|
48
59
|
//
|
|
49
60
|
// node scripts/gds/module-test-runner.js dispatch <store_module_versions.id>
|
|
50
61
|
// node scripts/gds/module-test-runner.js collect
|
|
51
62
|
|
|
52
63
|
const fs = require('node:fs');
|
|
64
|
+
const path = require('node:path');
|
|
53
65
|
const crypto = require('node:crypto');
|
|
54
66
|
const zlib = require('node:zlib');
|
|
55
|
-
const {
|
|
67
|
+
const { execFile } = require('node:child_process');
|
|
68
|
+
const { promisify } = require('node:util');
|
|
69
|
+
|
|
70
|
+
const execFileAsync = promisify(execFile);
|
|
56
71
|
|
|
57
72
|
const WORKFLOW_FILE = 'module-tests.yml';
|
|
58
73
|
const BRANCH_PREFIX = 'module-test/';
|
|
74
|
+
const CORE_BRANCH_PREFIX = 'core/';
|
|
59
75
|
const API = 'https://api.github.com';
|
|
60
76
|
// A run that has not finished this long after dispatch is recorded not_scored.
|
|
61
77
|
const MAX_RUN_AGE_MS = 6 * 60 * 60_000;
|
|
62
78
|
const MAX_SIGNAL_BYTES = 256 * 1024;
|
|
79
|
+
// GitHub's blob limit is 100 MB and the snapshot is sent base64 (4/3 the size).
|
|
80
|
+
const MAX_CORE_BYTES = 72 * 1024 * 1024;
|
|
81
|
+
const CORE_ROOT = path.join(__dirname, '..', '..');
|
|
63
82
|
const REPO_RE = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
|
|
64
83
|
const NONCE_RE = /^[0-9a-f]{32}$/;
|
|
84
|
+
const SHA_RE = /^[0-9a-f]{40}$/;
|
|
65
85
|
|
|
66
86
|
const runName = (versionId, nonce) => `module-tests ${versionId} ${nonce}`;
|
|
67
87
|
const artifactName = (versionId, nonce) => `module-tests-${versionId}-${nonce}`;
|
|
68
88
|
|
|
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
89
|
// The runner config, or null when it is not set up (the default: Tests stays pending).
|
|
78
|
-
function runnerConfig(env = process.env
|
|
90
|
+
function runnerConfig(env = process.env) {
|
|
79
91
|
const repo = (env.MODULE_TEST_REPO || '').trim();
|
|
80
92
|
const token = (env.MODULE_TEST_TOKEN || '').trim();
|
|
81
93
|
if (!repo || !token) return null;
|
|
82
94
|
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
95
|
const ref = (env.MODULE_TEST_REF || '').trim() || 'main';
|
|
86
|
-
return { repo, token,
|
|
96
|
+
return { repo, token, ref };
|
|
87
97
|
}
|
|
88
98
|
|
|
89
99
|
// One GitHub REST call. Resolves the parsed body (or null for 204); throws with
|
|
@@ -104,7 +114,54 @@ async function gh(cfg, method, urlPath, body, { fetchImpl = fetch } = {}) {
|
|
|
104
114
|
// The commit the control plane is running, so the runner tests against the same core.
|
|
105
115
|
function currentCoreRef() {
|
|
106
116
|
const { buildInfo } = require('../../src/build-info');
|
|
107
|
-
return buildInfo().commit
|
|
117
|
+
return buildInfo().commit;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// The core at one commit, as the tar.gz the runner unpacks. Async, so the web
|
|
121
|
+
// process keeps serving while git compresses. It needs the commit in a git
|
|
122
|
+
// checkout here: a vendored core (node_modules/@bongos/core) has none, and gets
|
|
123
|
+
// an error that says so rather than git's own.
|
|
124
|
+
async function archiveCore(sha) {
|
|
125
|
+
try {
|
|
126
|
+
const { stdout } = await execFileAsync('git', ['archive', '--format=tar.gz', sha, '--', '.', ':(exclude).impeccable', ':(exclude)node_modules'],
|
|
127
|
+
{ cwd: CORE_ROOT, encoding: 'buffer', maxBuffer: 4 * MAX_CORE_BYTES });
|
|
128
|
+
return stdout;
|
|
129
|
+
} catch (e) {
|
|
130
|
+
const why = String((e.stderr && e.stderr.toString()) || e.message).trim().split('\n')[0];
|
|
131
|
+
throw new Error(`could not snapshot core commit ${sha.slice(0, 8)}: the core must run from a git checkout that holds it (a vendored install has none) — git said: ${why}`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Push a one-file orphan commit to a new branch of the test repo.
|
|
136
|
+
async function pushOrphanBranch(cfg, branch, file, bytes, message, { fetchImpl } = {}) {
|
|
137
|
+
const r = `/repos/${cfg.repo}`;
|
|
138
|
+
const blob = await gh(cfg, 'POST', `${r}/git/blobs`, { content: bytes.toString('base64'), encoding: 'base64' }, { fetchImpl });
|
|
139
|
+
const tree = await gh(cfg, 'POST', `${r}/git/trees`, { tree: [{ path: file, mode: '100644', type: 'blob', sha: blob.sha }] }, { fetchImpl });
|
|
140
|
+
const commit = await gh(cfg, 'POST', `${r}/git/commits`, { message, tree: tree.sha, parents: [] }, { fetchImpl });
|
|
141
|
+
await gh(cfg, 'POST', `${r}/git/refs`, { ref: `refs/heads/${branch}`, sha: commit.sha }, { fetchImpl });
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Make sure branch core/<sha> holds the core snapshot, pushing it only the first
|
|
145
|
+
// time. Resolves { branch, pushed }. Two dispatches racing on a new commit both
|
|
146
|
+
// push; the loser's ref create is refused (422) and it uses the winner's.
|
|
147
|
+
async function ensureCoreSnapshot(cfg, sha, { fetchImpl, archive = archiveCore } = {}) {
|
|
148
|
+
if (!SHA_RE.test(sha || '')) throw new Error(`the running core commit is unknown ("${sha || ''}"), so there is no core to test against`);
|
|
149
|
+
const branch = `${CORE_BRANCH_PREFIX}${sha}`;
|
|
150
|
+
try {
|
|
151
|
+
await gh(cfg, 'GET', `/repos/${cfg.repo}/git/ref/heads/${branch}`, null, { fetchImpl });
|
|
152
|
+
return { branch, pushed: false };
|
|
153
|
+
} catch (e) { if (e.status !== 404) throw e; }
|
|
154
|
+
const tgz = await archive(sha);
|
|
155
|
+
if (tgz.length > MAX_CORE_BYTES) {
|
|
156
|
+
throw new Error(`the core snapshot for ${sha.slice(0, 8)} is ${(tgz.length / 1048576).toFixed(1)} MB, over the ${MAX_CORE_BYTES / 1048576} MB the test repo can take; trim what archiveCore sends in scripts/gds/module-test-runner.js`);
|
|
157
|
+
}
|
|
158
|
+
try {
|
|
159
|
+
await pushOrphanBranch(cfg, branch, 'core.tgz', tgz, `core snapshot ${sha}`, { fetchImpl });
|
|
160
|
+
} catch (e) {
|
|
161
|
+
if (e.status === 422) return { branch, pushed: false };
|
|
162
|
+
throw e;
|
|
163
|
+
}
|
|
164
|
+
return { branch, pushed: true };
|
|
108
165
|
}
|
|
109
166
|
|
|
110
167
|
function readVersionTarball(ver) {
|
|
@@ -112,27 +169,26 @@ function readVersionTarball(ver) {
|
|
|
112
169
|
return fs.promises.readFile(versionArtifactFile(ver));
|
|
113
170
|
}
|
|
114
171
|
|
|
115
|
-
//
|
|
116
|
-
// Resolves the runner record kept in the
|
|
117
|
-
|
|
172
|
+
// Make sure the core snapshot is there, push the tarball to a throwaway orphan
|
|
173
|
+
// branch, and dispatch the workflow. Resolves the runner record kept in the
|
|
174
|
+
// pending row: { nonce, branch, core_ref, dispatched_at }.
|
|
175
|
+
async function dispatchRun(ver, { cfg, fetchImpl, readTarball = readVersionTarball, coreRef = currentCoreRef, archive, now = () => new Date() } = {}) {
|
|
176
|
+
const sha = coreRef();
|
|
177
|
+
await ensureCoreSnapshot(cfg, sha, { fetchImpl, archive });
|
|
118
178
|
const tgz = await readTarball(ver);
|
|
119
179
|
const nonce = crypto.randomBytes(16).toString('hex');
|
|
120
180
|
const branch = `${BRANCH_PREFIX}${nonce}`;
|
|
121
|
-
|
|
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 });
|
|
181
|
+
await pushOrphanBranch(cfg, branch, 'module.tgz', tgz, `module tests: version ${ver.id}`, { fetchImpl });
|
|
126
182
|
try {
|
|
127
|
-
await gh(cfg, 'POST',
|
|
183
|
+
await gh(cfg, 'POST', `/repos/${cfg.repo}/actions/workflows/${WORKFLOW_FILE}/dispatches`, {
|
|
128
184
|
ref: cfg.ref || 'main',
|
|
129
|
-
inputs: { version_id: String(ver.id), module_key: ver.module_key, nonce,
|
|
185
|
+
inputs: { version_id: String(ver.id), module_key: ver.module_key, nonce, core_ref: sha },
|
|
130
186
|
}, { fetchImpl });
|
|
131
187
|
} catch (e) {
|
|
132
188
|
await deleteBranch(cfg, branch, { fetchImpl });
|
|
133
189
|
throw e;
|
|
134
190
|
}
|
|
135
|
-
return { nonce, branch, dispatched_at: now().toISOString() };
|
|
191
|
+
return { nonce, branch, core_ref: sha, dispatched_at: now().toISOString() };
|
|
136
192
|
}
|
|
137
193
|
|
|
138
194
|
async function deleteBranch(cfg, branch, { fetchImpl } = {}) {
|
|
@@ -162,7 +218,7 @@ async function maybeDispatch(ver, { db, env = process.env, log = console, ...opt
|
|
|
162
218
|
if (newest && !(newest.outcome === 'pending' && !(newest.detail && newest.detail.runner))) return null;
|
|
163
219
|
let runner;
|
|
164
220
|
try { runner = await dispatchRun(ver, { cfg, ...opts }); }
|
|
165
|
-
catch (e) { log.
|
|
221
|
+
catch (e) { log.error(`module test run for version ${ver.id} not dispatched: ${e.message}`); return null; }
|
|
166
222
|
await db.query(
|
|
167
223
|
`INSERT INTO module_assessment_signals (version_id, part, outcome, score, sample_size, reason, detail)
|
|
168
224
|
VALUES ($1, 'tests', 'pending', NULL, NULL, $2, $3)`,
|
|
@@ -355,4 +411,4 @@ if (require.main === module) {
|
|
|
355
411
|
main(process.argv.slice(2)).then((code) => { process.exitCode = code; }, (e) => { console.error(e.stack || e.message); process.exitCode = 1; });
|
|
356
412
|
}
|
|
357
413
|
|
|
358
|
-
module.exports = { runnerConfig, maybeDispatch, collectResults, sweepTick, readZipEntry, validateSignal, runName, artifactName };
|
|
414
|
+
module.exports = { runnerConfig, archiveCore, 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.
|
|
78
|
+
const CORE_VERSION = '1.21.151'; // 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');
|
|
@@ -18,9 +18,12 @@ import { fileURLToPath } from 'node:url';
|
|
|
18
18
|
const require = createRequire(import.meta.url);
|
|
19
19
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
20
20
|
const runner = require('../scripts/gds/module-test-runner.js');
|
|
21
|
-
const { runnerConfig, maybeDispatch, collectResults, sweepTick, readZipEntry, validateSignal, runName, artifactName } = runner;
|
|
21
|
+
const { runnerConfig, archiveCore, maybeDispatch, collectResults, sweepTick, readZipEntry, validateSignal, runName, artifactName } = runner;
|
|
22
22
|
|
|
23
|
-
const CFG = { repo: 'owner/module-tests', token: 't0ken'
|
|
23
|
+
const CFG = { repo: 'owner/module-tests', token: 't0ken' };
|
|
24
|
+
const SHA = 'a1'.repeat(20);
|
|
25
|
+
const coreRef = () => SHA;
|
|
26
|
+
const archive = async () => Buffer.from('core-tgz');
|
|
24
27
|
const VER = { id: 7, module_key: 'weather', version: '1.0.0', artifact_path: 'weather/weather-1.0.0.tgz' };
|
|
25
28
|
const quiet = { warn() {}, error() {} };
|
|
26
29
|
|
|
@@ -104,36 +107,75 @@ test('unconfigured: no runner, so Tests stays plain pending and nothing is sent'
|
|
|
104
107
|
assert.equal(db.signals.length, 0);
|
|
105
108
|
});
|
|
106
109
|
|
|
107
|
-
test('config: owner/name is required
|
|
110
|
+
test('config: owner/name is required; there is no core repo to name', () => {
|
|
108
111
|
assert.throws(() => runnerConfig({ MODULE_TEST_REPO: 'nope', MODULE_TEST_TOKEN: 'x' }), /owner\/name/);
|
|
109
|
-
|
|
110
|
-
assert.
|
|
111
|
-
assert.equal(runnerConfig({ MODULE_TEST_REPO: 'o/r', MODULE_TEST_TOKEN: 'x', MODULE_TEST_REF: 'trunk' }, { origin: () => 'o/core' }).ref, 'trunk');
|
|
112
|
+
assert.deepEqual(runnerConfig({ MODULE_TEST_REPO: 'o/r', MODULE_TEST_TOKEN: 'x', MODULE_TEST_CORE_REPO: 'o/core' }), { repo: 'o/r', token: 'x', ref: 'main' });
|
|
113
|
+
assert.equal(runnerConfig({ MODULE_TEST_REPO: 'o/r', MODULE_TEST_TOKEN: 'x', MODULE_TEST_REF: 'trunk' }).ref, 'trunk');
|
|
112
114
|
});
|
|
113
115
|
|
|
114
|
-
|
|
116
|
+
const short = (c) => `${c.method} ${c.url.replace('https://api.github.com/repos/owner/module-tests', '')}`;
|
|
117
|
+
const PUSH = ['POST /git/blobs', 'POST /git/trees', 'POST /git/commits', 'POST /git/refs'];
|
|
118
|
+
const DISPATCH = 'POST /actions/workflows/module-tests.yml/dispatches';
|
|
119
|
+
|
|
120
|
+
test('the first dispatch on a core commit pushes the core to core/<sha>, then the tarball, then dispatches', async () => {
|
|
115
121
|
const db = fakeDb();
|
|
116
122
|
const gh = fakeGitHub(dispatchRoutes);
|
|
117
|
-
const r = await maybeDispatch(VER, { db, cfg: CFG, fetchImpl: gh.fetchImpl, readTarball: async () => Buffer.from('tgz'), coreRef
|
|
123
|
+
const r = await maybeDispatch(VER, { db, cfg: CFG, fetchImpl: gh.fetchImpl, readTarball: async () => Buffer.from('tgz'), coreRef, archive, log: quiet });
|
|
118
124
|
assert.match(r.nonce, /^[0-9a-f]{32}$/);
|
|
119
125
|
assert.equal(r.branch, `module-test/${r.nonce}`);
|
|
120
|
-
assert.
|
|
121
|
-
|
|
122
|
-
assert.
|
|
123
|
-
assert.equal(gh.calls[
|
|
124
|
-
assert.
|
|
125
|
-
assert.
|
|
126
|
+
assert.equal(r.core_ref, SHA);
|
|
127
|
+
assert.deepEqual(gh.calls.map(short), [`GET /git/ref/heads/core/${SHA}`, ...PUSH, ...PUSH, DISPATCH]);
|
|
128
|
+
assert.equal(Buffer.from(gh.calls[1].body.content, 'base64').toString(), 'core-tgz');
|
|
129
|
+
assert.equal(gh.calls[2].body.tree[0].path, 'core.tgz');
|
|
130
|
+
assert.deepEqual(gh.calls[3].body.parents, [], 'the core snapshot is an orphan commit');
|
|
131
|
+
assert.equal(gh.calls[4].body.ref, `refs/heads/core/${SHA}`);
|
|
132
|
+
assert.deepEqual(gh.calls[7].body.parents, [], 'an orphan commit: the branch carries only the tarball');
|
|
133
|
+
assert.equal(gh.calls[8].body.ref, `refs/heads/module-test/${r.nonce}`);
|
|
134
|
+
assert.equal(gh.calls[9].body.ref, 'main');
|
|
135
|
+
assert.deepEqual(gh.calls[9].body.inputs, { version_id: '7', module_key: 'weather', nonce: r.nonce, core_ref: SHA });
|
|
126
136
|
assert.equal(db.signals.length, 1);
|
|
127
137
|
assert.equal(db.signals[0].outcome, 'pending');
|
|
128
138
|
assert.equal(db.signals[0].detail.runner.nonce, r.nonce);
|
|
129
139
|
});
|
|
130
140
|
|
|
141
|
+
test('a later dispatch on the same core commit reuses core/<sha> and never archives again', async () => {
|
|
142
|
+
const gh = fakeGitHub({ ...dispatchRoutes, [`GET /git/ref/heads/core/${SHA}$`]: { ref: `refs/heads/core/${SHA}` } });
|
|
143
|
+
let archived = 0;
|
|
144
|
+
const r = await maybeDispatch(VER, { db: fakeDb(), cfg: CFG, fetchImpl: gh.fetchImpl, readTarball: async () => Buffer.from('tgz'), coreRef, archive: async () => { archived += 1; return Buffer.from('x'); }, log: quiet });
|
|
145
|
+
assert.ok(r);
|
|
146
|
+
assert.equal(archived, 0);
|
|
147
|
+
assert.deepEqual(gh.calls.map(short), [`GET /git/ref/heads/core/${SHA}`, ...PUSH, DISPATCH]);
|
|
148
|
+
assert.equal(gh.calls[1].body.content, Buffer.from('tgz').toString('base64'), 'only the module tarball is pushed');
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test('two dispatches racing on a new commit: the loser\'s refused ref create still dispatches', async () => {
|
|
152
|
+
const gh = fakeGitHub({ ...dispatchRoutes, 'POST /git/refs$': (url, init) => (JSON.parse(init.body).ref.startsWith('refs/heads/core/') ? new Response('{"message":"Reference already exists"}', { status: 422 }) : { ref: 'x' }) });
|
|
153
|
+
assert.ok(await maybeDispatch(VER, { db: fakeDb(), cfg: CFG, fetchImpl: gh.fetchImpl, readTarball: async () => Buffer.from('tgz'), coreRef, archive, log: quiet }));
|
|
154
|
+
assert.equal(gh.calls.at(-1).url.endsWith('/dispatches'), true);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test('an oversized core snapshot, or an unknown core commit, fails the dispatch loudly and pushes nothing', async () => {
|
|
158
|
+
for (const opts of [{ archive: async () => Buffer.alloc(73 * 1024 * 1024) }, { coreRef: () => null, archive }]) {
|
|
159
|
+
const db = fakeDb();
|
|
160
|
+
const gh = fakeGitHub(dispatchRoutes);
|
|
161
|
+
const errors = [];
|
|
162
|
+
assert.equal(await maybeDispatch(VER, { db, cfg: CFG, fetchImpl: gh.fetchImpl, readTarball: async () => Buffer.from('tgz'), coreRef, log: { warn() {}, error: (m) => errors.push(m) }, ...opts }), null);
|
|
163
|
+
assert.match(errors[0], /over the 72 MB|core commit is unknown/);
|
|
164
|
+
assert.ok(!gh.calls.some((c) => c.method === 'POST'), 'nothing was pushed or dispatched');
|
|
165
|
+
assert.equal(db.signals.length, 0);
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test('a core commit git cannot find fails with a cause a person can act on, not git\'s bare error', async () => {
|
|
170
|
+
await assert.rejects(archiveCore('0'.repeat(40)), /must run from a git checkout that holds it/);
|
|
171
|
+
});
|
|
172
|
+
|
|
131
173
|
test('a version with a real Tests result, or a run already in flight, is not dispatched again', async () => {
|
|
132
174
|
for (const existing of [{ outcome: 'scored', detail: {} }, { outcome: 'pending', detail: { runner: { nonce: 'a'.repeat(32) } } }]) {
|
|
133
175
|
const db = fakeDb();
|
|
134
176
|
db.signals.push({ id: 1, version_id: 7, part: 'tests', ...existing });
|
|
135
177
|
const gh = fakeGitHub(dispatchRoutes);
|
|
136
|
-
assert.equal(await maybeDispatch(VER, { db, cfg: CFG, fetchImpl: gh.fetchImpl, readTarball: async () => Buffer.from('x'), coreRef
|
|
178
|
+
assert.equal(await maybeDispatch(VER, { db, cfg: CFG, fetchImpl: gh.fetchImpl, readTarball: async () => Buffer.from('x'), coreRef, archive, log: quiet }), null);
|
|
137
179
|
assert.equal(gh.calls.length, 0);
|
|
138
180
|
}
|
|
139
181
|
});
|
|
@@ -142,13 +184,13 @@ test('a plain pending (from before the runner was set up) is dispatched', async
|
|
|
142
184
|
const db = fakeDb();
|
|
143
185
|
db.signals.push({ id: 1, version_id: 7, part: 'tests', outcome: 'pending', detail: {} });
|
|
144
186
|
const gh = fakeGitHub(dispatchRoutes);
|
|
145
|
-
assert.ok(await maybeDispatch(VER, { db, cfg: CFG, fetchImpl: gh.fetchImpl, readTarball: async () => Buffer.from('x'), coreRef
|
|
187
|
+
assert.ok(await maybeDispatch(VER, { db, cfg: CFG, fetchImpl: gh.fetchImpl, readTarball: async () => Buffer.from('x'), coreRef, archive, log: quiet }));
|
|
146
188
|
});
|
|
147
189
|
|
|
148
190
|
test('a failed dispatch deletes the branch, records nothing and never throws', async () => {
|
|
149
191
|
const db = fakeDb();
|
|
150
192
|
const gh = fakeGitHub({ ...dispatchRoutes, 'POST /dispatches$': () => new Response('{"message":"nope"}', { status: 422 }), 'DELETE /git/refs/heads/module-test/': null });
|
|
151
|
-
assert.equal(await maybeDispatch(VER, { db, cfg: CFG, fetchImpl: gh.fetchImpl, readTarball: async () => Buffer.from('x'), coreRef
|
|
193
|
+
assert.equal(await maybeDispatch(VER, { db, cfg: CFG, fetchImpl: gh.fetchImpl, readTarball: async () => Buffer.from('x'), coreRef, archive, log: quiet }), null);
|
|
152
194
|
assert.equal(gh.calls.at(-1).method, 'DELETE');
|
|
153
195
|
assert.equal(db.signals.length, 0);
|
|
154
196
|
});
|
|
@@ -274,7 +316,18 @@ test('the workflow template: no permissions for the job that runs module code, n
|
|
|
274
316
|
assert.match(yml, /^run-name: module-tests \$\{\{ inputs\.version_id \}\} \$\{\{ inputs\.nonce \}\}$/m);
|
|
275
317
|
assert.equal(runName(7, 'n'), 'module-tests 7 n', 'collect matches the template\'s run-name');
|
|
276
318
|
const testJob = yml.slice(yml.indexOf('\n test:'));
|
|
319
|
+
const fetchJob = yml.slice(yml.indexOf('\n fetch:'), yml.indexOf('\n test:'));
|
|
277
320
|
assert.match(testJob, /permissions: \{\}/);
|
|
321
|
+
// The core repo is private (task 1004739): the test job checks out nothing by
|
|
322
|
+
// name; the fetch job reads the snapshot dispatch pushed to core/<sha> and hands
|
|
323
|
+
// it over as an artifact.
|
|
324
|
+
assert.doesNotMatch(testJob, /actions\/checkout/, 'the job that runs module code checks out no repo');
|
|
325
|
+
assert.doesNotMatch(yml, /repository:|core_repo/, 'no repo is named anywhere');
|
|
326
|
+
assert.match(fetchJob, /ref: "core\/\$\{\{ inputs\.core_ref \}\}"/);
|
|
327
|
+
assert.match(fetchJob, /name: "core-\$\{\{ inputs\.nonce \}\}", path: core-snapshot\/core\.tgz/);
|
|
328
|
+
assert.match(fetchJob, /\^\[0-9a-f\]\{40\}\$/, 'the core ref is checked before it is used');
|
|
329
|
+
assert.match(testJob, /name: "core-\$\{\{ inputs\.nonce \}\}", path: core-in/);
|
|
330
|
+
assert.ok(testJob.indexOf('Unpack the core') < testJob.indexOf('npm ci'), 'the core is unpacked before it is installed');
|
|
278
331
|
assert.match(testJob, /MODULE_TEST_SANDBOX=1/);
|
|
279
332
|
// Module code runs as an unprivileged user whose packets the firewall rejects off loopback.
|
|
280
333
|
const cut = testJob.indexOf('Cut the network');
|