@bongos/core 1.19.1038 → 1.19.1039
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 +31 -16
- package/docs/module-api-changelog.md +3 -1
- package/docs/recipes/core-release-pipeline.md +1 -1
- package/docs/recipes/private-npm-distribution.md +1 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +10 -0
- package/scripts/gds/plain-cards.js +5 -0
- package/scripts/gds/publish-integration-gate.js +142 -0
- package/scripts/gds/ship-land.js +72 -6
- package/src/module-api.js +1 -1
- package/tests/publish_integration_gate.mjs +198 -0
- package/tests/ship_land_ci_not_started.mjs +168 -0
package/.bongos-core.json
CHANGED
|
@@ -2,22 +2,22 @@
|
|
|
2
2
|
"artifact": "bongos-core",
|
|
3
3
|
"manifest_schema": 1,
|
|
4
4
|
"generator": "scripts/gds/package-core.js",
|
|
5
|
-
"core_version": "1.19.
|
|
6
|
-
"core_contract": "1.19.
|
|
7
|
-
"source_commit": "
|
|
5
|
+
"core_version": "1.19.1039",
|
|
6
|
+
"core_contract": "1.19.1039",
|
|
7
|
+
"source_commit": "dc9e168d73f9ec605af32419b0b638cca7539418",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-09-26T08:
|
|
9
|
+
"built_at": "2026-09-26T08:16:22.165Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
12
|
"docs_redacted": 542,
|
|
13
13
|
"agent_docs_stubbed": 26,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 2496,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 3065,
|
|
20
|
+
"tree_sha256": "e4228541e27d69f8e2578edadb43181228ced5f21cc7b6dbe0102de50c3917be",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/ask-for-help/SKILL.md",
|
|
@@ -2767,7 +2767,7 @@
|
|
|
2767
2767
|
{
|
|
2768
2768
|
"path": "docs/module-api-changelog.md",
|
|
2769
2769
|
"mode": "0000644",
|
|
2770
|
-
"sha256": "
|
|
2770
|
+
"sha256": "3d636e0870231f27697b67900e7feedff8e82b751cdd9771509cfe792d7157d6"
|
|
2771
2771
|
},
|
|
2772
2772
|
{
|
|
2773
2773
|
"path": "docs/modules-contract.md",
|
|
@@ -2887,7 +2887,7 @@
|
|
|
2887
2887
|
{
|
|
2888
2888
|
"path": "docs/recipes/core-release-pipeline.md",
|
|
2889
2889
|
"mode": "0000644",
|
|
2890
|
-
"sha256": "
|
|
2890
|
+
"sha256": "4f006e07e58a861399d8d04ab0b52a1752f4ff605475288d60b60bffb7e123e7"
|
|
2891
2891
|
},
|
|
2892
2892
|
{
|
|
2893
2893
|
"path": "docs/recipes/failed-ship-recovery-proof.md",
|
|
@@ -2967,7 +2967,7 @@
|
|
|
2967
2967
|
{
|
|
2968
2968
|
"path": "docs/recipes/private-npm-distribution.md",
|
|
2969
2969
|
"mode": "0000644",
|
|
2970
|
-
"sha256": "
|
|
2970
|
+
"sha256": "0125fca7ef4c910f87a03e20ccfb06ae8a81cf5ebc8f9821e4bc3e8a8d0a7ea1"
|
|
2971
2971
|
},
|
|
2972
2972
|
{
|
|
2973
2973
|
"path": "docs/recipes/redteam-patrol.md",
|
|
@@ -8452,12 +8452,12 @@
|
|
|
8452
8452
|
{
|
|
8453
8453
|
"path": "package-lock.json",
|
|
8454
8454
|
"mode": "0000644",
|
|
8455
|
-
"sha256": "
|
|
8455
|
+
"sha256": "3a6d785e2391594909ba62c1e5dcf1c250096d802da67b1033353c8ced7bfac8"
|
|
8456
8456
|
},
|
|
8457
8457
|
{
|
|
8458
8458
|
"path": "package.json",
|
|
8459
8459
|
"mode": "0000644",
|
|
8460
|
-
"sha256": "
|
|
8460
|
+
"sha256": "9ef9e201b3c2d1326f37ecd30eb7ed0ebbb56f8360608128e4b04974798cf4d0"
|
|
8461
8461
|
},
|
|
8462
8462
|
{
|
|
8463
8463
|
"path": "public-docs/index.html",
|
|
@@ -8477,7 +8477,7 @@
|
|
|
8477
8477
|
{
|
|
8478
8478
|
"path": "release-notes.json",
|
|
8479
8479
|
"mode": "0000644",
|
|
8480
|
-
"sha256": "
|
|
8480
|
+
"sha256": "d39efdd82af8f39bf10ff6b145838d57dd6ab9eea277836ff1b7bd67a06f61ca"
|
|
8481
8481
|
},
|
|
8482
8482
|
{
|
|
8483
8483
|
"path": "scripts/bongos-mcp.js",
|
|
@@ -9382,7 +9382,7 @@
|
|
|
9382
9382
|
{
|
|
9383
9383
|
"path": "scripts/gds/plain-cards.js",
|
|
9384
9384
|
"mode": "0000644",
|
|
9385
|
-
"sha256": "
|
|
9385
|
+
"sha256": "b253a6c0e34cac3d18fb031b9a445d4db0589484da016309ecd7481a1ec1f311"
|
|
9386
9386
|
},
|
|
9387
9387
|
{
|
|
9388
9388
|
"path": "scripts/gds/portability-smoke-art.sh",
|
|
@@ -9479,6 +9479,11 @@
|
|
|
9479
9479
|
"mode": "0000644",
|
|
9480
9480
|
"sha256": "3fbf7c20f83e3de8b47f834414c2bec9ba35f528cfb93995db73d627c8d46e73"
|
|
9481
9481
|
},
|
|
9482
|
+
{
|
|
9483
|
+
"path": "scripts/gds/publish-integration-gate.js",
|
|
9484
|
+
"mode": "0000644",
|
|
9485
|
+
"sha256": "f797bfe1b72d0bb50587093ab93f32fd86b916f644dbf782cedf0b0e177b2b25"
|
|
9486
|
+
},
|
|
9482
9487
|
{
|
|
9483
9488
|
"path": "scripts/gds/publish-manifest.js",
|
|
9484
9489
|
"mode": "0000644",
|
|
@@ -9872,7 +9877,7 @@
|
|
|
9872
9877
|
{
|
|
9873
9878
|
"path": "scripts/gds/ship-land.js",
|
|
9874
9879
|
"mode": "0000644",
|
|
9875
|
-
"sha256": "
|
|
9880
|
+
"sha256": "699e58b44e1be60c9e17acf108f7cd895a75d43a2854dac494583df35f522dbc"
|
|
9876
9881
|
},
|
|
9877
9882
|
{
|
|
9878
9883
|
"path": "scripts/gds/ship-memory-sync.js",
|
|
@@ -10532,7 +10537,7 @@
|
|
|
10532
10537
|
{
|
|
10533
10538
|
"path": "src/module-api.js",
|
|
10534
10539
|
"mode": "0000644",
|
|
10535
|
-
"sha256": "
|
|
10540
|
+
"sha256": "ef1f931d2bcea8854e341858cc0b0d7d347ede63bcc77377c71695f6600852fa"
|
|
10536
10541
|
},
|
|
10537
10542
|
{
|
|
10538
10543
|
"path": "src/module-loader/catalog.js",
|
|
@@ -14064,6 +14069,11 @@
|
|
|
14064
14069
|
"mode": "0000644",
|
|
14065
14070
|
"sha256": "3e3b97d2c7e4e67a412eaa04bbf3d8fd4ad649ae46f9e194c8e07eedfc2349bb"
|
|
14066
14071
|
},
|
|
14072
|
+
{
|
|
14073
|
+
"path": "tests/publish_integration_gate.mjs",
|
|
14074
|
+
"mode": "0000644",
|
|
14075
|
+
"sha256": "f220032a0e9d58bfc07316f4192744294d2eef023677e602c23fc2c775e87390"
|
|
14076
|
+
},
|
|
14067
14077
|
{
|
|
14068
14078
|
"path": "tests/publish_manifest.mjs",
|
|
14069
14079
|
"mode": "0000644",
|
|
@@ -14619,6 +14629,11 @@
|
|
|
14619
14629
|
"mode": "0000644",
|
|
14620
14630
|
"sha256": "a3b7858c255f1c7ea692866dbadd7321f77bd88c8380c429b7cb5f6b1b65496b"
|
|
14621
14631
|
},
|
|
14632
|
+
{
|
|
14633
|
+
"path": "tests/ship_land_ci_not_started.mjs",
|
|
14634
|
+
"mode": "0000644",
|
|
14635
|
+
"sha256": "9d7902dfb2183713a06ecc845c4f466d3dc04fcf7d34a0f79cb2c6b6f89587af"
|
|
14636
|
+
},
|
|
14622
14637
|
{
|
|
14623
14638
|
"path": "tests/ship_land_conflict.mjs",
|
|
14624
14639
|
"mode": "0000644",
|
|
@@ -2554,6 +2554,8 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
2554
2554
|
1.19.1037 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2555
2555
|
landed since 1.19.1036 with no explicit bump. run 36227523642. (task 1002620)
|
|
2556
2556
|
1.19.1038 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2557
|
-
landed since 1.19.1037 with no explicit bump. run
|
|
2557
|
+
landed since 1.19.1037 with no explicit bump. run 36228854271. (task 1002620)
|
|
2558
|
+
1.19.1039 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2559
|
+
landed since 1.19.1038 with no explicit bump. run 36229255566. (task 1002620)
|
|
2558
2560
|
---------------------------------------------------------------------------
|
|
2559
2561
|
```
|
|
@@ -99,7 +99,7 @@ node scripts/gds/package-core.js --ref main --self-verify
|
|
|
99
99
|
|
|
100
100
|
### 2c. Publish — automatic on merge
|
|
101
101
|
|
|
102
|
-
**Every merge to `main` publishes** ([ADR 0161](../adr/0161-publish-on-merge.md), superseding ADR 0137's manual gate): once the required `unit` check succeeds on a main push, the `publish` workflow auto-bumps a **patch** if nobody bumped (via `scripts/gds/bump-version.js`, which writes the literal AND appends the ledger entry to [`docs/module-api-changelog.md`](../module-api-changelog.md) — ADR 0203; the carrier commit stages both), builds through the fail-closed no-leak gate with `--self-verify`, publishes, and tags `core-v<version>`. A **minor/major is still a human in-task bump** — CI never makes that call, because it decides what patch-channel subscribers receive unattended.
|
|
102
|
+
**Every merge to `main` publishes** ([ADR 0161](../adr/0161-publish-on-merge.md), superseding ADR 0137's manual gate): once the required `unit` check succeeds on a main push — and the `integration` lane is green on the commit being released, which carries the packaged-core empty-database proof (task 1002863; the wait is task 1004290) — the `publish` workflow auto-bumps a **patch** if nobody bumped (via `scripts/gds/bump-version.js`, which writes the literal AND appends the ledger entry to [`docs/module-api-changelog.md`](../module-api-changelog.md) — ADR 0203; the carrier commit stages both), builds through the fail-closed no-leak gate with `--self-verify`, publishes, and tags `core-v<version>`. A **minor/major is still a human in-task bump** — CI never makes that call, because it decides what patch-channel subscribers receive unattended.
|
|
103
103
|
|
|
104
104
|
The lane is armed by the owner via the `PUBLISH_ON_MERGE=1` repo variable + two secrets (`NPM_TOKEN`, `RELEASE_PUSH_TOKEN`); while disarmed it no-ops loudly. Manual fallback, same command CI runs:
|
|
105
105
|
|
|
@@ -55,7 +55,7 @@ npm publish ./clients/bongos-client # real (publishConfig makes it
|
|
|
55
55
|
|
|
56
56
|
## Cutting a core release (the policy — [ADR 0161](../adr/0161-publish-on-merge.md), superseding ADR 0137)
|
|
57
57
|
|
|
58
|
-
**Publishing is automatic on merge** as of 2026-08-08: the `publish` workflow rides behind the required `unit` check, auto-bumps a patch when nobody bumped, builds through the fail-closed no-leak gate, publishes, and tags — see the workflow header (`.github/workflows/publish.yml`) and ADR 0161. It ships inert until the owner arms it: repo variable `PUBLISH_ON_MERGE=1`, a **trusted publisher** registered on npmjs.com for `@bongos/core` naming this repo + `publish.yml`, and the secret `RELEASE_PUSH_TOKEN` (contents:write, for the bump commit). There is deliberately **no** `NPM_TOKEN` — the lane authenticates by OIDC, and the workflow sets neither `NPM_TOKEN` nor `NODE_AUTH_TOKEN` because either one would send npm down the legacy token path and skip the OIDC exchange entirely. A **minor/major bump stays a human in-task edit**. The update subscription ([ADR 0136](../adr/0136-update-channel-subscription-policy.md)) consumes whatever is published, on its next sweep.
|
|
58
|
+
**Publishing is automatic on merge** as of 2026-08-08: the `publish` workflow rides behind the required `unit` check and waits for the `integration` lane's verdict on the commit it releases (task 1004290), auto-bumps a patch when nobody bumped, builds through the fail-closed no-leak gate, publishes, and tags — see the workflow header (`.github/workflows/publish.yml`) and ADR 0161. It ships inert until the owner arms it: repo variable `PUBLISH_ON_MERGE=1`, a **trusted publisher** registered on npmjs.com for `@bongos/core` naming this repo + `publish.yml`, and the secret `RELEASE_PUSH_TOKEN` (contents:write, for the bump commit). There is deliberately **no** `NPM_TOKEN` — the lane authenticates by OIDC, and the workflow sets neither `NPM_TOKEN` nor `NODE_AUTH_TOKEN` because either one would send npm down the legacy token path and skip the OIDC exchange entirely. A **minor/major bump stays a human in-task edit**. The update subscription ([ADR 0136](../adr/0136-update-channel-subscription-policy.md)) consumes whatever is published, on its next sweep.
|
|
59
59
|
|
|
60
60
|
The steps below remain as the **manual fallback** (lane disarmed, npm outage, or a deliberate out-of-band release):
|
|
61
61
|
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.1039",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.1039",
|
|
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.19.
|
|
3
|
+
"version": "1.19.1039",
|
|
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
|
@@ -7509,9 +7509,19 @@
|
|
|
7509
7509
|
}
|
|
7510
7510
|
],
|
|
7511
7511
|
"1.19.1038": [
|
|
7512
|
+
{
|
|
7513
|
+
"id": "1004289",
|
|
7514
|
+
"text": "When the code host refuses to run the automatic tests at all, for example because the account's allowance ran out, the shipping tool now says so plainly and names the owner's fix, instead of telling the builder to fix code tha"
|
|
7515
|
+
},
|
|
7512
7516
|
{
|
|
7513
7517
|
"id": "1002840",
|
|
7514
7518
|
"text": "The grader can now call AI models from other companies through one plug-in point, ready for a second-opinion reviewer; nothing changes today — it still uses only Claude and spends nothing new."
|
|
7515
7519
|
}
|
|
7520
|
+
],
|
|
7521
|
+
"1.19.1039": [
|
|
7522
|
+
{
|
|
7523
|
+
"id": "1004290",
|
|
7524
|
+
"text": "A new release of the core is now published only after the check that it can set up a brand-new empty database has passed, so a release that cannot install cleanly can no longer reach projects."
|
|
7525
|
+
}
|
|
7516
7526
|
]
|
|
7517
7527
|
}
|
|
@@ -169,6 +169,11 @@ const CARDS = Object.freeze({
|
|
|
169
169
|
'An automatic test failed on your work, so it has not been added to the project.',
|
|
170
170
|
'I will run that test here, fix what it found, and send the work again. Sending the same work again would fail the same way.',
|
|
171
171
|
null, FAULT),
|
|
172
|
+
// task 1004289: the tests never ran at all (the account cannot run them right now).
|
|
173
|
+
ci_not_started: card(
|
|
174
|
+
'The automatic tests never ran on your work: the code host did not start them, because the account has run out of test time or has a payment problem. Nothing was tested, so nothing needs fixing.',
|
|
175
|
+
'I will not change the work. Once the owner restores the test time, I will start the tests again on the same work and it will be added on its own.',
|
|
176
|
+
'The project owner, to restore the account\'s test time on the code host.', FAULT),
|
|
172
177
|
non_required_check_failing: card(
|
|
173
178
|
'An optional automatic test failed on your work.',
|
|
174
179
|
'I will look at what it found. It does not stop the work being added, but it is usually worth fixing.',
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// scripts/gds/publish-integration-gate.js — the publish lane waits for the integration
|
|
3
|
+
// lane's verdict on the exact commit it is about to release (task 1004290).
|
|
4
|
+
//
|
|
5
|
+
// WHY. publish.yml fires on the `unit` workflow's success, and `unit` is DB-free by
|
|
6
|
+
// design. The proof that the packaged core can set up an EMPTY database and serve a
|
|
7
|
+
// DB-backed route — tests/consumer_layout_boot.mjs, task 1002863 — runs in the
|
|
8
|
+
// `integration` workflow, on the same main push. Without this gate that result arrived
|
|
9
|
+
// after the publish and gated nothing: a core that installs and boots but cannot migrate
|
|
10
|
+
// its own database still reached npm, and from there the nightly tenant upgrade sweep.
|
|
11
|
+
//
|
|
12
|
+
// WHAT IT COSTS, MEASURED 2026-09-26 (not assumed). Both lanes start on the same push, and
|
|
13
|
+
// integration is the SHORTER one: on the two commits right after task 1002863 landed,
|
|
14
|
+
// integration took 247s and 245s against unit's 351s and 335s. So when unit's success fires
|
|
15
|
+
// the publish, integration has usually already answered and this gate is a single API
|
|
16
|
+
// read. It runs inside the existing publish job — no new job — and it polls only in the
|
|
17
|
+
// rare case integration is still going. Over 299 main pushes (2026-09-20 → 26) integration
|
|
18
|
+
// was green 254 times, cancelled 41 times (a newer merge superseded it) and red 3 times,
|
|
19
|
+
// all three the Actions-budget outage of 2026-09-25 — so it is not a flaky lane that would
|
|
20
|
+
// stall every release.
|
|
21
|
+
//
|
|
22
|
+
// THE VERDICTS (decideIntegrationGate, pure):
|
|
23
|
+
// go the newest push-event run on this commit succeeded → publish
|
|
24
|
+
// wait no run yet, or it is still going → poll
|
|
25
|
+
// superseded it was cancelled — almost always a newer merge's run replacing it under
|
|
26
|
+
// integration.yml's cancel-in-progress group. Nothing is published; that
|
|
27
|
+
// newer merge fires its own publish, which asks about its own commit.
|
|
28
|
+
// red anything else (failure, timed_out, startup_failure …) → refuse, loudly
|
|
29
|
+
// Only PUSH-event runs count: the weekly scheduled run on main carries HEAD's sha but runs
|
|
30
|
+
// only the parked-set ratchet, so its conclusion says nothing about this commit.
|
|
31
|
+
//
|
|
32
|
+
// OUTPUT: `go=true|false` to $GITHUB_OUTPUT. Exit 0 for go and superseded, 1 for red,
|
|
33
|
+
// timeout or an unreadable API (a refusal that must be seen), 2 for bad arguments.
|
|
34
|
+
//
|
|
35
|
+
// node scripts/gds/publish-integration-gate.js --sha <40-hex> --repo <owner/name>
|
|
36
|
+
// [--timeout-min 12] [--interval-s 20] token: GH_TOKEN or GITHUB_TOKEN
|
|
37
|
+
|
|
38
|
+
'use strict';
|
|
39
|
+
|
|
40
|
+
const fs = require('node:fs');
|
|
41
|
+
|
|
42
|
+
const WORKFLOW_FILE = 'integration.yml';
|
|
43
|
+
const DEFAULT_TIMEOUT_MIN = 12; // inside publish.yml's 20-minute job, leaving room to pack + publish
|
|
44
|
+
const DEFAULT_INTERVAL_S = 20;
|
|
45
|
+
const SHA_RE = /^[0-9a-f]{40}$/;
|
|
46
|
+
const REPO_RE = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
|
|
47
|
+
|
|
48
|
+
// decideIntegrationGate — PURE. `runs` is list-workflow-runs' `workflow_runs` for one
|
|
49
|
+
// head sha and the push event. The newest run decides (a re-push of the same sha, or a
|
|
50
|
+
// re-run, supersedes an older verdict).
|
|
51
|
+
function decideIntegrationGate(runs) {
|
|
52
|
+
const list = Array.isArray(runs) ? runs.filter((r) => r && typeof r === 'object') : [];
|
|
53
|
+
if (!list.length) return { verdict: 'wait', run: null };
|
|
54
|
+
const newest = list.slice().sort((a, b) =>
|
|
55
|
+
(Number(b.run_number) || 0) - (Number(a.run_number) || 0)
|
|
56
|
+
|| String(b.created_at || '').localeCompare(String(a.created_at || '')))[0];
|
|
57
|
+
if (newest.status !== 'completed') return { verdict: 'wait', run: newest };
|
|
58
|
+
if (newest.conclusion === 'success') return { verdict: 'go', run: newest };
|
|
59
|
+
if (newest.conclusion === 'cancelled') return { verdict: 'superseded', run: newest };
|
|
60
|
+
return { verdict: 'red', run: newest };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function runsUrl(repo, sha) {
|
|
64
|
+
return `https://api.github.com/repos/${repo}/actions/workflows/${WORKFLOW_FILE}/runs?head_sha=${sha}&event=push&per_page=20`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// waitForIntegration — poll until the gate has a verdict or the time is up. A 401/403/404
|
|
68
|
+
// is not something waiting fixes (a missing `actions: read`, a renamed workflow), so it
|
|
69
|
+
// ends the wait at once as 'unreadable'. Any other failed read is retried. Everything that
|
|
70
|
+
// touches the world is injectable for tests/publish_integration_gate.mjs.
|
|
71
|
+
async function waitForIntegration({
|
|
72
|
+
sha, repo, token, timeoutMs, intervalMs,
|
|
73
|
+
fetchImpl = globalThis.fetch, sleep = (ms) => new Promise((r) => setTimeout(r, ms)), now = Date.now,
|
|
74
|
+
}) {
|
|
75
|
+
const deadline = now() + timeoutMs;
|
|
76
|
+
let last = { verdict: 'wait', run: null, why: 'no read yet' };
|
|
77
|
+
for (;;) {
|
|
78
|
+
try {
|
|
79
|
+
const res = await fetchImpl(runsUrl(repo, sha), {
|
|
80
|
+
headers: { Authorization: `Bearer ${token}`, Accept: 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28' },
|
|
81
|
+
});
|
|
82
|
+
if ([401, 403, 404].includes(res.status)) return { verdict: 'unreadable', run: null, why: `HTTP ${res.status}` };
|
|
83
|
+
if (res.ok) {
|
|
84
|
+
const body = await res.json();
|
|
85
|
+
last = { ...decideIntegrationGate(body && body.workflow_runs), why: null };
|
|
86
|
+
if (last.verdict !== 'wait') return last;
|
|
87
|
+
} else {
|
|
88
|
+
last = { ...last, why: `HTTP ${res.status}` };
|
|
89
|
+
}
|
|
90
|
+
} catch (e) {
|
|
91
|
+
last = { ...last, why: (e && e.message) || String(e) };
|
|
92
|
+
}
|
|
93
|
+
if (now() + intervalMs > deadline) return { verdict: 'timeout', run: last.run, why: last.why };
|
|
94
|
+
await sleep(intervalMs);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// The one line per outcome the Actions log shows, and what the rest of the lane reads.
|
|
99
|
+
function reportLines(verdict, { sha, run, why, timeoutMin }) {
|
|
100
|
+
const short = String(sha).slice(0, 8);
|
|
101
|
+
const url = run && run.html_url ? ` ${run.html_url}` : '';
|
|
102
|
+
switch (verdict) {
|
|
103
|
+
case 'go':
|
|
104
|
+
return { go: true, code: 0, line: `integration is green on ${short}${url} — publishing.` };
|
|
105
|
+
case 'superseded':
|
|
106
|
+
return { go: false, code: 0, line: `::notice title=not published — integration was cancelled on ${short}::Usually a newer merge's run replaced it; that merge fires its own publish, which checks its own commit.${url}` };
|
|
107
|
+
case 'red':
|
|
108
|
+
return { go: false, code: 1, line: `::error title=not published — integration is red on ${short}::The integration lane (${run && run.conclusion}) runs the proof that the packaged core can migrate an empty database (task 1002863). A core that fails it must not reach npm. Fix main; the next merge publishes everything.${url}` };
|
|
109
|
+
case 'unreadable':
|
|
110
|
+
return { go: false, code: 1, line: `::error title=not published — cannot read the integration lane::${why}. publish.yml needs 'permissions: actions: read', and the workflow file must still be ${WORKFLOW_FILE}.` };
|
|
111
|
+
default:
|
|
112
|
+
return { go: false, code: 1, line: `::error title=not published — integration had no verdict on ${short} after ${timeoutMin} min::${why ? `Last read: ${why}. ` : ''}Re-run this workflow once integration finishes, or let the next merge publish.${url}` };
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function argValue(argv, flag) {
|
|
117
|
+
const i = argv.indexOf(flag);
|
|
118
|
+
return i >= 0 && i + 1 < argv.length ? argv[i + 1] : null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function main(argv = process.argv.slice(2), env = process.env) {
|
|
122
|
+
const sha = String(argValue(argv, '--sha') || '').toLowerCase();
|
|
123
|
+
const repo = String(argValue(argv, '--repo') || env.GITHUB_REPOSITORY || '');
|
|
124
|
+
const token = env.GH_TOKEN || env.GITHUB_TOKEN || '';
|
|
125
|
+
const timeoutMin = Number(argValue(argv, '--timeout-min') || DEFAULT_TIMEOUT_MIN);
|
|
126
|
+
const intervalS = Number(argValue(argv, '--interval-s') || DEFAULT_INTERVAL_S);
|
|
127
|
+
if (!SHA_RE.test(sha) || !REPO_RE.test(repo) || !token || !(timeoutMin > 0) || !(intervalS > 0)) {
|
|
128
|
+
console.error('usage: publish-integration-gate.js --sha <40-hex> --repo <owner/name> [--timeout-min N] [--interval-s N] (token: GH_TOKEN or GITHUB_TOKEN)');
|
|
129
|
+
return 2;
|
|
130
|
+
}
|
|
131
|
+
const result = await waitForIntegration({ sha, repo, token, timeoutMs: timeoutMin * 60000, intervalMs: intervalS * 1000 });
|
|
132
|
+
const out = reportLines(result.verdict, { sha, run: result.run, why: result.why, timeoutMin });
|
|
133
|
+
console.log(out.line);
|
|
134
|
+
if (env.GITHUB_OUTPUT) fs.appendFileSync(env.GITHUB_OUTPUT, `go=${out.go}\n`);
|
|
135
|
+
return out.code;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (require.main === module) {
|
|
139
|
+
main().then((code) => { process.exitCode = code; }, (e) => { console.error(e); process.exitCode = 1; });
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
module.exports = { decideIntegrationGate, waitForIntegration, reportLines, main, WORKFLOW_FILE, runsUrl };
|
package/scripts/gds/ship-land.js
CHANGED
|
@@ -291,7 +291,17 @@ function strandNextStep(taskId, via) {
|
|
|
291
291
|
// task 1003764: `red` — the PR was RED on the poll's last read. "NOT a failure" is then
|
|
292
292
|
// false: a required check that failed does not pass by waiting, so the banner says the
|
|
293
293
|
// land is stuck on it and defers to the red lines above for the remedy.
|
|
294
|
-
function landPendingLines(taskId, { merged, red } = {}) {
|
|
294
|
+
function landPendingLines(taskId, { merged, red, notStarted } = {}) {
|
|
295
|
+
// task 1004289: CI never RAN (the account cannot run Actions). Not a failure of the
|
|
296
|
+
// work, and not something waiting fixes either: the checks have to be started again.
|
|
297
|
+
if (notStarted) {
|
|
298
|
+
return [
|
|
299
|
+
' ci-land: Published, but CI never started on the PR (GitHub ran none of its checks), so it has NOT merged',
|
|
300
|
+
' and cannot until they run. The task stays at \'confirmed\'. Nothing in the work needs to change: once the',
|
|
301
|
+
` owner restores GitHub Actions, re-run the failed jobs (lines above), then re-run \`node scripts/gds/ship.js ${taskId}\``,
|
|
302
|
+
' to watch the land — it resumes from here (task 941).',
|
|
303
|
+
];
|
|
304
|
+
}
|
|
295
305
|
if (red) {
|
|
296
306
|
return [
|
|
297
307
|
' ci-land: Published, but the PR is RED (the check named above), so it has NOT merged and will not',
|
|
@@ -314,8 +324,9 @@ function printLandPending(taskId, opts = {}) {
|
|
|
314
324
|
console.log('');
|
|
315
325
|
for (const line of landPendingLines(taskId, opts)) console.log(line);
|
|
316
326
|
// The land_pending card says there is nothing to do, which a red PR makes untrue; the
|
|
317
|
-
// red line already printed the failing_check card (task 1003764)
|
|
318
|
-
|
|
327
|
+
// red line already printed the failing_check card (task 1003764), or the
|
|
328
|
+
// ci_not_started one (task 1004289).
|
|
329
|
+
if (!opts.red && !opts.notStarted) for (const line of plainCardLines('land_pending')) console.log(line);
|
|
319
330
|
}
|
|
320
331
|
|
|
321
332
|
// task 1002645 — the honest close-out when the PR MERGED but the server refused the
|
|
@@ -351,6 +362,11 @@ function prAssigneeLine(login) {
|
|
|
351
362
|
// exported so the wording is pinned by tests/unit_report_status.mjs.
|
|
352
363
|
function redPrLine(md) {
|
|
353
364
|
const reason = (md && md.reason) || '';
|
|
365
|
+
if (md && md.not_started) {
|
|
366
|
+
const job = (/^ci_not_started:(.+)$/.exec(reason) || [])[1];
|
|
367
|
+
return ` ci-land: CI NEVER RAN on this PR — GitHub did not start ${job ? `the \`${job}\` job` : 'its checks'} ("The job was not started because …", `
|
|
368
|
+
+ 'an Actions budget or billing stop on the account). Nothing in the work was tested, so there is nothing in it to fix.';
|
|
369
|
+
}
|
|
354
370
|
const fix = 'fix it, commit, and re-run the ship — re-pushing unchanged work cannot turn it green.';
|
|
355
371
|
if (md && md.failing_tests) {
|
|
356
372
|
return ` ci-land: the PR is RED — ${md.failing_tests}. Reproduce with \`node tests/<name>.mjs\`, ${fix}`;
|
|
@@ -369,9 +385,21 @@ function redPrLine(md) {
|
|
|
369
385
|
// task 1003764: the merge trap sits between them. `opts` = { taskId, via } for its wording.
|
|
370
386
|
function redPrLines(md, opts = {}) {
|
|
371
387
|
const red = redPrLine(md);
|
|
388
|
+
if (red && md.not_started) return [red, ...notStartedLines(md), ...plainCardLines('ci_not_started')];
|
|
372
389
|
return red ? [red, ...mergeTrapLines(md, opts), ...plainCardLines((md && md.reason) || 'failing_check')] : [];
|
|
373
390
|
}
|
|
374
391
|
|
|
392
|
+
// notStartedLines — task 1004289. What to do when CI never ran, instead of the merge trap
|
|
393
|
+
// (there is no failure to reproduce). Re-running is the remedy, not re-pushing: the same
|
|
394
|
+
// commit is fine, it simply was never tested. Pure + exported.
|
|
395
|
+
function notStartedLines(md) {
|
|
396
|
+
const rerun = md && /^\d+$/.test(String(md.run_id || '')) ? `\`gh run rerun ${md.run_id} --failed\`` : '"Re-run failed jobs" on the PR';
|
|
397
|
+
return [
|
|
398
|
+
' ci-land: this is the repo OWNER\'s to fix, on GitHub: the account\'s Actions budget / spending limit or payment method.',
|
|
399
|
+
` ci-land: once Actions can run again, ${rerun} starts the checks on the SAME commit — do not re-push or edit the work.`,
|
|
400
|
+
];
|
|
401
|
+
}
|
|
402
|
+
|
|
375
403
|
// The unit tests that gate GENERATED files — each compares a committed artifact with a
|
|
376
404
|
// fresh build of it. `fitness` holds the combined gate (fitness.js "generated artifacts
|
|
377
405
|
// fresh": copy registry, API docs, file map, nested CLAUDE.md blocks, the §13 snippet).
|
|
@@ -439,16 +467,42 @@ const RED_CONCLUSIONS = new Set(['FAILURE', 'ERROR', 'TIMED_OUT', 'CANCELLED', '
|
|
|
439
467
|
// poll paths hand redPrLines the same thing. `failingTestsFor(sha)` reads the unit-report
|
|
440
468
|
// status (gh pr view omits a status's description) and is asked only once that status is
|
|
441
469
|
// itself red, i.e. posted. Null when nothing is red. Pure + exported.
|
|
442
|
-
|
|
470
|
+
// task 1004289 — `neverStarted(rawCheck)` answers "did this red job never run at all?"
|
|
471
|
+
// (jobNeverStarted below). When EVERY red check is one, nothing was tested, and the
|
|
472
|
+
// answer is { reason: 'ci_not_started:<name>', not_started: true, run_id } rather than
|
|
473
|
+
// a failing check the builder is told to fix.
|
|
474
|
+
function localMergeDriver(pr, failingTestsFor = () => null, neverStarted = () => false) {
|
|
443
475
|
if (!pr || pr.state !== 'OPEN' || !Array.isArray(pr.statusCheckRollup)) return null;
|
|
444
476
|
const { checks } = readCheckRollup({ contexts: { nodes: pr.statusCheckRollup } });
|
|
445
477
|
const red = checks.filter((c) => RED_CONCLUSIONS.has(String(c.conclusion || '').toUpperCase()));
|
|
446
478
|
if (!red.length) return null;
|
|
479
|
+
const redRaw = pr.statusCheckRollup.filter((c) => c && RED_CONCLUSIONS.has(String(c.conclusion || c.state || '').toUpperCase()));
|
|
480
|
+
if (redRaw.length && redRaw.every((c) => neverStarted(c))) {
|
|
481
|
+
const run = /\/runs\/(\d+)\//.exec(String(redRaw[0].detailsUrl || ''));
|
|
482
|
+
return { reason: `ci_not_started:${red[0].name}`, not_started: true, failing_tests: null, run_id: run ? run[1] : null };
|
|
483
|
+
}
|
|
447
484
|
const kind = pr.mergeStateStatus === 'UNSTABLE' ? 'non_required_check_failing' : 'failing_check';
|
|
448
485
|
const reported = red.some((c) => c.name === UNIT_REPORT_CONTEXT);
|
|
449
486
|
return { reason: `${kind}:${red[0].name}`, failing_tests: (reported && failingTestsFor(pr.headRefOid)) || null };
|
|
450
487
|
}
|
|
451
488
|
|
|
489
|
+
// jobNeverStarted — task 1004289. When the account cannot run Actions (its budget is
|
|
490
|
+
// spent, or a payment failed), GitHub still marks every job FAILED seconds after it was
|
|
491
|
+
// queued, with no steps and no runner, and one annotation saying why: "The job was not
|
|
492
|
+
// started because an Actions budget is preventing further use." (PR 1211, 2026-09-25).
|
|
493
|
+
// Read as an ordinary red check, that told the builder to fix their code — advice that
|
|
494
|
+
// cannot help, where each retry is another failed run. An Actions job's id (the tail of
|
|
495
|
+
// its detailsUrl) is its check-run id, so its annotations are one read away. FAIL-OPEN:
|
|
496
|
+
// anything unreadable is "no", which keeps the ordinary red wording. `gh` is injectable.
|
|
497
|
+
const NOT_STARTED_RE = /^\s*The job was not started because\b/i;
|
|
498
|
+
function jobNeverStarted(check, gh = ghJson) {
|
|
499
|
+
if (!check || (check.__typename && check.__typename !== 'CheckRun')) return false;
|
|
500
|
+
const m = /\/job\/(\d+)(?:[/?#]|$)/.exec(String(check.detailsUrl || ''));
|
|
501
|
+
if (!m) return false;
|
|
502
|
+
const notes = gh(['api', `repos/{owner}/{repo}/check-runs/${m[1]}/annotations`]);
|
|
503
|
+
return Array.isArray(notes) && notes.some((a) => a && NOT_STARTED_RE.test(String(a.message || '')));
|
|
504
|
+
}
|
|
505
|
+
|
|
452
506
|
// unitReportFor — the failing-test line the `unit` workflow posted on `sha`, read with this
|
|
453
507
|
// machine's gh through the same parser the server uses. Null when unreadable; the red line
|
|
454
508
|
// then names the check instead, as it did before the report existed. `gh` is injectable.
|
|
@@ -921,6 +975,14 @@ function pollCiDeploy(taskId, branch, headSha, deps = {}) {
|
|
|
921
975
|
if (sha !== report.sha) report = { sha, text: unitReportFor(sha, readJson) };
|
|
922
976
|
return report.text;
|
|
923
977
|
};
|
|
978
|
+
// task 1004289: a finished job's annotations never change, so each is read once.
|
|
979
|
+
let notStarted = false;
|
|
980
|
+
const startMemo = new Map();
|
|
981
|
+
const neverStarted = (c) => {
|
|
982
|
+
const key = String((c && c.detailsUrl) || '');
|
|
983
|
+
if (!startMemo.has(key)) startMemo.set(key, jobNeverStarted(c, readJson));
|
|
984
|
+
return startMemo.get(key);
|
|
985
|
+
};
|
|
924
986
|
for (let i = 0; i < MERGE_TRIES; i++) {
|
|
925
987
|
// task 1003499: mergeable + mergeStateStatus ride the same read, so a conflict is
|
|
926
988
|
// named on the poll it appears; the two-field read is the fallback for a gh that
|
|
@@ -937,13 +999,14 @@ function pollCiDeploy(taskId, branch, headSha, deps = {}) {
|
|
|
937
999
|
return landBailed('the PR was closed without merging (a check failed, or it was closed manually)', `node scripts/gds/ship.js ${taskId}`);
|
|
938
1000
|
}
|
|
939
1001
|
if (pr) {
|
|
940
|
-
const md = localMergeDriver(pr, failingTestsFor);
|
|
1002
|
+
const md = localMergeDriver(pr, failingTestsFor, neverStarted);
|
|
941
1003
|
const red = redPrLine(md);
|
|
942
1004
|
if (red && red !== redLine) {
|
|
943
1005
|
redLine = red;
|
|
944
1006
|
for (const line of redPrLines(md, { taskId, via: 'local' })) console.log(line);
|
|
945
1007
|
}
|
|
946
1008
|
stuckRed = !!red && redBlocksMerge(md);
|
|
1009
|
+
notStarted = !!(md && md.not_started);
|
|
947
1010
|
}
|
|
948
1011
|
conflicting = noteConflict(taskId, pr, conflicting);
|
|
949
1012
|
pause();
|
|
@@ -954,7 +1017,7 @@ function pollCiDeploy(taskId, branch, headSha, deps = {}) {
|
|
|
954
1017
|
// CONFLICTING (task 1003499): then nothing is still in CI, and it never merges.
|
|
955
1018
|
// Or RED (task 1003764): then the banner says the land is stuck on that check.
|
|
956
1019
|
if (conflicting) return landConflicted(taskId);
|
|
957
|
-
printLandPending(taskId, { red: stuckRed });
|
|
1020
|
+
printLandPending(taskId, { red: stuckRed, notStarted });
|
|
958
1021
|
return landPending();
|
|
959
1022
|
}
|
|
960
1023
|
console.log(` ci-land: merged to main (${mergeSha.slice(0, 8)}). Waiting for the prod deploy...`);
|
|
@@ -1036,6 +1099,9 @@ module.exports = {
|
|
|
1036
1099
|
redBlocksMerge,
|
|
1037
1100
|
localMergeDriver,
|
|
1038
1101
|
unitReportFor,
|
|
1102
|
+
// task 1004289: CI that never ran is said as such, not as a failure to fix.
|
|
1103
|
+
jobNeverStarted,
|
|
1104
|
+
notStartedLines,
|
|
1039
1105
|
reconciledCardStatus,
|
|
1040
1106
|
strandCommand,
|
|
1041
1107
|
strandNextStep,
|
package/src/module-api.js
CHANGED
|
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
71
71
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
72
72
|
// the entry to that file. Look for a version's history there, not here.
|
|
73
73
|
// ---------------------------------------------------------------------------
|
|
74
|
-
const CORE_VERSION = '1.19.
|
|
74
|
+
const CORE_VERSION = '1.19.1039'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
75
75
|
|
|
76
76
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
77
77
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
// tests/publish_integration_gate.mjs — npm publishing waits for the integration lane's
|
|
2
|
+
// verdict on the commit it releases (task 1004290).
|
|
3
|
+
//
|
|
4
|
+
// WHY. publish.yml fires on `unit`, which is DB-free by design. The proof that the
|
|
5
|
+
// PACKAGED core can migrate an empty database (tests/consumer_layout_boot.mjs, task
|
|
6
|
+
// 1002863) runs in integration.yml, and until this gate its verdict arrived after the
|
|
7
|
+
// publish and gated nothing. What is pinned here:
|
|
8
|
+
// 1. the verdicts, from the real list-workflow-runs payload shape
|
|
9
|
+
// 2. the wait: polls while running, stops at once on an unreadable API, times out loudly
|
|
10
|
+
// 3. the lane is WIRED: actions:read, the step sits before any install/pack/publish, and
|
|
11
|
+
// every later armed step carries the integration go — so a new step cannot slip past
|
|
12
|
+
//
|
|
13
|
+
// Network-free: fetch, sleep and the clock are injected.
|
|
14
|
+
//
|
|
15
|
+
// Run: node tests/publish_integration_gate.mjs
|
|
16
|
+
|
|
17
|
+
import { strict as assert } from 'node:assert';
|
|
18
|
+
import { createRequire } from 'node:module';
|
|
19
|
+
import fs from 'node:fs';
|
|
20
|
+
import os from 'node:os';
|
|
21
|
+
import path from 'node:path';
|
|
22
|
+
|
|
23
|
+
const require = createRequire(import.meta.url);
|
|
24
|
+
const gate = require('../scripts/gds/publish-integration-gate.js');
|
|
25
|
+
const { decideIntegrationGate, waitForIntegration, reportLines, main, runsUrl } = gate;
|
|
26
|
+
|
|
27
|
+
let passed = 0;
|
|
28
|
+
let failed = 0;
|
|
29
|
+
async function test(name, fn) {
|
|
30
|
+
try { await fn(); passed++; console.log(` ok ${name}`); }
|
|
31
|
+
catch (err) { failed++; console.log(` FAIL ${name}`); console.log(` ${err.stack || err.message}`); }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const SHA = '078e8b7b'.padEnd(40, '0');
|
|
35
|
+
// The fields GitHub's list-workflow-runs answers with that the gate reads (from a real
|
|
36
|
+
// integration run on main, 2026-09-26: run 36210...; trimmed to what matters).
|
|
37
|
+
const ghRun = (run_number, status, conclusion) => ({
|
|
38
|
+
id: 36210000000 + run_number, run_number, event: 'push', head_sha: SHA, status, conclusion,
|
|
39
|
+
created_at: `2026-09-26T03:${String(run_number % 60).padStart(2, '0')}:00Z`,
|
|
40
|
+
html_url: `https://github.com/example-org/example-repo/actions/runs/${36210000000 + run_number}`,
|
|
41
|
+
});
|
|
42
|
+
const okJson = (body) => ({ ok: true, status: 200, json: async () => body });
|
|
43
|
+
|
|
44
|
+
// ---- 1. the verdicts ----
|
|
45
|
+
|
|
46
|
+
await test('decideIntegrationGate: success → go, running or absent → wait, cancelled → superseded, anything else → red', async () => {
|
|
47
|
+
assert.equal(decideIntegrationGate([ghRun(1, 'completed', 'success')]).verdict, 'go');
|
|
48
|
+
assert.equal(decideIntegrationGate([]).verdict, 'wait', 'the run has not been created yet');
|
|
49
|
+
assert.equal(decideIntegrationGate(undefined).verdict, 'wait');
|
|
50
|
+
for (const status of ['queued', 'in_progress', 'waiting', 'pending']) {
|
|
51
|
+
assert.equal(decideIntegrationGate([ghRun(1, status, null)]).verdict, 'wait', status);
|
|
52
|
+
}
|
|
53
|
+
assert.equal(decideIntegrationGate([ghRun(1, 'completed', 'cancelled')]).verdict, 'superseded');
|
|
54
|
+
for (const c of ['failure', 'timed_out', 'startup_failure', 'action_required', 'skipped', 'neutral', 'stale', null]) {
|
|
55
|
+
assert.equal(decideIntegrationGate([ghRun(1, 'completed', c)]).verdict, 'red', `fail-closed on ${c}`);
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
await test('decideIntegrationGate: the NEWEST run on the sha decides, whatever order the API lists them in', async () => {
|
|
60
|
+
assert.equal(decideIntegrationGate([ghRun(5, 'completed', 'failure'), ghRun(7, 'completed', 'success')]).verdict, 'go');
|
|
61
|
+
assert.equal(decideIntegrationGate([ghRun(7, 'completed', 'failure'), ghRun(5, 'completed', 'success')]).verdict, 'red');
|
|
62
|
+
assert.equal(decideIntegrationGate([ghRun(8, 'in_progress', null), ghRun(7, 'completed', 'success')]).verdict, 'wait');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
await test('only PUSH runs are asked for: the weekly scheduled run carries HEAD\'s sha but tests only the parked set', async () => {
|
|
66
|
+
const u = runsUrl('example-org/example-repo', SHA);
|
|
67
|
+
assert.match(u, /\/actions\/workflows\/integration\.yml\/runs\?/);
|
|
68
|
+
assert.match(u, new RegExp(`head_sha=${SHA}`));
|
|
69
|
+
assert.match(u, /event=push/);
|
|
70
|
+
assert.ok(fs.existsSync(new URL('../.github/workflows/integration.yml', import.meta.url)), 'the workflow the gate names exists');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// ---- 2. the wait ----
|
|
74
|
+
|
|
75
|
+
function clock() {
|
|
76
|
+
let t = 0;
|
|
77
|
+
return { now: () => t, sleep: async (ms) => { t += ms; } };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
await test('waitForIntegration: polls while integration runs, then publishes on green', async () => {
|
|
81
|
+
const c = clock();
|
|
82
|
+
const replies = [okJson({ workflow_runs: [] }), okJson({ workflow_runs: [ghRun(3, 'in_progress', null)] }),
|
|
83
|
+
okJson({ workflow_runs: [ghRun(3, 'completed', 'success')] })];
|
|
84
|
+
const asked = [];
|
|
85
|
+
const r = await waitForIntegration({
|
|
86
|
+
sha: SHA, repo: 'example-org/example-repo', token: 't', timeoutMs: 600000, intervalMs: 20000,
|
|
87
|
+
fetchImpl: async (url, init) => { asked.push({ url, auth: init.headers.Authorization }); return replies.shift(); },
|
|
88
|
+
...c,
|
|
89
|
+
});
|
|
90
|
+
assert.equal(r.verdict, 'go');
|
|
91
|
+
assert.equal(asked.length, 3);
|
|
92
|
+
assert.equal(asked[0].auth, 'Bearer t');
|
|
93
|
+
assert.equal(c.now(), 40000, 'slept between reads, not after the verdict');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
await test('waitForIntegration: a 403/404 ends the wait at once (waiting cannot grant a permission)', async () => {
|
|
97
|
+
for (const status of [401, 403, 404]) {
|
|
98
|
+
const c = clock();
|
|
99
|
+
let calls = 0;
|
|
100
|
+
const r = await waitForIntegration({
|
|
101
|
+
sha: SHA, repo: 'o/r', token: 't', timeoutMs: 600000, intervalMs: 20000,
|
|
102
|
+
fetchImpl: async () => { calls++; return { ok: false, status, json: async () => ({}) }; }, ...c,
|
|
103
|
+
});
|
|
104
|
+
assert.equal(r.verdict, 'unreadable', String(status));
|
|
105
|
+
assert.equal(calls, 1);
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
await test('waitForIntegration: flaky reads are retried, and a lane with no verdict times out rather than publishing', async () => {
|
|
110
|
+
const c = clock();
|
|
111
|
+
let calls = 0;
|
|
112
|
+
const r = await waitForIntegration({
|
|
113
|
+
sha: SHA, repo: 'o/r', token: 't', timeoutMs: 60000, intervalMs: 20000,
|
|
114
|
+
fetchImpl: async () => {
|
|
115
|
+
calls++;
|
|
116
|
+
if (calls === 1) throw new Error('ECONNRESET');
|
|
117
|
+
if (calls === 2) return { ok: false, status: 502, json: async () => ({}) };
|
|
118
|
+
return okJson({ workflow_runs: [ghRun(3, 'in_progress', null)] });
|
|
119
|
+
},
|
|
120
|
+
...c,
|
|
121
|
+
});
|
|
122
|
+
assert.equal(r.verdict, 'timeout');
|
|
123
|
+
assert.ok(calls >= 3, 'kept reading through the transient failures');
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
await test('reportLines: only a green lane says go; red, unreadable and timeout exit non-zero', async () => {
|
|
127
|
+
const base = { sha: SHA, run: ghRun(3, 'completed', 'failure'), why: 'HTTP 403', timeoutMin: 12 };
|
|
128
|
+
assert.deepEqual([reportLines('go', base).go, reportLines('go', base).code], [true, 0]);
|
|
129
|
+
assert.deepEqual([reportLines('superseded', base).go, reportLines('superseded', base).code], [false, 0]);
|
|
130
|
+
for (const v of ['red', 'unreadable', 'timeout']) {
|
|
131
|
+
const o = reportLines(v, base);
|
|
132
|
+
assert.equal(o.go, false, v);
|
|
133
|
+
assert.equal(o.code, 1, v);
|
|
134
|
+
assert.match(o.line, /^::error title=not published/, v);
|
|
135
|
+
assert.doesNotMatch(o.line.split('::')[1], /,/, `${v}: a comma in the title would split the annotation's properties`);
|
|
136
|
+
}
|
|
137
|
+
assert.match(reportLines('red', base).line, /task 1002863/, 'says what the red lane was proving');
|
|
138
|
+
assert.match(reportLines('unreadable', base).line, /actions: read/);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
await test('main: writes go=… to GITHUB_OUTPUT and refuses bad arguments with exit 2', async () => {
|
|
142
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'integ-gate-'));
|
|
143
|
+
try {
|
|
144
|
+
const out = path.join(dir, 'out');
|
|
145
|
+
const log = console.log;
|
|
146
|
+
const err = console.error;
|
|
147
|
+
console.log = () => {};
|
|
148
|
+
console.error = () => {};
|
|
149
|
+
try {
|
|
150
|
+
assert.equal(await main(['--sha', 'nope', '--repo', 'o/r'], { GH_TOKEN: 't', GITHUB_OUTPUT: out }), 2);
|
|
151
|
+
assert.equal(await main(['--sha', SHA, '--repo', 'o/r'], { GITHUB_OUTPUT: out }), 2, 'no token');
|
|
152
|
+
const realFetch = globalThis.fetch;
|
|
153
|
+
globalThis.fetch = async () => okJson({ workflow_runs: [ghRun(3, 'completed', 'success')] });
|
|
154
|
+
try {
|
|
155
|
+
assert.equal(await main(['--sha', SHA, '--repo', 'o/r'], { GH_TOKEN: 't', GITHUB_OUTPUT: out }), 0);
|
|
156
|
+
} finally { globalThis.fetch = realFetch; }
|
|
157
|
+
} finally { console.log = log; console.error = err; }
|
|
158
|
+
assert.equal(fs.readFileSync(out, 'utf8'), 'go=true\n');
|
|
159
|
+
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// ---- 3. the wiring ----
|
|
163
|
+
|
|
164
|
+
const YML = fs.readFileSync(new URL('../.github/workflows/publish.yml', import.meta.url), 'utf8');
|
|
165
|
+
// Split the job into steps at each ` - ` list item (the job's step indentation).
|
|
166
|
+
const steps = YML.split(/\n(?= - (?:name|uses):)/).slice(1);
|
|
167
|
+
const stepIndex = (re) => steps.findIndex((s) => re.test(s));
|
|
168
|
+
|
|
169
|
+
await test('publish.yml can read the integration runs, and asks about the commit it packages', async () => {
|
|
170
|
+
assert.match(YML, /^permissions:[\s\S]*?^\s+actions: read$/m, 'the job token needs actions:read to list runs');
|
|
171
|
+
assert.doesNotMatch(YML, /actions: write/, 'read only');
|
|
172
|
+
const g = steps[stepIndex(/id: integ\n/)];
|
|
173
|
+
assert.ok(g, 'the integration gate step exists');
|
|
174
|
+
assert.match(g, /node scripts\/gds\/publish-integration-gate\.js --sha "\$\(git rev-parse HEAD\)"/, 'HEAD — the checkout is ref: main, not the trigger sha');
|
|
175
|
+
assert.match(g, /GH_TOKEN: \$\{\{ github\.token \}\}/);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
await test('publish.yml: the gate runs before anything is installed, bumped, packed, published, recorded or tagged', async () => {
|
|
179
|
+
const at = stepIndex(/id: integ\n/);
|
|
180
|
+
assert.ok(at > stepIndex(/id: relhead\n/), 'after the cascade terminator (a carrier HEAD needs no gate)');
|
|
181
|
+
for (const re of [/actions\/setup-node@/, /run: npm ci\b/, /id: ver\n/, /id: bump\n/, /id: pack\n/, /id: pub\n/, /git push origin HEAD:main/, /git tag "core-v/]) {
|
|
182
|
+
const i = stepIndex(re);
|
|
183
|
+
assert.ok(i > at, `${re} must come after the integration gate (at ${i}, gate at ${at})`);
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
await test('publish.yml: every armed step after the gate also requires its go — no step can slip past it', async () => {
|
|
188
|
+
const at = stepIndex(/id: integ\n/);
|
|
189
|
+
const later = steps.slice(at + 1).filter((s) => /steps\.armed\.outputs\.armed == 'true'/.test(s));
|
|
190
|
+
assert.ok(later.length >= 9, `expected the install..tag chain after the gate, found ${later.length}`);
|
|
191
|
+
for (const s of later) {
|
|
192
|
+
const name = (/- (?:name: (.*)|uses: (.*))/.exec(s) || [])[1] || s.slice(0, 60);
|
|
193
|
+
assert.match(s, /steps\.integ\.outputs\.go == 'true'/, `"${name}" runs without the integration gate`);
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
console.log(`\npublish integration gate (task 1004290) tests: ${passed} passed, ${failed} failed`);
|
|
198
|
+
process.exitCode = failed > 0 ? 1 : 0;
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// tests/ship_land_ci_not_started.mjs — a land wait that says "CI never ran" when it did
|
|
2
|
+
// not, instead of "fix your code" (task 1004289).
|
|
3
|
+
//
|
|
4
|
+
// WHY. During the 2026-09-25 GitHub Actions budget outage (blocker 1000173) every job on
|
|
5
|
+
// every PR "failed" in about two seconds without running: conclusion FAILURE, no steps, no
|
|
6
|
+
// runner, and one annotation — "The job was not started because an Actions budget is
|
|
7
|
+
// preventing further use." Task 1003764's red-check reader saw an ordinary red `unit`
|
|
8
|
+
// check and told the builder on PR 1211 to fix it, commit and re-run, plus the "CI tests
|
|
9
|
+
// your branch MERGED with main" trap. None of that can help: nothing was tested, and the
|
|
10
|
+
// fix is the owner's, on the GitHub account. These drive the laptop poll with a fake gh
|
|
11
|
+
// carrying the REAL payloads from PR 1211 (run 36210373664, attempt 1).
|
|
12
|
+
//
|
|
13
|
+
// Run: node tests/ship_land_ci_not_started.mjs
|
|
14
|
+
|
|
15
|
+
import { strict as assert } from 'node:assert';
|
|
16
|
+
import { createRequire } from 'node:module';
|
|
17
|
+
import {
|
|
18
|
+
jobNeverStarted, landPendingLines, localMergeDriver, notStartedLines, pollCiDeploy, redBlocksMerge, redPrLine, redPrLines,
|
|
19
|
+
} from '../scripts/gds/ship-land.js';
|
|
20
|
+
|
|
21
|
+
const require = createRequire(import.meta.url);
|
|
22
|
+
const { CARDS, cardFor } = require('../scripts/gds/plain-cards.js');
|
|
23
|
+
|
|
24
|
+
let passed = 0;
|
|
25
|
+
let failed = 0;
|
|
26
|
+
async function test(name, fn) {
|
|
27
|
+
try { await fn(); passed++; console.log(` ok ${name}`); }
|
|
28
|
+
catch (err) { failed++; console.log(` FAIL ${name}`); console.log(` ${err.stack || err.message}`); }
|
|
29
|
+
}
|
|
30
|
+
async function capture(fn) {
|
|
31
|
+
const lines = [];
|
|
32
|
+
const orig = console.log;
|
|
33
|
+
console.log = (...a) => { lines.push(a.join(' ')); };
|
|
34
|
+
try { return { result: await fn(), out: lines.join('\n') }; }
|
|
35
|
+
finally { console.log = orig; }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const SHA = 'b9b03b03687ce823c016a29b0141736a3595c547';
|
|
39
|
+
const RUN = '36210373664';
|
|
40
|
+
const JOB_UNIT = '108315429264';
|
|
41
|
+
const JOB_REPORT = '108315436139';
|
|
42
|
+
const jobUrl = (job) => `https://github.com/example-org/example-repo/actions/runs/${RUN}/job/${job}`;
|
|
43
|
+
const checkRun = (name, job, conclusion = 'FAILURE') => ({
|
|
44
|
+
__typename: 'CheckRun', name, conclusion, status: 'COMPLETED', workflowName: 'unit', detailsUrl: jobUrl(job),
|
|
45
|
+
startedAt: '2026-09-26T02:01:46Z', completedAt: '2026-09-26T02:01:48Z',
|
|
46
|
+
});
|
|
47
|
+
const ctx = (context, state) => ({ __typename: 'StatusContext', context, state, targetUrl: '' });
|
|
48
|
+
const openPr = (rollup) => ({ state: 'OPEN', mergeCommit: null, mergeStateStatus: 'BLOCKED', headRefOid: SHA, statusCheckRollup: rollup });
|
|
49
|
+
// PR 1211's rollup during the outage: both unit-workflow jobs "failed", the trust status green.
|
|
50
|
+
const OUTAGE_ROLLUP = [checkRun('unit', JOB_UNIT), checkRun('report', JOB_REPORT), ctx('gate-author-trust', 'SUCCESS')];
|
|
51
|
+
|
|
52
|
+
// The two annotations GitHub returned for job 108315429264, verbatim (paths trimmed).
|
|
53
|
+
const BUDGET_NOTES = [
|
|
54
|
+
{ path: '.github', annotation_level: 'failure', title: '', message: 'The job was not started because an Actions budget is preventing further use.' },
|
|
55
|
+
{ path: '.github', annotation_level: 'notice', title: '', message: '"The ubuntu-latest label will migrate to Ubuntu 26 beginning October 19, 2026."' },
|
|
56
|
+
];
|
|
57
|
+
const NOTICE_ONLY = [BUDGET_NOTES[1]];
|
|
58
|
+
const annotationsPath = (job) => `repos/{owner}/{repo}/check-runs/${job}/annotations`;
|
|
59
|
+
|
|
60
|
+
// ---- the detector ----
|
|
61
|
+
|
|
62
|
+
await test('jobNeverStarted: the budget annotation on the job reads as never started', async () => {
|
|
63
|
+
const asked = [];
|
|
64
|
+
const gh = (args) => { asked.push(args); return BUDGET_NOTES; };
|
|
65
|
+
assert.equal(jobNeverStarted(checkRun('unit', JOB_UNIT), gh), true);
|
|
66
|
+
assert.deepEqual(asked, [['api', annotationsPath(JOB_UNIT)]], 'reads the check run named by the job id in its detailsUrl');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
await test('jobNeverStarted: the payment-failed wording is the same shape and is caught too', async () => {
|
|
70
|
+
const gh = () => [{ annotation_level: 'failure', message: 'The job was not started because recent account payments have failed or your spending limit needs to be increased.' }];
|
|
71
|
+
assert.equal(jobNeverStarted(checkRun('unit', JOB_UNIT), gh), true);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
await test('jobNeverStarted is FAIL-OPEN: a real failure, an unreadable read, or no job id is "no"', async () => {
|
|
75
|
+
assert.equal(jobNeverStarted(checkRun('unit', JOB_UNIT), () => NOTICE_ONLY), false, 'a job that ran and failed');
|
|
76
|
+
assert.equal(jobNeverStarted(checkRun('unit', JOB_UNIT), () => null), false, 'gh could not read the annotations');
|
|
77
|
+
assert.equal(jobNeverStarted(checkRun('unit', JOB_UNIT), () => ({ message: 'Not Found' })), false, 'an error object, not a list');
|
|
78
|
+
let calls = 0;
|
|
79
|
+
const gh = () => { calls++; return BUDGET_NOTES; };
|
|
80
|
+
assert.equal(jobNeverStarted({ __typename: 'CheckRun', name: 'x', detailsUrl: 'https://example.com/checks' }, gh), false);
|
|
81
|
+
assert.equal(jobNeverStarted(ctx('unit-report', 'FAILURE'), gh), false, 'a commit status is not a job');
|
|
82
|
+
assert.equal(calls, 0, 'nothing to look up, so nothing is asked');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// ---- the merge-driver answer and the lines it prints ----
|
|
86
|
+
|
|
87
|
+
await test('localMergeDriver: every red check never started → ci_not_started, not a failing check', async () => {
|
|
88
|
+
const md = localMergeDriver(openPr(OUTAGE_ROLLUP), () => 'must not be asked', () => true);
|
|
89
|
+
assert.deepEqual(md, { reason: 'ci_not_started:unit', not_started: true, failing_tests: null, run_id: RUN });
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
await test('localMergeDriver: one real failure beside a never-started job is still a failing check', async () => {
|
|
93
|
+
const rollup = [checkRun('unit', JOB_UNIT), checkRun('lint', '999', 'FAILURE')];
|
|
94
|
+
const md = localMergeDriver(openPr(rollup), () => null, (c) => c.name === 'unit');
|
|
95
|
+
assert.equal(md.reason, 'failing_check:unit');
|
|
96
|
+
assert.equal(md.not_started, undefined);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
await test('localMergeDriver: without a never-started answer (the default) nothing changes', async () => {
|
|
100
|
+
assert.deepEqual(localMergeDriver(openPr(OUTAGE_ROLLUP), () => null), { reason: 'failing_check:unit', failing_tests: null });
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
await test('the red line says CI never ran and the work needs nothing; no fix-your-code, no merge trap', async () => {
|
|
104
|
+
const md = { reason: 'ci_not_started:unit', not_started: true, failing_tests: null, run_id: RUN };
|
|
105
|
+
const out = redPrLines(md, { taskId: 42, via: 'local' }).join('\n');
|
|
106
|
+
assert.match(redPrLine(md), /CI NEVER RAN/);
|
|
107
|
+
assert.match(out, /the `unit` job/);
|
|
108
|
+
assert.match(out, /nothing in it to fix/);
|
|
109
|
+
assert.match(out, /repo OWNER's to fix/);
|
|
110
|
+
assert.match(out, new RegExp(`gh run rerun ${RUN} --failed`), 'names the re-run for the exact run');
|
|
111
|
+
assert.match(out, /do not re-push or edit the work/);
|
|
112
|
+
assert.doesNotMatch(out, /fix it, commit/, 'the advice that cannot help is gone');
|
|
113
|
+
assert.doesNotMatch(out, /MERGED with the current main/, 'and so is the merge trap');
|
|
114
|
+
assert.ok(out.includes(CARDS.ci_not_started.happened), 'the plain card rides along');
|
|
115
|
+
assert.ok(!out.includes(CARDS.failing_check.happened), 'not the "a test failed" card');
|
|
116
|
+
assert.equal(cardFor('ci_not_started:unit'), CARDS.ci_not_started, 'the named reason resolves to its card');
|
|
117
|
+
assert.equal(redBlocksMerge(md), false, 'it is not the failing-check kind of red');
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
await test('notStartedLines without a run id points at the PR button instead of inventing a command', async () => {
|
|
121
|
+
const out = notStartedLines({ reason: 'ci_not_started:unit', not_started: true, run_id: null }).join('\n');
|
|
122
|
+
assert.match(out, /"Re-run failed jobs" on the PR/);
|
|
123
|
+
assert.doesNotMatch(out, /gh run rerun/);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
await test('the closing banner for a never-started CI is neither "NOT a failure" nor "fix the check"', async () => {
|
|
127
|
+
const out = landPendingLines(42, { notStarted: true }).join('\n');
|
|
128
|
+
assert.match(out, /CI never started/);
|
|
129
|
+
assert.match(out, /Nothing in the work needs to change/);
|
|
130
|
+
assert.match(out, /node scripts\/gds\/ship\.js 42/);
|
|
131
|
+
assert.doesNotMatch(out, /NOT a failure/);
|
|
132
|
+
assert.doesNotMatch(out, /Do what the lines above say/);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// ---- the laptop poll, end to end ----
|
|
136
|
+
|
|
137
|
+
await test('pollCiDeploy (laptop): an Actions-budget outage is named as CI never running, once, with no code advice', async () => {
|
|
138
|
+
const seen = { prView: 0, annotations: 0, status: 0 };
|
|
139
|
+
const gh = (args) => {
|
|
140
|
+
if (args[0] === 'pr' && args[1] === 'view') { seen.prView++; return openPr(OUTAGE_ROLLUP); }
|
|
141
|
+
if (args[0] === 'api' && /check-runs\/\d+\/annotations$/.test(args[1])) { seen.annotations++; return BUDGET_NOTES; }
|
|
142
|
+
if (args[0] === 'api') { seen.status++; return { statuses: [] }; }
|
|
143
|
+
throw new Error(`unexpected gh call: ${args.join(' ')}`);
|
|
144
|
+
};
|
|
145
|
+
const { result, out } = await capture(() => pollCiDeploy(42, 'task-42', SHA, { ghJson: gh, pause: () => {} }));
|
|
146
|
+
assert.equal(result.pending, true, 'the land is still pending — the checks can yet run');
|
|
147
|
+
assert.equal(seen.prView, 30, 'polled the whole window');
|
|
148
|
+
assert.equal(seen.annotations, 2, 'each job\'s annotations are read once, not once per poll');
|
|
149
|
+
assert.equal(out.split('CI NEVER RAN on this PR').length - 1, 1, 'named once');
|
|
150
|
+
assert.match(out, /CI never started on the PR/, 'the closing banner says the same');
|
|
151
|
+
assert.doesNotMatch(out, /fix it, commit/);
|
|
152
|
+
assert.doesNotMatch(out, /MERGED with the current main/);
|
|
153
|
+
assert.doesNotMatch(out, /This is NOT a failure/);
|
|
154
|
+
assert.ok(!out.includes(CARDS.land_pending.happened), 'nor the "nothing to do" card');
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
await test('pollCiDeploy: when the annotations cannot be read, it falls back to the ordinary red wording', async () => {
|
|
158
|
+
const gh = (args) => {
|
|
159
|
+
if (args[0] === 'pr' && args[1] === 'view') return openPr(OUTAGE_ROLLUP);
|
|
160
|
+
return null; // no Checks read on this gh, or the API is down
|
|
161
|
+
};
|
|
162
|
+
const { out } = await capture(() => pollCiDeploy(42, 'task-42', SHA, { ghJson: gh, pause: () => {} }));
|
|
163
|
+
assert.match(out, /the PR is RED — the `unit` check failed/);
|
|
164
|
+
assert.doesNotMatch(out, /CI NEVER RAN/);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
console.log(`\nship-land CI never started (task 1004289) tests: ${passed} passed, ${failed} failed`);
|
|
168
|
+
process.exitCode = failed > 0 ? 1 : 0;
|