@bongos/core 1.21.149 → 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 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.149",
6
- "core_contract": "1.21.149",
7
- "source_commit": "59efe78dbbc530e6743f98d50abc605e2184adcc",
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-19T21:44:07.784Z",
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": "2fb626947478be776e71613c82d973ebf073298ea8d6e5561f4e604e1438d661",
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": "af2424a88f8836e7035c00a52637fbbf137bf1b716130a360868fa50bab54b2d"
2930
+ "sha256": "9898532c3685ca8978bd6b50f7dd08d517133ea7ec1f1666f1f2bdec93163f06"
2931
2931
  },
2932
2932
  {
2933
2933
  "path": "docs/modules-contract.md",
@@ -7737,7 +7737,7 @@
7737
7737
  {
7738
7738
  "path": "modules/lifecycle/ship-assurance.js",
7739
7739
  "mode": "0000644",
7740
- "sha256": "690171d9e4e7d82537cf1d030d98aa37ee0ac72bbcdce6a04429d3d49ecbe46a"
7740
+ "sha256": "c4dd044485e664528c01caea0500dd6fe9d8ee9d2319385000d1cee26e32102e"
7741
7741
  },
7742
7742
  {
7743
7743
  "path": "modules/lifecycle/ship-card.js",
@@ -10552,12 +10552,12 @@
10552
10552
  {
10553
10553
  "path": "package-lock.json",
10554
10554
  "mode": "0000644",
10555
- "sha256": "d03c2a175c60400695aecccbe5c9ef87b085521c6bf92176c6d2ed08e514c8a6"
10555
+ "sha256": "c3a3f8b630793dca42d403a809b1bc443a971b8b716cbc4528b618a2207e088e"
10556
10556
  },
10557
10557
  {
10558
10558
  "path": "package.json",
10559
10559
  "mode": "0000644",
10560
- "sha256": "9d6713f305cdfe492805906778e171c75c42e4fd8dff5935ad9245d185b6ce22"
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": "9394c3c784137020e70389b269a68f36f8f2d0757b306a1f64622b8b5886f368"
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": "36ecef8da86e9905c8c3e8431881828aba0ecca7e10b63db014f86658c5bf58b"
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": "00c844922f5b465485847e45782c71c3103fe9d6aa0e09d4908121769aecbe78"
11495
+ "sha256": "a7b3b169ead1a43021f7207d5da2974728212e45867f1bacd33c66e9c5d33fe6"
11496
11496
  },
11497
11497
  {
11498
11498
  "path": "scripts/gds/module-test-runner/README.md",
11499
11499
  "mode": "0000644",
11500
- "sha256": "e6253ef4056f35355d5c19af247de840b0f925f7a2b8ead8f2598227be9e6d38"
11500
+ "sha256": "0100c214318a60e6af0259e5194159963c0e879f549652cdd95920d77ac3e790"
11501
11501
  },
11502
11502
  {
11503
11503
  "path": "scripts/gds/module-test-runner/module-tests.yml",
11504
11504
  "mode": "0000644",
11505
- "sha256": "d721ad7959afb899877aafd31c6a22287d6e479e0fb4fb4f600d12ce5e12a2fd"
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": "b89f1cd4820d3d4257eabf44a666b44c722c9e159dd561bbced94784cceaf8ea"
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": "e6fbb32fd8f43c748d89aca5a9025872582d1628e47de12c4ce4bca834c8be88"
16660
+ "sha256": "328cf836fd45d28ddfc0630f86801037069396dc7cdd066181375ac3c3a81538"
16661
16661
  },
16662
16662
  {
16663
16663
  "path": "tests/module_toggle_restart.mjs",
@@ -18102,7 +18102,7 @@
18102
18102
  {
18103
18103
  "path": "tests/ship_assurance.mjs",
18104
18104
  "mode": "0000644",
18105
- "sha256": "ca6e0100049ad284c8467fe4775ed9db4578f11c0704091043dda5e2c25654cf"
18105
+ "sha256": "be68b8dbf514937f581dfa3a892958fd4f16e20dd319d0ed70bf08811a3451e3"
18106
18106
  },
18107
18107
  {
18108
18108
  "path": "tests/ship_autolinkify.mjs",
@@ -3135,5 +3135,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
3135
3135
  landed since 1.21.147 with no explicit bump. run 39286989989. (task 1002620)
3136
3136
  1.21.149 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
3137
3137
  landed since 1.21.148 with no explicit bump. run 39329687123. (task 1002620)
3138
+ 1.21.150 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
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)
3138
3142
  ---------------------------------------------------------------------------
3139
3143
  ```
@@ -67,7 +67,13 @@ function normalizeShipAssurance(a) {
67
67
  //
68
68
  // `deps` is injected so this is unit-testable with no network and no DB.
69
69
  async function deriveShipAssurance({ taskId, body = {}, db, githubPush, onProbeError }) {
70
- const report = (e) => { if (onProbeError) onProbeError(e); };
70
+ // Every probe miss is kept, not just logged: when the ONLY reason there is no
71
+ // proof is that this server cannot read GitHub at all, the refusal must say so
72
+ // (task 1004760). The toast instance merged real work and was told only that the
73
+ // land was "not proven" — its GitHub App existed but was never installed on the
74
+ // repo, which is a one-click owner fix nobody was told about.
75
+ const probeErrors = [];
76
+ const report = (e) => { probeErrors.push(e); if (onProbeError) onProbeError(e); };
71
77
  // The tip is the durable proof (task 1547); the branch/PR status is only a
72
78
  // WITNESS for the out-of-band fallback (task 1002615). A claim with no branch
73
79
  // makes getPublishStatus throw BAD_BRANCH — that used to abort the whole probe
@@ -117,6 +123,26 @@ async function deriveShipAssurance({ taskId, body = {}, db, githubPush, onProbeE
117
123
  return { assurance: { kind: 'no_artifact', reason } };
118
124
  }
119
125
 
126
+ const noAccess = githubAccessCause(probeErrors);
127
+ if (noAccess) {
128
+ return {
129
+ refusal: {
130
+ code: 'ship_land_not_proven',
131
+ status: 409,
132
+ message: noAccess.message,
133
+ details: {
134
+ cause: noAccess.cause,
135
+ install_url: noAccess.install_url || null,
136
+ what_this_means: noAccess.message,
137
+ your_options: [
138
+ noAccess.fix,
139
+ 'Nothing is lost: once the server can read GitHub, the reconciler proves the merge and ships the task on its own, with its credits.',
140
+ ],
141
+ },
142
+ },
143
+ };
144
+ }
145
+
120
146
  return {
121
147
  refusal: {
122
148
  code: 'ship_land_not_proven',
@@ -133,6 +159,37 @@ async function deriveShipAssurance({ taskId, body = {}, db, githubPush, onProbeE
133
159
  }
134
160
 
135
161
 
162
+ // Was the land unprovable only because this server has no way to read GitHub?
163
+ // PURE over the probe errors deriveShipAssurance collected. Returns null when the
164
+ // probes could see GitHub (a genuine "not on main" answer stays the generic
165
+ // refusal), else { cause, message, fix, install_url? } (task 1004760).
166
+ // APP_NOT_INSTALLED the App exists but nobody installed it on the repo —
167
+ // the owner's one remaining setup step.
168
+ // PUSH_UNCONFIGURED / no GitHub credential at all, and no git checkout of
169
+ // LOCAL_PROOF_UNAVAILABLE the project to ask instead (a hosted instance).
170
+ function githubAccessCause(errors) {
171
+ const codes = (errors || []).map((e) => e && e.code);
172
+ const notInstalled = (errors || []).find((e) => e && e.code === 'APP_NOT_INSTALLED');
173
+ if (notInstalled) {
174
+ const repo = notInstalled.owner && notInstalled.repo ? `${notInstalled.owner}/${notInstalled.repo}` : 'the project repository';
175
+ const where = notInstalled.install_url ? `at ${notInstalled.install_url}` : "from the App's page in GitHub settings (Install App)";
176
+ return {
177
+ cause: 'github_app_not_installed',
178
+ install_url: notInstalled.install_url || null,
179
+ message: `The merge may well be on main, but this server cannot check: the project's GitHub App is not installed on ${repo}, so the server cannot read the repository.`,
180
+ fix: `The project owner installs the GitHub App on ${repo} ${where} — one click, no other setup.`,
181
+ };
182
+ }
183
+ if (codes.includes('PUSH_UNCONFIGURED') || codes.includes('LOCAL_PROOF_UNAVAILABLE')) {
184
+ return {
185
+ cause: 'no_github_access',
186
+ message: 'The merge may well be on main, but this server cannot check: it has no GitHub access (no GitHub App or token) and no copy of the repository to look in.',
187
+ fix: "The project owner finishes the project's GitHub setup (Settings → GitHub) so the server can read the repository.",
188
+ };
189
+ }
190
+ return null;
191
+ }
192
+
136
193
  // Record WHY a ship was allowed, not merely that it happened. POST-COMMIT and
