@bongos/core 1.19.797 → 1.19.798
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 +29 -14
- package/docs/adr/0295-the-registry-is-the-witness-the-lane-cannot-be.md +123 -0
- package/docs/adr/README.md +1 -0
- package/docs/file-map.md +1 -0
- package/docs/module-api-changelog.md +2 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/publish-watch.js +268 -0
- package/src/module-api.js +1 -1
- package/tests/publish_watch.mjs +158 -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.798",
|
|
6
|
+
"core_contract": "1.19.798",
|
|
7
|
+
"source_commit": "004852105d6e6651ca15c7405fb3aafb859b89d3",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-09-17T01:
|
|
9
|
+
"built_at": "2026-09-17T01:22:01.549Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
|
-
"docs_redacted":
|
|
12
|
+
"docs_redacted": 487,
|
|
13
13
|
"agent_docs_stubbed": 25,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 2185,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 2697,
|
|
20
|
+
"tree_sha256": "87cf75447f225dc7cc6c23dbe927a64293ba4c290bc3aaee0b87fabbfabd7b9f",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/ask-for-help/SKILL.md",
|
|
@@ -1959,10 +1959,15 @@
|
|
|
1959
1959
|
"mode": "0000644",
|
|
1960
1960
|
"sha256": "32d1b4184b6ba494bacb915990f0ba2770535e25c5646c86a2d91d9cc05c93ed"
|
|
1961
1961
|
},
|
|
1962
|
+
{
|
|
1963
|
+
"path": "docs/adr/0295-the-registry-is-the-witness-the-lane-cannot-be.md",
|
|
1964
|
+
"mode": "0000644",
|
|
1965
|
+
"sha256": "971bd9c2feaa6f3fc910c8a413b1f1a2ebc416cb5c50885dac25f4114cb976c3"
|
|
1966
|
+
},
|
|
1962
1967
|
{
|
|
1963
1968
|
"path": "docs/adr/README.md",
|
|
1964
1969
|
"mode": "0000644",
|
|
1965
|
-
"sha256": "
|
|
1970
|
+
"sha256": "91240573bd704d34ed8100da3795b082430736acc1e03e8cf6469fcd12e2b220"
|
|
1966
1971
|
},
|
|
1967
1972
|
{
|
|
1968
1973
|
"path": "docs/api-reference.md",
|
|
@@ -2517,7 +2522,7 @@
|
|
|
2517
2522
|
{
|
|
2518
2523
|
"path": "docs/file-map.md",
|
|
2519
2524
|
"mode": "0000644",
|
|
2520
|
-
"sha256": "
|
|
2525
|
+
"sha256": "44af03bcfe197664d948053f26ed4af87c7b6fbe1a2a341d3ba3b02439357445"
|
|
2521
2526
|
},
|
|
2522
2527
|
{
|
|
2523
2528
|
"path": "docs/handoff-template.md",
|
|
@@ -2527,7 +2532,7 @@
|
|
|
2527
2532
|
{
|
|
2528
2533
|
"path": "docs/module-api-changelog.md",
|
|
2529
2534
|
"mode": "0000644",
|
|
2530
|
-
"sha256": "
|
|
2535
|
+
"sha256": "90e23bd74c05c075cab7d2d7220a4ece60c0cd99d78925b317567c1ada6c0ecd"
|
|
2531
2536
|
},
|
|
2532
2537
|
{
|
|
2533
2538
|
"path": "docs/modules-contract.md",
|
|
@@ -7702,12 +7707,12 @@
|
|
|
7702
7707
|
{
|
|
7703
7708
|
"path": "package-lock.json",
|
|
7704
7709
|
"mode": "0000644",
|
|
7705
|
-
"sha256": "
|
|
7710
|
+
"sha256": "17535d90fc6adb0a8c78b659584b58869bf2d47da14fabb22defb1f73b303976"
|
|
7706
7711
|
},
|
|
7707
7712
|
{
|
|
7708
7713
|
"path": "package.json",
|
|
7709
7714
|
"mode": "0000644",
|
|
7710
|
-
"sha256": "
|
|
7715
|
+
"sha256": "f1a0a4d4fe697be8633c635210d733e0e2b60f263b0e6ecb9925532145e30b4e"
|
|
7711
7716
|
},
|
|
7712
7717
|
{
|
|
7713
7718
|
"path": "public-docs/index.html",
|
|
@@ -8624,6 +8629,11 @@
|
|
|
8624
8629
|
"mode": "0000644",
|
|
8625
8630
|
"sha256": "8c6ca4655d1eb54b7e604e659fe8eebdf4f7a0b0231a31dab944883fc5c582e8"
|
|
8626
8631
|
},
|
|
8632
|
+
{
|
|
8633
|
+
"path": "scripts/gds/publish-watch.js",
|
|
8634
|
+
"mode": "0000644",
|
|
8635
|
+
"sha256": "e64bc7b8d284633d43678721aafc56ffeabb78c071f5ef58da7764dc1325fae3"
|
|
8636
|
+
},
|
|
8627
8637
|
{
|
|
8628
8638
|
"path": "scripts/gds/pulse.js",
|
|
8629
8639
|
"mode": "0000644",
|
|
@@ -9587,7 +9597,7 @@
|
|
|
9587
9597
|
{
|
|
9588
9598
|
"path": "src/module-api.js",
|
|
9589
9599
|
"mode": "0000644",
|
|
9590
|
-
"sha256": "
|
|
9600
|
+
"sha256": "a4af4ddf0f8f8cc92c41fcebbd1193af19b432de585e836eb6d4626a0dc1e033"
|
|
9591
9601
|
},
|
|
9592
9602
|
{
|
|
9593
9603
|
"path": "src/module-loader/catalog.js",
|
|
@@ -12484,6 +12494,11 @@
|
|
|
12484
12494
|
"mode": "0000644",
|
|
12485
12495
|
"sha256": "fb994e6a587b577c5e5fc9f1b8b4e63cc92c51c3ab0dd64ea8caee8c0e70ef7c"
|
|
12486
12496
|
},
|
|
12497
|
+
{
|
|
12498
|
+
"path": "tests/publish_watch.mjs",
|
|
12499
|
+
"mode": "0000644",
|
|
12500
|
+
"sha256": "91ada5b4bf9b22ff8cefb0508be34f5d006bd223c825c73e5bbc9812411a4253"
|
|
12501
|
+
},
|
|
12487
12502
|
{
|
|
12488
12503
|
"path": "tests/push_path_brief.mjs",
|
|
12489
12504
|
"mode": "0000644",
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# ADR 0295 — The registry is the witness the publish lane cannot be
|
|
2
|
+
|
|
3
|
+
**Status:** accepted · **Date:** 2026-09-16 · **Task:** [task 1003916](https://cloudbongos.com/builders#/task/1003916) (goal 1000090)
|
|
4
|
+
|
|
5
|
+
## Context
|
|
6
|
+
|
|
7
|
+
On 2026-09-13 eight consecutive publish runs failed between 09:54 and 13:02 UTC on a
|
|
8
|
+
mis-scoped leak gate. For three hours `@bongos/core` received nothing. It was found
|
|
9
|
+
because a person happened to read the npm version list and noticed a hole between
|
|
10
|
+
1.19.715 and 1.19.724 — ten days of exposure closed by luck.
|
|
11
|
+
|
|
12
|
+
[Task 1003914](https://cloudbongos.com/builders#/task/1003914) fixed the *lossiness*:
|
|
13
|
+
the bump commit is now pushed AFTER a successful publish, so a failure retries the same
|
|
14
|
+
version instead of burning it. It deliberately left the *silence*, which is structural
|
|
15
|
+
rather than an oversight — and every layer involved was working correctly:
|
|
16
|
+
|
|
17
|
+
- a failed publish is one red row in the Actions tab, and nobody watches the Actions tab;
|
|
18
|
+
- `main` keeps looking healthy, because the next work merge bumps past the failure and
|
|
19
|
+
publishes normally — the repo self-heals in a way that **erases the evidence**;
|
|
20
|
+
- the lane's own `Publish verdict` step writes ✅/⬜ to the job summary, correctly, and
|
|
21
|
+
is read by nobody.
|
|
22
|
+
|
|
23
|
+
This is the same shape as the nightly auto-upgrade sweep that died on 2026-08-25
|
|
24
|
+
reporting `failed=0`. In both cases the machine knew and no human path existed.
|
|
25
|
+
|
|
26
|
+
## Decision
|
|
27
|
+
|
|
28
|
+
**A separate reader asks the registry what the lane only ever asserted.** Two sources
|
|
29
|
+
that should agree: *announced* — every version a `release: core X.Y.Z` commit exists
|
|
30
|
+
for — and *published*, what the registry actually holds. A version in the first and not
|
|
31
|
+
the second is a **phantom**.
|
|
32
|
+
|
|
33
|
+
**"Announced" is deliberately BROADER than the publisher's own `RELEASE_COMMIT_RE`, and
|
|
34
|
+
the gap between the two questions is the reason.** That constant is narrow by design: it
|
|
35
|
+
is the cascade terminator, and it must *not* match a human release commit, because a
|
|
36
|
+
human release is a version that still needs publishing. That is the correct answer to
|
|
37
|
+
the publisher's question ("is this HEAD already done?") and the wrong answer to this
|
|
38
|
+
one ("what did we tell the world we released?"). Human carriers exist on main —
|
|
39
|
+
`release: core 1.19.3 — CLI/init ergonomics carrier`, `release: core 1.18.19 — carrier
|
|
40
|
+
bump for the 32 commits since 1.18.18` — and `publish.yml` fires on any main HEAD with a
|
|
41
|
+
bumped `CORE_VERSION`, hand-edited or not. Matching only the CI shape would have left a
|
|
42
|
+
failed *human* release invisible to the very check meant to catch a failed release. The
|
|
43
|
+
watcher therefore anchors on `^release: core <semver> ` (so a commit merely quoting a
|
|
44
|
+
release line cannot match) and records which lane announced each version, so a finding
|
|
45
|
+
can say which one broke.
|
|
46
|
+
|
|
47
|
+
**Why this can exist now, when the publisher's own design says it cannot.** The lane
|
|
48
|
+
publishes through OIDC trusted publishing, which carries no read credential and
|
|
49
|
+
authenticates `npm publish` and nothing else; that is precisely why
|
|
50
|
+
`laneReleasedVersions` infers released versions from git rather than asking the
|
|
51
|
+
registry. **That constraint never bound a reader.** Since
|
|
52
|
+
[task 1003872](https://cloudbongos.com/builders#/task/1003872) the package is public, so
|
|
53
|
+
an anonymous read needs no credential at all (verified 2026-09-16). The publisher must
|
|
54
|
+
still infer; the watcher can simply look. Keeping them separate is the point — a lane
|
|
55
|
+
that graded its own homework is what produced the silence.
|
|
56
|
+
|
|
57
|
+
**Daily, not hourly, and stated as a cost trade rather than a preference.** Actions
|
|
58
|
+
minutes on this repo are billable and already oversubscribed. Hourly is ~720 runs a
|
|
59
|
+
month; a publish outage is measured in hours (09:54→13:02), so a daily sweep bounds the
|
|
60
|
+
silence to about a day where the alternative was ten days and luck. Raise it if a second
|
|
61
|
+
outage is missed. The cadence and the cron are pinned to each other by
|
|
62
|
+
`routine-schedule.js`, so they cannot drift apart silently.
|
|
63
|
+
|
|
64
|
+
**Deliberately NOT on the session-start path.** `deploy-divergence.js` already runs
|
|
65
|
+
there and would have been the cheapest place to surface this, but it would put a network
|
|
66
|
+
call to a third party on the hot path of every session start, behind a hook timeout. A
|
|
67
|
+
check that makes every session slower is one someone eventually removes.
|
|
68
|
+
|
|
69
|
+
**A healthy day says nothing.** Findings go to stdout, which `run-routine.js` posts to
|
|
70
|
+
Discord `#ship-news`; the healthy line goes to stderr, so a person running it by hand
|
|
71
|
+
still sees it and the channel does not accrue a daily "all fine". A channel of
|
|
72
|
+
reassurances is a channel nobody reads, which is the failure being fixed.
|
|
73
|
+
|
|
74
|
+
## Two ways this could cry wolf, and what stops each
|
|
75
|
+
|
|
76
|
+
**A closed incident is permanent git history.** 1.19.716..723 are announced-and-absent
|
|
77
|
+
today and always will be; an all-history check would fire forever. Only the last 3 days
|
|
78
|
+
are considered — **by time, not by a commit count**, which a burst of releases would
|
|
79
|
+
outrun (the rate varies from ~5 to ~20 a day).
|
|
80
|
+
|
|
81
|
+
**npm acknowledges a publish before its listing shows it** — the CLI says so outright:
|
|
82
|
+
*"Your package is being processed and may take a few minutes to become available."* This
|
|
83
|
+
was not reasoned about in advance; **the check found it by running.** On its first live
|
|
84
|
+
run it flagged 1.19.794 as a phantom fifteen minutes after a publish whose own log ended
|
|
85
|
+
`published @bongos/core@1.19.794` and whose per-version endpoint answered 200 while the
|
|
86
|
+
aggregate listing still omitted it. Untreated, it would have alarmed on every healthy
|
|
87
|
+
release — a self-defeating monitor. Two guards: a **30-minute grace period**, and a
|
|
88
|
+
**per-version confirmation** before anything is called a phantom. The aggregate listing
|
|
89
|
+
only ever nominates suspects; it never returns a verdict.
|
|
90
|
+
|
|
91
|
+
## Consequences
|
|
92
|
+
|
|
93
|
+
- **A failed read is never a clean result.** `publishedVersions` throws rather than
|
|
94
|
+
returning an empty list. Exit codes are the contract: 0 clean, 1 found **and
|
|
95
|
+
delivered**, 2 could not run, 3 found and could **not** deliver. 2 and 3 are red.
|
|
96
|
+
Reporting an unreachable registry as "nothing missing" is exactly the `failed=0` bug
|
|
97
|
+
that hid the upgrade sweep's death; exit 3 is the same principle applied to the alarm
|
|
98
|
+
itself, since a finding that reached nobody has reproduced the original defect.
|
|
99
|
+
- **The scheduler calls the script directly rather than through `run-routine.js`, which
|
|
100
|
+
every other routine uses.** Not a shortcut — a correction, and the second bug this work
|
|
101
|
+
found by running rather than reading. `run-routine` shells out with `execFileSync`
|
|
102
|
+
(throws on any non-zero exit) and its catch does an unconditional `process.exit(1)`, so
|
|
103
|
+
a real exit 2 or 3 arrives at the workflow as 1: the two cases that must be loud would
|
|
104
|
+
have printed a reassuring notice and gone green. Measured, not inferred — a forced
|
|
105
|
+
exit-3 run returns 1 through `run-routine` and 3 directly. The same property made the
|
|
106
|
+
first wiring drop findings entirely: `run-routine` aborts a failed step *before*
|
|
107
|
+
routing its stdout, so the original `sink: discord` step would have thrown away every
|
|
108
|
+
finding it produced. The script now delivers for itself. Where a routine's exit code
|
|
109
|
+
carries meaning, `run-routine` is the wrong harness.
|
|
110
|
+
- **The alarm is armed but mute until one owner action.** The repo has no
|
|
111
|
+
`DISCORD_SHIP_NEWS_WEBHOOK` secret (checked 2026-09-16), so a real finding takes the
|
|
112
|
+
exit-3 path — loud in CI, absent from Discord. Adding that secret is the whole fix.
|
|
113
|
+
- **The detector is proven against a real failed publish, not a synthetic one.**
|
|
114
|
+
`tests/publish_watch.mjs` replays the 2026-09-13 outage out of this repo's history and
|
|
115
|
+
the live registry: the eight carrier commits exist, the eight versions do not, the
|
|
116
|
+
check names all eight as of that date and is quiet about them today.
|
|
117
|
+
- **It does not detect a lane that has stopped being TRIGGERED.** It compares what was
|
|
118
|
+
announced against what was delivered; a publish workflow that never fires announces
|
|
119
|
+
nothing and looks identical to a quiet day. That is a different check.
|
|
120
|
+
- Rejected: asking the registry from inside the publish lane (it has no read credential,
|
|
121
|
+
and self-witnessing is the original defect); an all-history sweep (permanent false
|
|
122
|
+
alarm); hourly (cost, for a failure measured in hours); wiring it into session start
|
|
123
|
+
(a third-party network call on every session's hot path).
|
package/docs/adr/README.md
CHANGED
|
@@ -386,3 +386,4 @@ This keeps the decision history honest and traceable.
|
|
|
386
386
|
| 0292 | [**Both install paths stay, and vendoring turns out to be the production one** ([task 1003879](https://cloudbongos.com/builders#/task/1003879), goal 1000090 — public-npm **gate 6**, the decision row held back from gate 5's mechanical cleanup because both candidates were large deletions that could as easily be mistakes). Gate 6 framed the **vendored-tarball path** (~124 refs across `upgrade.js`/`init.js`/`provision-repo.js`/`package-core.js`) as insurance for hosts that cannot reach npm, to be weighed against the AGPL self-host promise. Read that way it loses. **That is not what the code does with it:** `provision-repo.js` resolves the core for every wizard-created project as "a tarball built from THIS CHECKOUT, file:-pinned", with `--registry-core` as the opt-out — vendoring is the production provisioning path, not the fallback, and deleting it breaks project creation. `resolveVendorableCoreTarball`'s three-rung ladder (reuse the control plane's own pinned tarball → pack the core it has INSTALLED → rebuild from source) exists to answer one question a registry install cannot express: *what is the control plane actually running?* Rungs 1 and 2 each name the bug they fixed — empty new repos when the runner is itself a consumer (task 1002275), and "the frozen-old-tarball births" (task 1002706). The owner's input — a checkout routinely runs ahead of the registry — holds, though not via `main`: since publish-on-merge, 136 of the last 300 `main` commits are release bumps, releases land every 9–51 min, and `main`'s `CORE_VERSION` equalled the registry `latest` (1.19.785) when this was written. Provisioning does not provision from `main`; it provisions from the control plane's own tree, and an unmerged branch or a deliberately-held pin exists on the registry at NO version, ever. **Supersedes ADR 0134's "vendored is the default until the registry path is proven end-to-end" clause:** the condition is MET twice over — with a token 2026-07-08 (task 2118 / blocker 71) and tokenless since [ADR 0283](0283-the-core-publishes-publicly-because-access-was-never-the-thing-protecting-it.md) (`npm pack @bongos/core` from a clean `HOME` fetches 25.5 MB) — and the default stands anyway, for load-bearing reasons rather than for want of proof. Costs accepted with open eyes: ~124 refs, a second install mode every scaffold/upgrade/provision path must keep straight, a Windows-only test failure ([task 1003839](https://cloudbongos.com/builders#/task/1003839)), `refreshStandaloneCorePin`. **Candidate 2 — `@cloudbongos/cli` — also STAYS, on a NEW justification:** task 1003872 was right that the original reason (reaching the board without a private-registry credential) is gone, so it needs one of its own, and the measurement supplies it — 32 files / 0.5 MB / `undici`, versus the core's 2,611 files / 46.4 MB / `express`+`pg`+`pino`. Collapsing it would make `npx` a 46 MB download dragging in a web server and a Postgres driver to print a task list. **Unpublishing was never on the table** (public at 0.1.3, strangers may have installed it); [ADR 0258](0258-the-public-cli-is-a-generated-client-package-not-the-published-core.md) stands with its *rationale* amended — a deliberately small entry point, no longer a private-core workaround. The one real rot the flip left is rung 2's stale "still-private registry package" clause, a comment. **Gate 6 closes keep/keep, no removal task.** Reopens if provisioning ever stops meaning "match the control plane".](0292-both-install-paths-stay-and-vendoring-is-the-production-one.md) | distribution / provisioning |
|
|
387
387
|
| 0293 | [**The owner's deploy door is an INTENT the control plane drains, not a web tier with a shell** ([task 1003159](https://cloudbongos.com/builders#/task/1003159), goal 1000090). The owner, twice in a month: *"I don't think I can make anything live"*, then *"this is above my paygrade"*. Moving a core pin is gate 3 of the release pipeline and the ONLY act that makes shipped work visible to users — and it was reachable from exactly one place: a shell on the control-plane box holding an ssh key. No route, no hall control. An agent could not close the loop either, because the permission classifier blocks a mutating `sudo -u lars` over ssh BY DESIGN — so the gate had no owner-reachable control AND no agent-reachable one, which is a structural mismatch with a platform whose premise is that agents build and the human holds the gates. Measured cost the day it was written: cloudbongos.com served 1.19.754 while 1.19.757, carrying a shipped fix to the claimable-task feed, sat published and unreachable. **Decision: the door is an INTENT the control-plane runner drains** — the seam [ADR 0111](0111-instance-hosting-provisioning-module.md) already built and that provision/teardown/domain-attach/settings-apply/restart already ride; `enqueueIntent` already carries `requested_by`, so the actor requirement is met by the existing shape rather than bolted on. The web tier gains no sudo, no ssh key, no new capability — it gains the ability to ASK. Rejected: giving the web tier sudo (ADR 0043 makes the control plane a separate privilege domain precisely so compromising the HTTP-reachable process does not hand over the box), and a second bespoke queue (another set of claim/settle/retry semantics to get right, when this one's failure modes are already understood in production). **The control plane is enrolled as a SELF ROW** because `provisioning_intents.instance_id` is NOT NULL and the box has never had one — which is exactly why the door had nothing to point at. That same row puts a teardown button one query away from the platform, so `hosting_shape='control-plane'` is a distinct value every destructive leg can refuse BY NAME, and the rule is an ALLOW-list (`core-upgrade`, `restart`) rather than a deny-list of today's destructive actions: a deny-list would admit tomorrow's by omission, and "permitted by omission" here means the platform deleting itself. A partial unique index makes a second self row impossible. Rejected: a nullable `instance_id` meaning "the control plane" (an implicit encoding that reads as a bug at every later call site and silently widens every query assuming the FK holds). **The target version comes from the published registry, never the request** — not tidiness: the release lane mints a git tag BEFORE the npm publish completes (measured 8–13 minutes on 2026-09-16 against ~100s recorded earlier, learnings 1000263/1000266), so a door trusting "whatever main says" would routinely queue a version that does not exist and die on `npm error notarget`. **Two-step preview-then-confirm**, the owner's own call: auto-rollback already guards the bad-release case, this guards the different one — the wrong button. **Archon-gated server-side, no caching** (ADR 0016), with `core_241` giving the ledger an actor and a source, because a privileged action with an unattributed row is not acceptable and the table carried only a free-text note. **Truth is the BOX's `/version`, not the apex** (the apex sits behind pre-launch basic_auth and its HTML rides a CDN, so an apex read reports a stale answer with total confidence). Self-restart is safe for a stated reason rather than an assumed one: the runner and the web service are DIFFERENT systemd units, and a running process keeps the code it already loaded, so an upgrade rewriting the runner's own files takes effect next tick, never mid-run. Tenant instances ride the same queue and are a filed follow-up. What it does NOT do: make deploys automatic — ADR 0137's three gates stand, and this only gives the third a handle.](0293-the-owners-deploy-door-is-an-intent-the-control-plane-drains.md) | deploy / authority / provisioning |
|
|
388
388
|
| 0294 | [**CLI sessions do not expire; revocation is the whole remedy** ([task 1003956](https://cloudbongos.com/builders#/task/1003956), goal 1000110 — owner request 2026-09-16, after a three-week gap signed him out). A `bongos` sign-in died after 24h idle: `createSession` defaulted a null `expiresAt` to `now()+SESSION_TTL_MS` and `lookupSession` rewrote `expires_at = now()+24h` on every request. **This is the third argument about the number and the first about the mechanism** — V3.R38 ([task #259](https://cloudbongos.com/builders#/task/259), migration 035) set 1h, V3.R90 ([task 1000400](https://cloudbongos.com/builders#/task/1000400)) widened it to 24h because 1h "kicked builders out over lunch", and each round shortened the nuisance while leaving the mechanism alone. The hygiene case for keeping it (criterion C9 — bound a leaked token in the mode-0600 plaintext session file) **does not survive contact with refresh-on-use**: a leaked token is by definition one somebody is using, and every use rolled the expiry forward another day, so the timer could only ever fire on a session gone quiet — the legitimate holder stepping away. The control was aimed at attackers and landed exclusively on users. [ADR 0206](0206-own-scoped-session-revocation-no-absolute-ceiling.md) had already written half of this down ("a CLI bearer in daily use is, in practice, immortal") without drawing the conclusion. Decision: **CLI sessions carry no expiry and last until revoked.** `createSession` records `expires_at = NULL` when the caller names none — every such call site is `source: 'cli'` (device flow, federated sign-in, hall re-issue, dev-box pair) — and `lookupSession` still bumps `last_used_at` but rolls `expires_at` forward only via `CASE WHEN expires_at IS NULL THEN NULL ELSE …`; **without that CASE the first request after sign-in re-mints the very timeout this removes.** Migration `core_243` clears `expires_at` on live unrevoked `source='cli'` rows, so the change reaches existing sessions instead of charging every builder one last forced re-auth on deploy day — migration 035's grace inverted, for the reason 035 gave. **Browser sessions are untouched** (they pass an explicit 30 days, so the new default never reaches them, and the migration's `source='cli'` filter is its whole safety property — `lookupSession` only rolls an expiry it can already see, so a web row cleared by mistake stays cleared forever). `SESSION_TTL_MS` survives as the roll-forward window for rows that have an expiry, no longer as a default. The accepted cost, stated plainly: **a leaked CLI token is valid until revoked** — smaller in practice than it sounds, since under refresh-on-use an actively-used leak was already immortal, so what changes is the *abandoned* copy on a dead laptop. The remedy is ADR 0206's own-scoped revoke, immediate where a TTL is next-day, and that ADR's visibility half (`created_at` beside `last_used_at`) is now the primary control rather than a nice-to-have. Nothing about rank moves: `setBuilderRank` still sweeps on demotion and `requireBuilder` still refuses a deactivated builder on the hot path, neither consulting `expires_at`. Rejected: widening the TTL a fourth time; keeping it and making re-auth cheaper (already built — [task 1000401](https://cloudbongos.com/builders#/task/1000401), [task 1000504](https://cloudbongos.com/builders#/task/1000504) — the friction shrank, the interruption stayed); an absolute `created_at` ceiling (ADR 0206's reasoning holds unchanged); dropping the column (load-bearing for web); a blanket `UPDATE … SET expires_at = NULL`. Revisit for machine principals nobody will notice a leak on, a compliance maximum, or long-dead sessions accumulating faster than people prune them.](0294-cli-sessions-do-not-expire.md) | auth / account security / CLI |
|
|
389
|
+
| 0295 | [**The registry is the witness the publish lane cannot be** ([task 1003916](https://cloudbongos.com/builders#/task/1003916), goal 1000090). On 2026-09-13 eight publish runs failed over three hours and NOTHING reported it; it was found by a person idly reading the npm version list. Task 1003914 fixed the lossiness and left the silence, which is structural: a failed run is one red row nobody watches, `main` self-heals on the next merge and **erases the evidence**, and the lane's own verdict step writes to a job summary no one reads. Same shape as the nightly upgrade sweep that died reporting `failed=0`. **Decision: a separate reader compares what the lane ANNOUNCED (release commits) against what the registry HOLDS**; the difference is a phantom. The publisher infers from git because OIDC trusted publishing carries no read credential — **that never bound a reader**, and since task 1003872 the package is public, so an anonymous read needs none. Keeping witness and publisher separate IS the point. Daily not hourly (billable minutes, against a failure measured in hours), cadence pinned to the cron by `routine-schedule.js`; deliberately NOT on the session-start path (a third-party call behind a hook timeout). Healthy days are SILENT in Discord — findings to stdout, the all-clear to stderr — because a channel of reassurances stops being read. Two false-alarm sources, each guarded: a closed incident is permanent history (a 3-day window, by TIME not commit count, which a release burst would outrun), and **npm acknowledges a publish before its listing shows it** — found by RUNNING the check, which flagged 1.19.794 fifteen minutes after a publish whose log ended `published @bongos/core@1.19.794` and whose per-version endpoint answered 200; hence a 30-minute grace period plus per-version confirmation, with the listing only ever nominating suspects. A failed read throws rather than reading as "nothing missing". Proven by replaying the REAL outage from git + the live registry, not a synthetic failure. Does NOT detect a lane that stopped being triggered.](0295-the-registry-is-the-witness-the-lane-cannot-be.md) | release / observability |
|
package/docs/file-map.md
CHANGED
|
@@ -457,6 +457,7 @@ tests/
|
|
|
457
457
|
├── overnight-builder/SKILL.md ← autonomous overnight session-runner — claims the highest-priority safe task, works it end-to-end and ships it; autonomy-gated and OFF by default (restored in task 1003365; unscheduled in core — only redteam-weekly has a firing workflow; config/scheduled-routines.json records the intended cadence)
|
|
458
458
|
├── overnight-code-review/SKILL.md ← nightly autonomous code-quality review; each finding lands as its own idea_inbox row so it survives triage; autonomy-gated and OFF by default (restored in task 1003365; unscheduled in core — only redteam-weekly has a firing workflow; config/scheduled-routines.json records the intended cadence)
|
|
459
459
|
├── peer-vote-tally/SKILL.md ← deterministic tally (`tally.js`) folding builder-on-builder peer votes at least 7 days old into karma_log rows (criterion C2) (restored in task 1003365; unscheduled in core — only redteam-weekly has a firing workflow; config/scheduled-routines.json records the intended cadence)
|
|
460
|
+
├── publish-watch/SKILL.md ← daily publish-lane check (task 1003916): compares every version the release lane ANNOUNCED in its own release commits against what the registry actually holds, and posts anything announced-but-never-delivered to Discord #ship-news. Exists because the 2026-09-13 outage (8 failed runs, 3 hours) was found by a person reading the npm version list — a failed run is one red row nobody watches, and the next merge erases the evidence. Silent on a healthy day by design; exit 2 (could not read) is distinct from exit 1 (found one).
|
|
460
461
|
├── rank-roles-audit/SKILL.md ← nightly deterministic check that every builder holds one of the four live ranks, so none silently resolves to zero permissions under the governance_002 backfill's INNER JOIN; detect-only, files one idea_inbox row and never repairs, and never fatal at boot (task 1002873; unscheduled in core — only redteam-weekly has a firing workflow; config/scheduled-routines.json records the intended cadence)
|
|
461
462
|
├── redteam-weekly/SKILL.md ← weekly deterministic red-team patrol (task 1003128): full-history gitleaks scan + dependency drift review (audit delta, cooldown, young pkgs, stale overrides) + action-pin check → files security reports (advisory, ADR 0162). FIRED BY .github/workflows/redteam-patrol.yml, Mondays 06:17 UTC — `cadence: weekly` here and that cron must agree or fitness Check 25 (scripts/gds/routine-schedule.js) fails the gate (task 1003209, ADR 0188)
|
|
462
463
|
├── repo-simplify-sweep/SKILL.md ← weekly repo-cleanliness janitor — runs repo-metrics, files dead-code findings to idea_inbox, and auto-fixes only the narrow safe class (restored in task 1003365; unscheduled in core — only redteam-weekly has a firing workflow; config/scheduled-routines.json records the intended cadence)
|
|
@@ -2068,5 +2068,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
2068
2068
|
landed since 1.19.795 with no explicit bump. run 35169093667. (task 1002620)
|
|
2069
2069
|
1.19.797 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2070
2070
|
landed since 1.19.796 with no explicit bump. run 35169694391. (task 1002620)
|
|
2071
|
+
1.19.798 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2072
|
+
landed since 1.19.797 with no explicit bump. run 35170229509. (task 1002620)
|
|
2071
2073
|
---------------------------------------------------------------------------
|
|
2072
2074
|
```
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.798",
|
|
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.798",
|
|
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.798",
|
|
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",
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// scripts/gds/publish-watch.js — does the registry actually have what the lane
|
|
3
|
+
// SAID it released? (task 1003916)
|
|
4
|
+
//
|
|
5
|
+
// WHY THIS EXISTS. On 2026-09-13 eight consecutive publish runs failed between
|
|
6
|
+
// 09:54 and 13:02 UTC on a mis-scoped leak gate. Nothing reported that the
|
|
7
|
+
// registry had stopped receiving releases for three hours. It was found by
|
|
8
|
+
// someone idly reading the npm version list and noticing a hole. Task 1003914
|
|
9
|
+
// fixed the LOSSINESS (the bump is no longer pushed before the publish, so a
|
|
10
|
+
// failure retries the same version instead of burning it) and deliberately left
|
|
11
|
+
// the SILENCE, which is this task.
|
|
12
|
+
//
|
|
13
|
+
// The silence is structural, not an oversight:
|
|
14
|
+
// • a failed publish is a red run in the Actions tab, and nobody watches it;
|
|
15
|
+
// • `main` keeps looking healthy — the next work merge bumps past the failure
|
|
16
|
+
// and publishes normally, so the repo self-heals in a way that ERASES the
|
|
17
|
+
// evidence;
|
|
18
|
+
// • the lane's own `Publish verdict` step writes ✅/⬜ to the job summary,
|
|
19
|
+
// which is correct and read by nobody.
|
|
20
|
+
//
|
|
21
|
+
// WHAT CHANGED, AND WHY THIS IS NOW A THREE-LINE QUESTION. The lane cannot ASK
|
|
22
|
+
// the registry whether a version is published: publishing via OIDC trusted
|
|
23
|
+
// publishing means there is no read credential, and OIDC authenticates
|
|
24
|
+
// `npm publish` and nothing else. That constraint is what pushed
|
|
25
|
+
// laneReleasedVersions onto a git-log derivation (see bump-version.js).
|
|
26
|
+
// It no longer binds a READER. Post-task 1003872 `@bongos/core` is PUBLIC, so an
|
|
27
|
+
// anonymous `npm view` needs no credential at all — verified 2026-09-16, and the
|
|
28
|
+
// reason this check can exist as a plain comparison rather than an inference.
|
|
29
|
+
//
|
|
30
|
+
// THE COMPARISON. Two independent sources that should agree:
|
|
31
|
+
// announced — every version the lane wrote a release commit for, read from git
|
|
32
|
+
// via bump-version.js's RELEASE_COMMIT_RE. Deliberately the SAME
|
|
33
|
+
// constant the lane itself uses, so an announcement shape can
|
|
34
|
+
// never mean one thing to the publisher and another here.
|
|
35
|
+
// published — every version actually on the registry (anonymous read).
|
|
36
|
+
// A version in `announced` and not in `published` is a PHANTOM: the lane said it
|
|
37
|
+
// shipped something it never delivered. 1.19.716..723 are exactly that, and are
|
|
38
|
+
// still observable today — tests/publish_watch.mjs pins the detector against
|
|
39
|
+
// that real history rather than a synthetic failure.
|
|
40
|
+
//
|
|
41
|
+
// WHY A TIME WINDOW, NOT ALL OF HISTORY. The 2026-09-13 phantoms are permanent
|
|
42
|
+
// git history: an all-history check would fire forever on a closed incident and
|
|
43
|
+
// train the owner to scroll past it — the precise failure this task exists to
|
|
44
|
+
// prevent. The window is by TIME rather than by count so it cannot be outrun by
|
|
45
|
+
// a burst of releases (the rate varies from ~5 to ~20 a day).
|
|
46
|
+
'use strict';
|
|
47
|
+
|
|
48
|
+
const { execFileSync } = require('child_process');
|
|
49
|
+
const path = require('path');
|
|
50
|
+
const { RELEASE_COMMIT_RE } = require(path.join(__dirname, 'bump-version.js'));
|
|
51
|
+
|
|
52
|
+
const REPO_ROOT = path.resolve(__dirname, '..', '..');
|
|
53
|
+
const PKG = '@bongos/core';
|
|
54
|
+
const DEFAULT_WINDOW_DAYS = 3;
|
|
55
|
+
// npm acknowledges a publish before the aggregate listing shows it: the CLI
|
|
56
|
+
// prints "Your package is being processed and may take a few minutes to become
|
|
57
|
+
// available." A version announced inside this grace period is therefore not yet
|
|
58
|
+
// evidence of anything. Found by RUNNING this check (2026-09-16): it flagged
|
|
59
|
+
// 1.19.794 as a phantom fifteen minutes after a publish whose own log ended
|
|
60
|
+
// `published @bongos/core@1.19.794`, and whose per-version endpoint answered 200.
|
|
61
|
+
const DEFAULT_GRACE_MINUTES = 30;
|
|
62
|
+
const GIT_TIMEOUT_MS = 15000;
|
|
63
|
+
const NPM_TIMEOUT_MS = 60000;
|
|
64
|
+
|
|
65
|
+
// WHAT COUNTS AS AN ANNOUNCEMENT — deliberately BROADER than the publisher's
|
|
66
|
+
// own RELEASE_COMMIT_RE, and the difference is the point.
|
|
67
|
+
//
|
|
68
|
+
// RELEASE_COMMIT_RE is narrow BY DESIGN: it is the cascade terminator, and it
|
|
69
|
+
// must not match a human release commit, because a human release is a version
|
|
70
|
+
// that still needs publishing (see bump-version.js). That is the right answer to
|
|
71
|
+
// the publisher's question ("is this HEAD already done?").
|
|
72
|
+
//
|
|
73
|
+
// It is the WRONG answer to this file's question ("what did we tell the world we
|
|
74
|
+
// released?"). A human carrier announces a release just as loudly —
|
|
75
|
+
// `release: core 1.19.3 — CLI/init ergonomics carrier (task 1002631)`,
|
|
76
|
+
// `release: core 1.18.19 — carrier bump for the 32 commits since 1.18.18` — and
|
|
77
|
+
// publish.yml fires on any main HEAD with a bumped CORE_VERSION, hand-edited or
|
|
78
|
+
// not. Matching only the CI shape would have left a failed HUMAN release
|
|
79
|
+
// invisible to the exact check meant to catch a failed release.
|
|
80
|
+
//
|
|
81
|
+
// So: anchored at `^` so a commit merely QUOTING a release line cannot match
|
|
82
|
+
// (tested), version-shaped, and each entry records WHICH lane announced it so a
|
|
83
|
+
// finding can say so.
|
|
84
|
+
const ANNOUNCED_RE = /^release: core (\d+\.\d+\.\d+) /;
|
|
85
|
+
|
|
86
|
+
// Pure. Release commits newest-first, as {version, sha, iso, lane}, parsed from
|
|
87
|
+
// `git log --format=%H|%cI|%s`.
|
|
88
|
+
function parseAnnounced(logText) {
|
|
89
|
+
const out = [];
|
|
90
|
+
for (const raw of String(logText == null ? '' : logText).split('\n')) {
|
|
91
|
+
const line = raw.trim();
|
|
92
|
+
if (!line) continue;
|
|
93
|
+
const parts = line.split('|');
|
|
94
|
+
if (parts.length < 3) continue;
|
|
95
|
+
const [sha, iso] = parts;
|
|
96
|
+
const subject = parts.slice(2).join('|');
|
|
97
|
+
const m = ANNOUNCED_RE.exec(subject);
|
|
98
|
+
if (!m) continue;
|
|
99
|
+
out.push({
|
|
100
|
+
version: m[1],
|
|
101
|
+
sha,
|
|
102
|
+
shortSha: sha.slice(0, 8),
|
|
103
|
+
iso,
|
|
104
|
+
lane: RELEASE_COMMIT_RE.test(subject) ? 'ci' : 'human',
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
return out;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Pure, and the whole verdict. `published` is any iterable of version strings.
|
|
111
|
+
function phantoms({ announced = [], published = [], now = new Date(), windowDays = DEFAULT_WINDOW_DAYS, graceMinutes = DEFAULT_GRACE_MINUTES } = {}) {
|
|
112
|
+
const have = new Set(Array.from(published, (v) => String(v)));
|
|
113
|
+
const nowMs = new Date(now).getTime();
|
|
114
|
+
const cutoff = nowMs - windowDays * 86400000;
|
|
115
|
+
const graceEdge = nowMs - graceMinutes * 60000;
|
|
116
|
+
const considered = [];
|
|
117
|
+
const missing = [];
|
|
118
|
+
const settling = [];
|
|
119
|
+
for (const rel of announced) {
|
|
120
|
+
const t = Date.parse(rel.iso);
|
|
121
|
+
if (!Number.isFinite(t) || t < cutoff) continue;
|
|
122
|
+
considered.push(rel);
|
|
123
|
+
if (have.has(rel.version)) continue;
|
|
124
|
+
// Too fresh to judge — the registry may simply not have finished processing.
|
|
125
|
+
if (t > graceEdge) { settling.push(rel); continue; }
|
|
126
|
+
missing.push(rel);
|
|
127
|
+
}
|
|
128
|
+
return { considered, missing, settling, windowDays, graceMinutes, cutoffIso: new Date(cutoff).toISOString() };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// execFileSync, NOT execSync: the `%H|%cI|%s` format is full of pipes, and a
|
|
132
|
+
// shell splits the command on them into a nonsense pipeline. Caught by running
|
|
133
|
+
// it (exit 2, "could not complete") rather than by reading it.
|
|
134
|
+
function gitLog(ref, { days, cwd = REPO_ROOT } = {}) {
|
|
135
|
+
const args = ['log', ref];
|
|
136
|
+
if (days) args.push(`--since=${days + 1} days ago`);
|
|
137
|
+
args.push('--format=%H|%cI|%s');
|
|
138
|
+
return execFileSync('git', args, {
|
|
139
|
+
cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
|
|
140
|
+
maxBuffer: 256 * 1024 * 1024, timeout: GIT_TIMEOUT_MS,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Anonymous registry read. Throws on failure — a read that did NOT happen must
|
|
145
|
+
// never be reported as "nothing missing" (the accounting bug that let the
|
|
146
|
+
// nightly upgrade sweep die reporting failed=0 on 2026-08-25).
|
|
147
|
+
function publishedVersions(pkg = PKG) {
|
|
148
|
+
const raw = execFileSync('npm', ['view', pkg, 'versions', '--json'], {
|
|
149
|
+
encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
|
|
150
|
+
maxBuffer: 64 * 1024 * 1024, timeout: NPM_TIMEOUT_MS,
|
|
151
|
+
});
|
|
152
|
+
const parsed = JSON.parse(raw);
|
|
153
|
+
const list = Array.isArray(parsed) ? parsed : [parsed];
|
|
154
|
+
if (!list.length) throw new Error(`registry returned no versions for ${pkg}`);
|
|
155
|
+
return list;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// The aggregate listing is a CANDIDATE source, never a verdict: it lags a
|
|
159
|
+
// publish by minutes. This asks the registry about one exact version, which does
|
|
160
|
+
// not lag — the endpoint answered 200 for 1.19.794 while the listing still
|
|
161
|
+
// omitted it. Returns true only on a clear "it is there"; a thrown request is
|
|
162
|
+
// reported as still-missing by the caller, never silently as present.
|
|
163
|
+
function versionIsPublished(pkg, version) {
|
|
164
|
+
try {
|
|
165
|
+
const out = execFileSync('npm', ['view', `${pkg}@${version}`, 'version'], {
|
|
166
|
+
encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: NPM_TIMEOUT_MS,
|
|
167
|
+
});
|
|
168
|
+
return String(out).trim() === String(version);
|
|
169
|
+
} catch {
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function check({ ref = 'origin/main', windowDays = DEFAULT_WINDOW_DAYS, now = new Date(), pkg = PKG, graceMinutes = DEFAULT_GRACE_MINUTES, confirm = versionIsPublished } = {}) {
|
|
175
|
+
const announced = parseAnnounced(gitLog(ref, { days: windowDays }));
|
|
176
|
+
const published = publishedVersions(pkg);
|
|
177
|
+
const r = phantoms({ announced, published, now, windowDays, graceMinutes });
|
|
178
|
+
// Confirm each candidate individually before calling it a phantom. Only runs
|
|
179
|
+
// on suspects, so a healthy lane costs exactly one registry read.
|
|
180
|
+
const confirmed = [];
|
|
181
|
+
const lagging = [];
|
|
182
|
+
for (const cand of r.missing) (confirm(pkg, cand.version) ? lagging : confirmed).push(cand);
|
|
183
|
+
return { pkg, ref, ...r, missing: confirmed, lagging, publishedCount: published.length };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// One line when healthy; the roll-call when not. Written for Discord #ship-news,
|
|
187
|
+
// so it stays plain language — the owner reads that channel, not this file.
|
|
188
|
+
function report(r) {
|
|
189
|
+
if (!r.missing.length) {
|
|
190
|
+
const extra = [];
|
|
191
|
+
if (r.settling && r.settling.length) extra.push(`${r.settling.length} still settling`);
|
|
192
|
+
if (r.lagging && r.lagging.length) extra.push(`${r.lagging.length} present but not yet in the listing`);
|
|
193
|
+
const tail = extra.length ? ` (${extra.join('; ')})` : '';
|
|
194
|
+
return `Publish lane healthy — all ${r.considered.length} release(s) announced in the last ${r.windowDays} days are on the registry${tail}.`;
|
|
195
|
+
}
|
|
196
|
+
const lines = [
|
|
197
|
+
`⚠ The publish lane announced ${r.missing.length} release(s) it never delivered to the registry.`,
|
|
198
|
+
'',
|
|
199
|
+
`Anyone installing ${r.pkg} cannot get ${r.missing.length === 1 ? 'this version' : 'these versions'}:`,
|
|
200
|
+
];
|
|
201
|
+
for (const m of r.missing) lines.push(` • ${m.version} — announced ${m.iso.slice(0, 16).replace('T', ' ')}Z by commit ${m.shortSha}${m.lane === 'human' ? ' (hand-cut release)' : ''}`);
|
|
202
|
+
lines.push('', 'A publish run failed and the next merge will paper over it. Check the publish workflow runs for that window.');
|
|
203
|
+
return lines.join('\n');
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Deliver the finding ourselves rather than via run-routine.js's `sink: discord`.
|
|
207
|
+
// WHY: run-routine treats a non-zero step as a FAILED step and aborts before it
|
|
208
|
+
// routes stdout anywhere — so exit 1 ("found a phantom") would have thrown the
|
|
209
|
+
// finding away instead of posting it. Caught by running the routine with a
|
|
210
|
+
// forced finding and no webhook (2026-09-16). artifact-audit has the same shape
|
|
211
|
+
// for the same reason: the script performs its own side effect and the routine
|
|
212
|
+
// step stays `sink: none`.
|
|
213
|
+
async function deliver(text) {
|
|
214
|
+
const { postWebhook } = require(path.join(__dirname, '..', 'discord', 'lib.js'));
|
|
215
|
+
await postWebhook('ship_news', text);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function main() {
|
|
219
|
+
const argv = process.argv.slice(2);
|
|
220
|
+
const arg = (f, d) => { const i = argv.indexOf(f); return i >= 0 && argv[i + 1] ? argv[i + 1] : d; };
|
|
221
|
+
const windowDays = Number(arg('--window-days', DEFAULT_WINDOW_DAYS));
|
|
222
|
+
const graceMinutes = Number(arg('--grace-minutes', DEFAULT_GRACE_MINUTES));
|
|
223
|
+
const ref = arg('--ref', 'origin/main');
|
|
224
|
+
const json = argv.includes('--json');
|
|
225
|
+
|
|
226
|
+
let r;
|
|
227
|
+
try {
|
|
228
|
+
r = check({
|
|
229
|
+
ref,
|
|
230
|
+
windowDays: Number.isFinite(windowDays) && windowDays > 0 ? windowDays : DEFAULT_WINDOW_DAYS,
|
|
231
|
+
graceMinutes: Number.isFinite(graceMinutes) && graceMinutes >= 0 ? graceMinutes : DEFAULT_GRACE_MINUTES,
|
|
232
|
+
});
|
|
233
|
+
} catch (err) {
|
|
234
|
+
// Exit 2 = COULD NOT RUN. Distinct from 1 (ran, found something) so a
|
|
235
|
+
// scheduler can go red on the former and merely report the latter.
|
|
236
|
+
console.error(`publish-watch could not complete: ${err && err.message ? err.message : err}`);
|
|
237
|
+
process.exit(2);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const text = report(r);
|
|
241
|
+
if (json) { console.log(JSON.stringify(r, null, 2)); process.exit(r.missing.length ? 1 : 0); }
|
|
242
|
+
|
|
243
|
+
// A healthy day is deliberately quiet: it reports to a person running this by
|
|
244
|
+
// hand (stderr) and posts nothing. A daily "all fine" in #ship-news is how the
|
|
245
|
+
// channel becomes wallpaper, which is the failure this check exists to fix.
|
|
246
|
+
if (!r.missing.length) { console.error(text); process.exit(0); }
|
|
247
|
+
|
|
248
|
+
console.log(text);
|
|
249
|
+
if (argv.includes('--no-post')) process.exit(1);
|
|
250
|
+
|
|
251
|
+
try {
|
|
252
|
+
await deliver(text);
|
|
253
|
+
console.error('posted the finding to Discord #ship-news.');
|
|
254
|
+
process.exit(1);
|
|
255
|
+
} catch (err) {
|
|
256
|
+
// FOUND SOMETHING AND COULD NOT SAY IT. This is the original defect one
|
|
257
|
+
// layer down, so it must be the LOUDEST outcome, not the quietest: exit 3
|
|
258
|
+
// is >= 2, which turns the scheduled run red. A finding that reached nobody
|
|
259
|
+
// is not a successful run.
|
|
260
|
+
console.error(`\nFOUND A PHANTOM AND COULD NOT DELIVER IT: ${err && err.message ? err.message : err}`);
|
|
261
|
+
console.error('The finding is above. Set DISCORD_SHIP_NEWS_WEBHOOK (repo secret) so this reaches #ship-news.');
|
|
262
|
+
process.exit(3);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (require.main === module) main();
|
|
267
|
+
|
|
268
|
+
module.exports = { ANNOUNCED_RE, parseAnnounced, phantoms, publishedVersions, versionIsPublished, check, report, DEFAULT_WINDOW_DAYS, DEFAULT_GRACE_MINUTES, PKG };
|
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.798'; // 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,158 @@
|
|
|
1
|
+
// tests/publish_watch.mjs — the publish-lane silence detector (task 1003916).
|
|
2
|
+
//
|
|
3
|
+
// The LIVE test at the bottom replays the REAL 2026-09-13 incident out of this
|
|
4
|
+
// repo's own history and the real registry, rather than a synthetic failure:
|
|
5
|
+
// eight release commits (1.19.716..723) exist on main and those eight versions
|
|
6
|
+
// are absent from npm to this day. That is a genuinely failed publish preserved
|
|
7
|
+
// in evidence, which is a stronger proof than one manufactured here.
|
|
8
|
+
import test from 'node:test';
|
|
9
|
+
import assert from 'node:assert/strict';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { execFileSync } from 'node:child_process';
|
|
12
|
+
import { createRequire } from 'node:module';
|
|
13
|
+
|
|
14
|
+
const require = createRequire(import.meta.url);
|
|
15
|
+
const ROOT = path.resolve(import.meta.dirname, '..');
|
|
16
|
+
const pw = require(path.join(ROOT, 'scripts', 'gds', 'publish-watch.js'));
|
|
17
|
+
|
|
18
|
+
const REL = (v, iso, sha = 'abcdef1234567890') =>
|
|
19
|
+
`${sha}|${iso}|release: core ${v} — CI auto-patch carrier (publish-on-merge, ADR 0161)`;
|
|
20
|
+
|
|
21
|
+
test('parseAnnounced reads BOTH lanes — a hand-cut release announces too', () => {
|
|
22
|
+
// Deliberately broader than the publisher's RELEASE_COMMIT_RE: that constant is
|
|
23
|
+
// the cascade terminator and must NOT match a human release, but a human
|
|
24
|
+
// release is still an announcement, and a failed one is exactly what this
|
|
25
|
+
// check exists to catch. Real examples on main: 1.19.3, 1.18.19.
|
|
26
|
+
const log = [
|
|
27
|
+
REL('1.19.700', '2026-09-13T07:00:00Z'),
|
|
28
|
+
'deadbeefdeadbeef|2026-09-13T07:05:00Z|fix(auth): something unrelated (task 1)',
|
|
29
|
+
'cafebabecafebabe|2026-09-13T07:10:00Z|release: core 1.20.0 — carrier for the init-blocker fixes (task 1002624)',
|
|
30
|
+
REL('1.19.701', '2026-09-13T07:15:00Z'),
|
|
31
|
+
].join('\n');
|
|
32
|
+
const got = pw.parseAnnounced(log);
|
|
33
|
+
assert.deepEqual(got.map((r) => r.version), ['1.19.700', '1.20.0', '1.19.701']);
|
|
34
|
+
assert.deepEqual(got.map((r) => r.lane), ['ci', 'human', 'ci']);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('a failed HUMAN release is reported, and named as hand-cut', () => {
|
|
38
|
+
const announced = pw.parseAnnounced('cafebabecafebabe|2026-09-10T07:10:00Z|release: core 1.20.0 — carrier for the init-blocker fixes (task 1002624)');
|
|
39
|
+
const r = pw.phantoms({ announced, published: [], now: new Date('2026-09-10T12:00:00Z') });
|
|
40
|
+
assert.deepEqual(r.missing.map((m) => m.version), ['1.20.0']);
|
|
41
|
+
assert.match(pw.report(r), /hand-cut release/);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('parseAnnounced does not match a subject merely QUOTING a release line', () => {
|
|
45
|
+
const log = 'aaaabbbbccccdddd|2026-09-13T07:00:00Z|docs: explain "release: core 1.19.9 — CI auto-patch carrier (publish-on-merge"';
|
|
46
|
+
assert.deepEqual(pw.parseAnnounced(log), []);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('a delivered lane is silent', () => {
|
|
50
|
+
const announced = pw.parseAnnounced([REL('1.19.700', '2026-09-16T10:00:00Z'), REL('1.19.701', '2026-09-16T11:00:00Z')].join('\n'));
|
|
51
|
+
const r = pw.phantoms({ announced, published: ['1.19.700', '1.19.701'], now: new Date('2026-09-16T12:00:00Z') });
|
|
52
|
+
assert.equal(r.missing.length, 0);
|
|
53
|
+
assert.equal(r.considered.length, 2);
|
|
54
|
+
assert.match(pw.report(r), /healthy/);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('an announced-but-undelivered version is reported, and named', () => {
|
|
58
|
+
const announced = pw.parseAnnounced([REL('1.19.700', '2026-09-16T10:00:00Z'), REL('1.19.701', '2026-09-16T11:00:00Z')].join('\n'));
|
|
59
|
+
const r = pw.phantoms({ announced, published: ['1.19.700'], now: new Date('2026-09-16T12:00:00Z') });
|
|
60
|
+
assert.deepEqual(r.missing.map((m) => m.version), ['1.19.701']);
|
|
61
|
+
const text = pw.report(r);
|
|
62
|
+
assert.match(text, /never delivered/);
|
|
63
|
+
assert.match(text, /1\.19\.701/);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('the window ages a closed incident out, so it cannot cry wolf forever', () => {
|
|
67
|
+
const announced = pw.parseAnnounced(REL('1.19.716', '2026-09-13T09:54:30Z'));
|
|
68
|
+
const fresh = pw.phantoms({ announced, published: [], now: new Date('2026-09-13T18:00:00Z'), windowDays: 3 });
|
|
69
|
+
assert.equal(fresh.missing.length, 1, 'fires while the incident is recent');
|
|
70
|
+
const stale = pw.phantoms({ announced, published: [], now: new Date('2026-09-30T00:00:00Z'), windowDays: 3 });
|
|
71
|
+
assert.equal(stale.missing.length, 0, 'silent once it is old news');
|
|
72
|
+
assert.equal(stale.considered.length, 0);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('a read that returned nothing must not read as "nothing missing"', () => {
|
|
76
|
+
// publishedVersions throws rather than returning [] — an empty registry answer
|
|
77
|
+
// and a healthy lane are the same shape otherwise, which is how the nightly
|
|
78
|
+
// upgrade sweep died reporting failed=0 on 2026-08-25.
|
|
79
|
+
assert.throws(() => pw.publishedVersions('@bongos/definitely-not-a-real-package-1003916'));
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('LIVE: replays the real 2026-09-13 publish outage from git + the registry', (t) => {
|
|
83
|
+
// The eight carrier commits exist on main...
|
|
84
|
+
// Bounded: this runs on every ordinary suite run, so it reads a window around
|
|
85
|
+
// the incident rather than the whole history.
|
|
86
|
+
const log = execFileSync('git', ['log', 'origin/main', '--since=2026-09-01', '--until=2026-09-20', '--format=%H|%cI|%s'], { encoding: 'utf8', cwd: ROOT, maxBuffer: 64 * 1024 * 1024 });
|
|
87
|
+
const announced = pw.parseAnnounced(log);
|
|
88
|
+
const incident = announced.filter((r) => {
|
|
89
|
+
const n = Number(r.version.split('.')[2]);
|
|
90
|
+
return r.version.startsWith('1.19.') && n >= 716 && n <= 723;
|
|
91
|
+
});
|
|
92
|
+
assert.equal(incident.length, 8, `expected the 8 carrier commits for 1.19.716..723, saw ${incident.length}`);
|
|
93
|
+
|
|
94
|
+
// ...and the registry does not have them, to this day. The registry is a third
|
|
95
|
+
// party on the ordinary suite path, so an unreachable one SKIPS rather than
|
|
96
|
+
// failing the suite — a flaky red teaches people to ignore a red.
|
|
97
|
+
let published;
|
|
98
|
+
try {
|
|
99
|
+
published = pw.publishedVersions();
|
|
100
|
+
} catch (err) {
|
|
101
|
+
t.skip(`registry unreachable (${err && err.message ? err.message : err})`);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
for (const rel of incident) {
|
|
105
|
+
assert.equal(published.includes(rel.version), false, `${rel.version} unexpectedly present on the registry`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Evaluated AS OF the incident, the detector names all eight.
|
|
109
|
+
const asOf = pw.phantoms({ announced, published, now: new Date('2026-09-13T16:00:00Z'), windowDays: 3 });
|
|
110
|
+
const named = asOf.missing.map((m) => m.version);
|
|
111
|
+
for (const rel of incident) assert.ok(named.includes(rel.version), `detector missed ${rel.version}`);
|
|
112
|
+
|
|
113
|
+
// And today it is quiet about that same closed incident.
|
|
114
|
+
const today = pw.phantoms({ announced, published, now: new Date(), windowDays: 3 });
|
|
115
|
+
for (const rel of incident) {
|
|
116
|
+
assert.equal(today.missing.some((m) => m.version === rel.version), false, `still crying wolf about ${rel.version}`);
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('a release announced moments ago is "settling", not a phantom', () => {
|
|
121
|
+
// npm acknowledges a publish before its aggregate listing shows it. Reporting
|
|
122
|
+
// inside that window would fire on every healthy release — which is exactly
|
|
123
|
+
// what this check did on its first live run (1.19.794, 2026-09-16).
|
|
124
|
+
const announced = pw.parseAnnounced(REL('1.19.794', '2026-09-17T00:45:23Z'));
|
|
125
|
+
const r = pw.phantoms({ announced, published: [], now: new Date('2026-09-17T00:50:00Z'), graceMinutes: 30 });
|
|
126
|
+
assert.equal(r.missing.length, 0);
|
|
127
|
+
assert.deepEqual(r.settling.map((s) => s.version), ['1.19.794']);
|
|
128
|
+
assert.match(pw.report(r), /healthy/);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('past the grace period the same release IS a phantom', () => {
|
|
132
|
+
const announced = pw.parseAnnounced(REL('1.19.794', '2026-09-17T00:45:23Z'));
|
|
133
|
+
const r = pw.phantoms({ announced, published: [], now: new Date('2026-09-17T04:00:00Z'), graceMinutes: 30 });
|
|
134
|
+
assert.deepEqual(r.missing.map((m) => m.version), ['1.19.794']);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('a candidate the registry confirms per-version is lagging, not missing', () => {
|
|
138
|
+
// The listing is a candidate source; the per-version read is the verdict.
|
|
139
|
+
const log = [REL('1.19.900', '2026-09-10T00:00:00Z'), REL('1.19.901', '2026-09-10T01:00:00Z')].join('\n');
|
|
140
|
+
const announced = pw.parseAnnounced(log);
|
|
141
|
+
const base = pw.phantoms({ announced, published: [], now: new Date('2026-09-10T12:00:00Z'), graceMinutes: 30 });
|
|
142
|
+
assert.equal(base.missing.length, 2, 'both look missing from the listing alone');
|
|
143
|
+
|
|
144
|
+
// Emulate check()'s confirmation pass: 900 really is there, 901 really is not.
|
|
145
|
+
const confirm = (_pkg, v) => v === '1.19.900';
|
|
146
|
+
const confirmed = [];
|
|
147
|
+
const lagging = [];
|
|
148
|
+
for (const c of base.missing) (confirm(pw.PKG, c.version) ? lagging : confirmed).push(c);
|
|
149
|
+
assert.deepEqual(lagging.map((l) => l.version), ['1.19.900']);
|
|
150
|
+
assert.deepEqual(confirmed.map((c) => c.version), ['1.19.901']);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test('versionIsPublished is true for a real version and false for an invented one', (t) => {
|
|
154
|
+
let live;
|
|
155
|
+
try { live = pw.versionIsPublished(pw.PKG, '1.19.715'); } catch { live = null; }
|
|
156
|
+
if (live !== true) { t.skip('registry unreachable'); return; }
|
|
157
|
+
assert.equal(pw.versionIsPublished(pw.PKG, '1.19.716'), false, '1.19.716 is the real 2026-09-13 hole');
|
|
158
|
+
});
|