137
194
  // best-effort by design: insertAuditLog uses the module pool rather than the
138
195
  // ship's transaction client, and an audit miss must never roll back a landed ship
@@ -172,13 +229,14 @@ async function gateManualShip(res, opts) {
172
229
  onProbeError: (e) => log && log.warn(`POST /tasks/${taskId}/ship: land probe failed (${e && e.message}) — no proof`),
173
230
  });
174
231
  if (derived.refusal) {
175
- res.fail(derived.refusal.code, derived.refusal.status, derived.refusal.details);
232
+ const { code, status, message, details } = derived.refusal;
233
+ res.fail(code, { status, message, details });
176
234
  return null;
177
235
  }
178
236
  return derived.assurance;
179
237
  }
180
238
 
181
239
  module.exports = {
182
- normalizeShipAssurance, deriveShipAssurance, recordShipAssurance,
240
+ normalizeShipAssurance, deriveShipAssurance, recordShipAssurance, githubAccessCause,
183
241
  assertShipAssurance, gateManualShip, SHIP_BODY_SCHEMA, SHIP_ASSURANCE_KINDS: KINDS, MAX_REASON_CHARS,
184
242
  };
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.21.149",
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.149",
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.149",
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",
@@ -9908,5 +9908,17 @@
9908
9908
  "id": "1004759",
9909
9909
  "text": "When a project's site is too old to send Bongos feedback, the command now says 'Not sent' and keeps the note, instead of claiming it was sent."
9910
9910
  }
9911
+ ],
9912
+ "1.21.150": [
9913
+ {
9914
+ "id": "1004760",
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
+ }
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
+ }
9911
9923
  ]
9912
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: `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`.
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) but runs no module code; `test` runs the module's code with a
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
- 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 }
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, core_repo, core_ref }. The
17
- // nonce goes into the pending Tests row's detail.runner.
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 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
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 three control-plane env vars (all unset = off; Tests then stays
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 { execFileSync } = require('node:child_process');
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, { origin = originRepo } = {}) {
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, coreRepo, ref };
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 || 'main';
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
- // 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() } = {}) {
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
- 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 });
181
+ await pushOrphanBranch(cfg, branch, 'module.tgz', tgz, `module tests: version ${ver.id}`, { fetchImpl });
126
182
  try {
127
- await gh(cfg, 'POST', `${r}/actions/workflows/${WORKFLOW_FILE}/dispatches`, {
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, core_repo: cfg.coreRepo, core_ref: coreRef() },
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.warn(`module test run for version ${ver.id} not dispatched: ${e.message}`); return null; }
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.149'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
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', coreRepo: 'owner/core' };
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, the core repo falls back to git origin', () => {
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
- const c = runnerConfig({ MODULE_TEST_REPO: 'o/r', MODULE_TEST_TOKEN: 'x' }, { origin: () => 'o/core' });
110
- assert.deepEqual(c, { repo: 'o/r', token: 'x', coreRepo: 'o/core', ref: 'main' });
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
- test('dispatch pushes the tarball to a throwaway branch, dispatches, and records the nonce', async () => {
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: () => 'abc123', log: quiet });
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.deepEqual(gh.calls.map((c) => `${c.method} ${c.url.replace('https://api.github.com/repos/owner/module-tests', '')}`), [
121
- 'POST /git/blobs', 'POST /git/trees', 'POST /git/commits', 'POST /git/refs', 'POST /actions/workflows/module-tests.yml/dispatches']);
122
- assert.deepEqual(gh.calls[2].body.parents, [], 'an orphan commit: the branch carries only the tarball');
123
- assert.equal(gh.calls[3].body.ref, `refs/heads/module-test/${r.nonce}`);
124
- assert.equal(gh.calls[4].body.ref, 'main');
125
- assert.deepEqual(gh.calls[4].body.inputs, { version_id: '7', module_key: 'weather', nonce: r.nonce, core_repo: 'owner/core', core_ref: 'abc123' });
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: () => 'c', log: quiet }), null);
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: () => 'c', log: quiet }));
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: () => 'c', log: quiet }), null);
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');
@@ -321,5 +321,69 @@ await t('no tip + no PR for the task → the 409 refusal, and getPublishStatus i
321
321
  assert.equal(statusCalls, 0);
322
322
  });
323
323
 
324
+ // task 1004760 — the toast instance merged real work and was refused with a bare
325
+ // "not proven": its GitHub App was never installed on the repo, so every probe threw
326
+ // APP_NOT_INSTALLED and the server could not look. The refusal must NAME that cause
327
+ // and the one-click fix, not leave the builder guessing.
328
+ console.log('\nthe refusal names a server that cannot read GitHub (task 1004760)');
329
+ const notInstalled = () => {
330
+ const e = new Error('APP_NOT_INSTALLED');
331
+ e.code = 'APP_NOT_INSTALLED';
332
+ e.owner = 'acme';
333
+ e.repo = 'widget';
334
+ e.install_url = 'https://github.com/apps/widget-app/installations/new';
335
+ return e;
336
+ };
337
+ await t('App not installed → the refusal says so, with the install link', async () => {
338
+ const r = await deriveShipAssurance({
339
+ taskId: 1,
340
+ body: {},
341
+ db: { getClaimPublishedSha: async () => TIP },
342
+ githubPush: {
343
+ findTaskPullRequest: async () => { throw notInstalled(); },
344
+ getPublishStatus: async () => null,
345
+ landProvenForTask: async () => { throw notInstalled(); },
346
+ },
347
+ });
348
+ assert.equal(r.refusal.code, 'ship_land_not_proven');
349
+ assert.equal(r.refusal.details.cause, 'github_app_not_installed');
350
+ assert.match(r.refusal.message, /not installed on acme\/widget/);
351
+ assert.equal(r.refusal.details.install_url, 'https://github.com/apps/widget-app/installations/new');
352
+ assert.ok(r.refusal.details.your_options.some((o) => o.includes('installations/new')));
353
+ });
354
+ await t('no credential and no checkout → the refusal says the server has no GitHub access', async () => {
355
+ for (const code of ['PUSH_UNCONFIGURED', 'LOCAL_PROOF_UNAVAILABLE']) {
356
+ const r = await deriveShipAssurance({
357
+ taskId: 1,
358
+ body: {},
359
+ db: { getClaimPublishedSha: async () => TIP },
360
+ githubPush: {
361
+ findTaskPullRequest: async () => null,
362
+ landProvenForTask: async () => { const e = new Error(code); e.code = code; throw e; },
363
+ },
364
+ });
365
+ assert.equal(r.refusal.details.cause, 'no_github_access', code);
366
+ assert.match(r.refusal.message, /no GitHub access/);
367
+ }
368
+ });
369
+ await t('a server that CAN read GitHub keeps the plain refusal (the tip really is not on main)', async () => {
370
+ const r = await deriveShipAssurance({
371
+ taskId: 1,
372
+ body: {},
373
+ db: { getClaimPublishedSha: async () => TIP },
374
+ githubPush: {
375
+ findTaskPullRequest: async () => null,
376
+ landProvenForTask: async () => ({ proven: false, reason: 'not in main' }),
377
+ },
378
+ });
379
+ assert.equal(r.refusal.code, 'ship_land_not_proven');
380
+ assert.equal(r.refusal.details.cause, undefined);
381
+ assert.equal(r.refusal.message, undefined);
382
+ });
383
+ await t('the gate sends the cause as the error MESSAGE, which is what the CLI prints', () => {
384
+ const src = fs.readFileSync(path.join(ROOT, 'modules/lifecycle/ship-assurance.js'), 'utf8');
385
+ assert.match(src, /res\.fail\(code, \{ status, message, details \}\)/);
386
+ });
387
+
324
388
  console.log(`\n${passed} passed, ${failed} failed`);
325
389
  process.exit(failed ? 1 : 0);