fix(ci): make the test suite run truthfully and let CI report on main - #167
fix(ci): make the test suite run truthfully and let CI report on main#167yaron-thenvoi wants to merge 9 commits into
Conversation
Several guard tests read this repository's own files and assert on exact lines. Under git's default `core.autocrlf` on Windows those files arrive CRLF, so the assertions compare against bytes CI never sees and fail for a reason unrelated to the code: the C6 brand guard's README heading mismatched, and seven `\n`-anchored regexes in the release-hardening suite could not match the workflows they guard. Pin the working tree to LF and make the C6 guard split on either ending, so a contributor's checkout config stops being a test input. Nothing in the tree needs CRLF; the `.sh` scripts and Dockerfiles positively require LF. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`ci.yml` triggered on `pull_request` only, so nothing ran the suite on a push to `main`. Combined with a `main-branch-protection` ruleset that requires a pull request but no *passing* checks, a red build could land and then stop being reported: #159 merged with both `test` and the aggregate `ci-status` concluding failure, and the failure stayed invisible afterwards. Add `push: [main]` and make the trunk run mean something: - force both package filters true on a push, so validation covers the whole tree rather than the slice one merge touched — otherwise `ci-status` could go green having skipped `lint` and `test`; - cancel in-progress runs for pull requests only, so a trunk commit is never left without a verdict; - keep the release-intent check on pull requests, where it has a baseline and an unmerged change to gate. The hardening suite now asserts each of these, so the trigger cannot be quietly removed or hollowed out. Those same tests also skip the four release-publisher cases off POSIX: they drive a `#!/bin/sh` npm stub for a script that spawns a bare `npm`, and a permanently red suite is one nobody reads. The remaining half of the gap is external state this repo cannot assert. `docs/ci-cd-workflows.md` now records the live ruleset's verified contents and the exact one-rule change an administrator must apply. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The C3/C4/C5 proofs compile a real consumer against the built package to show the Band symbols resolve and the Thenvoi ones do not. Each spawned `node_modules/.bin/tsc` — a POSIX shell script whose Windows counterparts are `tsc.CMD`/`tsc.ps1` — so `spawnSync` returned ENOENT with a null status, and `status ?? 1` presented that as a compiler verdict. The proofs asserting new names compile failed; the ones asserting old names are rejected were one weakened assertion away from passing without a compiler ever running. Resolve the compiler's own JS entry and run it under the current Node binary, and throw when the spawn fails: a compiler that did not run must never be reportable as "compilation failed as expected". Verified live — pointing a negative case at a symbol that does exist now fails both assertions. The same POSIX assumptions ran through the rest of P-C5-1, so the tarball proof also gets a Windows-safe `npm pack`, a `tar` invocation that does not read `D:` as a remote host, a junction instead of a symlink needing elevation, and a Node-native scan replacing `grep -rl` — that last one was silently vacuous anywhere grep is absent, since missing stdout satisfied "no packed file leaks the legacy scope" without opening a file. Three copies of the compile helper become one in `tests/support/`, which also raises a clear error when `dist/` is missing instead of failing as a puzzling compile error. Two related reliability fixes: give the suite timeout headroom (the 5s default sat under the honest ~4.5s cost of the import-boundary proofs, and far under a real `tsc` run), and skip the SQLite store tests when `node:sqlite` is absent rather than failing — it is unflagged only from Node 22.13, below both the pinned toolchain and CI, where these tests run for real. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…estion Records the live `main-branch-protection` ruleset as verified against the API rather than as remembered: active, no bypass actors, rules limited to `deletion`, `non_fast_forward`, and `pull_request`. Review is already enforced there (one approval, stale dismissal, thread resolution); what is absent is any `required_status_checks` rule, so an approver can merge a red PR — which is what happened on #159. Adding that rule with the single `ci-status` context is the change that closes the gap. It is external GitHub state, so this only records the intended configuration and how to re-verify it. Separately, an earlier revision judged strict checks not worth the update-branch churn. That is worth revisiting — CI now takes about two minutes, `main` is the release branch rather than a plain trunk, `dev` is a compatibility lane rather than an integration buffer, and several open PRs are months behind `main`. The guard suites assert over global state, so a semantic conflict between two separately green PRs is realistic here. Strictness is deliberately written up as an open suggestion with both sides argued, not as an agreed decision: it belongs to whoever owns merge policy, and the gap above is closed either way. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Style pass over the branch's own changes:
- drop the unused `HAS_NODE_SQLITE` export and merge the two adjacent JSDoc
blocks it carried into one;
- use the exported `CompileResult` at the call sites that re-declared its shape
inline, and give `runNpm` a named `NpmResult` interface;
- route the last `/\r?\n/` split in the C6 guard through `readLines`, so the
rationale for it lives in exactly one place;
- group the new imports with the other internal modules instead of wedging them
between builtins and types;
- carry `expect(status, output)` to every positive compile assertion, not just
the C3 ones, since surfacing the diagnostic is the point;
- expand the tsconfig literal now that it is the single canonical copy;
- rename `POSIX_ONLY` to `SKIP_ON_WINDOWS`, which is what it selects.
`runNpm` keeps `shell: true` on Windows. Both shell-free alternatives were
measured and fail on Node 22: `spawnSync("npm.cmd", …)` returns EINVAL because
Node blocks .cmd without a shell (CVE-2024-27980), and resolving npm's JS entry
gives ERR_PACKAGE_PATH_NOT_EXPORTED. Every argument is now quoted rather than
only those containing spaces, and the comment records the measurements so the
next reader does not "fix" it back into EINVAL.
Two timeout gaps this surfaced, both mine:
- `COMPILE_PROOF_OPTS` set only the test timeout, but the tarball proof does its
packing in `beforeAll`, which Vitest governs with `hookTimeout`. Under
full-suite parallelism that hook exceeded 20s and skipped four tests. The
budget is now a named constant passed to those hooks explicitly.
- The 20s global was sized off a warm, standalone measurement of the
import-boundary proofs (~4.5s). Running the whole suite in parallel puts them
well past 20s, which is why they still failed intermittently — on this branch
and on its parent alike. 60s is sized against observed contention instead.
Verified with three consecutive full runs: 922 passed, 14 skipped, 0 failed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The strict-checks section had grown into a decision memo — arguments for, arguments against, a worked example — which belongs in the pull request where the decision was argued, not in an operational reference someone reads to find out how CI works. Strict checks are now agreed, so the "both sides, undecided" framing is also obsolete. What remains is the decision, one sentence on what it protects against, and the two things a reader actually needs operationally: that there is no merge queue and what adding one would require, and that Dependabot branches will go stale. Roughly forty lines become eighteen. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The branch-protection section had become a to-do: a dated snapshot of the live ruleset, its numeric id, a command to check what was currently configured, a retelling of the #159 incident, and instructions for an administrator. All of it goes stale the moment someone applies the setting, and a reference document that describes a moment in time is worse than one that says nothing — a later reader cannot tell which parts still hold. What belongs here is the intended configuration: `ci-status` as the single required check, why it is the only one worth requiring, and that checks are strict. Current state and the work to reach it belong in the pull request that raised them, which carries the verified ruleset dump and the steps. Keeps the standing caveat that rulesets are external state this repository cannot assert, with a command for checking the live values — true regardless of what is configured today. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
| include: [filename], | ||
| })); | ||
|
|
||
| const result = spawnSync(process.execPath, [TSC_ENTRY, "-p", join(dir, "tsconfig.json")], { |
There was a problem hiding this comment.
issue: this spawnSync call sets no timeout, so a hung tsc blocks the Node event loop directly — Vitest's testTimeout/hookTimeout (the whole point of COMPILE_PROOF_OPTS/COMPILE_PROOF_TIMEOUT_MS) can't fire while a synchronous spawn is blocking the same thread. Same gap in runNpm's spawnSync call at c5-package-symbols.test.ts:338. A hang here runs past the documented 120s budget until GitHub Actions' own job-level timeout eventually kills it, not Vitest's.
There was a problem hiding this comment.
Agreed — added an explicit timeout: COMPILE_PROOF_TIMEOUT_MS to this spawnSync call (and to runNpm's in c5-package-symbols.test.ts:338, same gap). A hang is now caught by the test's own budget via the existing result.status === null → throw branch, instead of running until the job-level timeout.
| const useShell = process.platform === "win32"; | ||
| const result = spawnSync( | ||
| "npm", | ||
| useShell ? args.map((arg) => `"${arg}"`) : args, |
There was a problem hiding this comment.
nit: wrapping each arg in bare double quotes doesn't stop cmd.exe from expanding %VAR% sequences inside a quoted segment — quoting neutralizes spaces/&/^ but not %. If os.tmpdir() ever produced a path containing a literal %...% run, cmd.exe would substitute or drop it before npm sees the argument, and npm pack would silently write the tarball to an unexpected path.
There was a problem hiding this comment.
Agreed — quoting doesn't stop cmd.exe's %VAR% expansion, and correctly escaping it (doubling % behaves differently in a batch file vs. a /c command line) is more machinery than this edge case is worth. Rather than ship a half-working escape, runNpm now throws if any argument contains % before it ever reaches the shell, so a path like that fails loudly instead of silently packing to the wrong destination.
| sdk: ${{ github.event_name == 'push' && 'true' || steps.filter.outputs.sdk }} | ||
| openclaw: ${{ github.event_name == 'push' && 'true' || steps.filter.outputs.openclaw }} | ||
| steps: | ||
| - name: Checkout code |
There was a problem hiding this comment.
nit: this checkout step still runs unconditionally on push, but the only step it feeds — dorny/paths-filter right below — is now gated if: github.event_name == 'pull_request' and never runs on push. Every trunk push does a full checkout for a step that's skipped.
There was a problem hiding this comment.
Agreed — added if: github.event_name == 'pull_request' to the checkout step to match paths-filter below it, so a trunk push no longer pays for a checkout nothing downstream uses.
AlexanderZ-Band
left a comment
There was a problem hiding this comment.
Reviewed the full diff (compile-proof helper, CI trunk trigger, line-ending fix, honest skips). Solid, well-reasoned change overall — three inline nits/issues found, none blocking:
- issue
compileProof.ts:79—spawnSynchas notimeout, soCOMPILE_PROOF_TIMEOUT_MScan't actually rescue a hungtsc/npm(same gap inrunNpmat c5-package-symbols.test.ts:338). - nit
c5-package-symbols.test.ts:340— Windows arg quoting doesn't neutralize cmd.exe's%VAR%expansion. - nit
ci.yml:42— checkout step runs unconditionally on push even though its only consumer (paths-filter) is now PR-only.
(The three comments above landed as separate auto-submitted reviews rather than one batch — a GitHub API quirk of posting comments without an explicit pending-review id. This review just wraps them up.)
|
suggestion: unrelated to this diff, but while verifying — root |
…d checkout - Give both spawnSync compiler/npm invocations an explicit timeout so a hang is caught by the test's own budget instead of running until the CI job's timeout kills the whole runner. - runNpm's cmd.exe quoting doesn't stop %VAR% expansion inside a quoted argument; rather than ship a partial escape, fail loudly if an arg could ever be misinterpreted that way. - Skip the changes job's checkout on push, since the only step it feeds (paths-filter) already only runs on pull_request. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QiXF25xJM7wbpTVjpL6JPq
Some compile-proof tests link against dist/ and throw if it's missing (CONTRIBUTING.md documents which ones). README listed pnpm test before pnpm build, which doesn't match that dependency; reordered and cross-referenced CONTRIBUTING.md. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QiXF25xJM7wbpTVjpL6JPq
7941de8 to
e8fffc0
Compare
|
Agreed and fixed here rather than deferred — reordered README's Development section to |
AlexanderZ-Band
left a comment
There was a problem hiding this comment.
Both remaining inline nits and the timeout issue addressed as described — verified in the diff (spawnSync timeouts added, % guard in runNpm, checkout gated to pull_request). Approving.
Closes INT-1312.
maincarried a permanently failing test, and nothing required CI to pass before merging — which is how it landed there. This makes the suite run truthfully on every platform and makes CI report on the trunk, then documents the one piece that is external GitHub state.Verified against the live API on 2026-08-31 (read-only; nothing was changed):
Review is already enforced — the
pull_requestrule setsrequired_approving_review_count: 1,dismiss_stale_reviews_on_push: true,required_review_thread_resolution: true. The missing control is orthogonal: a PR and an approval are mandatory; a passing one is not. An approver can merge a PR whose CI is red. That is exactly what happened on #159 — it merged with bothtestand the aggregateci-statusconcluding failure, and because CI never re-ran onmain, the failure was invisible from that moment until a manual review found it.An administrator needs to add one rule to ruleset
14198025(Settings → Rules →main-branch-protection):required_status_checksci-status— and only that one (integration: GitHub Actions)No workflow change is needed for either. The
ci-statusgate job already exists, already depends on every other job, and a test asserts it keeps doing so.docs/ci-cd-workflows.mdrecords both settings and the reasoning behind them.Why
ci-statusand onlyci-statusGitHub reports a job skipped by a job-level condition as successful.
lintandtestare intentionally conditional;changesandpackagingare mandatory.ci-statusencodes that distinction, always runs, and exposes one stable context — so adding or renaming a CI job never requires a ruleset edit.Why strict, and what it costs
Strict targets semantic conflicts: two PRs that each pass alone and break combined, with no textual conflict for git to catch. Not hypothetical here — the
C3–C7guards assert over global state (export-surface snapshots, whole-tree text scans, byte-identical doc regeneration), so one PR tightening a guard while another adds text the old guard allowed is green twice and red on the trunk.Four things made the case:
mainis the release branch, not just the trunk — Release Please cuts releases from it, so a bad interaction between two PRs is a publish candidate, not merely a broken trunk.devis a legacy compatibility lane, not a buffer, so nothing catches a bad interaction beforemain.main. A stale branch merging on a long-outdated green run is the riskiest merge this repo makes.Costs, accepted: there is no merge queue, so on a busy day a branch can go stale between updating and merging and the update is retried; and Dependabot branches go stale on every merge, though Dependabot rebases them itself.
docs/ci-cd-workflows.mdrecords what adding a queue would require if that race ever becomes a real cost.What changed
1. Compile proofs did not run off POSIX — and half of them could not fail
First, what
C3/C4/C5mean (they are defined nowhere but the filenames)C1–C7are the seven slices of the Thenvoi→Band rename, shipped as #150 (3173431). The labels live on only in test filenames, so for reference:@thenvoi/rest-client→@band-ai/rest-client,ThenvoiClient→BandClientband-client-conformanceLinearThenvoiBridgeConfig→LinearBandBridgeConfig, plus Band-first env fallbacklinear-c3-compatibilitythenvoiRoomId→bandRoomId, while deliberately retaining the physicalthenvoi_room_idSQLite schemac4-room-metadata-storage@thenvoi/sdk→@band-ai/sdkand every remaining public symbolc5-package-symbolsBAND_, default hostapp.band.aic6-no-stale-live-thenvoi,c6-urlsband_*, MCP →mcp__band__c7-tools-mcpP-C5-3,P-C3-1,P-STO-01are individual numbered proofs within a slice.C3, C4 and C5 are the three slices that changed public API surface, so they are the ones with compile proofs — the only way to show a consumer's code still typechecks across a breaking rename. They are the evidence that the
1.0rename was safe, which is why silently broken proofs mattered.Those three slices' proofs compile a real consumer against the built package to show Band symbols resolve and Thenvoi ones do not. Each spawned
node_modules/.bin/tsc— a POSIX shell script whose Windows counterparts aretsc.CMD/tsc.ps1. Reproduced directly:status ?? 1then laundered "never ran" into a compiler verdict. Proofs asserting new names compile failed; proofs asserting old names are rejected were one weakened assertion away from passing while proving nothing.Now resolved through the compiler's own JS entry under
process.execPath, with a throw on spawn failure — a compiler that did not run must never be reportable as "compilation failed as expected". Three copies of the helper became one intests/support/compileProof.ts, which also raises a clear error whendist/is missing instead of failing as a puzzling compile error.Verified non-vacuous: pointing a negative case at a symbol that does exist makes both assertions fail, as they should.
The same assumptions ran through
P-C5-1, so that proof also gets a Windows-safenpm pack, atarinvocation that does not readD:as a remote host, a junction instead of an elevation-requiring symlink, and a Node-native scan replacinggrep -rl— that last one was silently vacuous anywhere grep is absent, since missing stdout satisfied "no packed file leaks the legacy scope" without opening a single file.2. CI now validates the trunk
ci.ymltriggered onpull_requestonly, so a PR run only ever tested a merge preview, nevermainitself. Addedpush: [main], plus three changes that stop the trunk run being fake-green:trueon push — otherwiseci-statuscould pass having skippedlintandtest;cancel-in-progresslimited to PRs — a cancelled trunk run leaves that commit with no verdict;The hardening suite asserts all four, so the trigger cannot be quietly removed or hollowed out. This is detection, not prevention — it complements the ruleset rule above rather than replacing it.
3. Line endings were a test input
core.autocrlfwith no.gitattributesmeant Windows checkouts arrived CRLF, so guards reading the repo's own files compared against bytes CI never sees: the C6 brand-guard heading, plus seven\n-anchored regexes in the hardening suite. Fixed at the root with* text=auto eol=lf. Nothing tracked needs CRLF;.shand the Dockerfiles positively require LF.4. Honest skips instead of permanent red
node:sqliteis unavailable (unflagged only from Node 22.13; the pinned toolchain and CI are both well past it).#!/bin/shnpm stub for a script that spawns a barenpm.Both are inert on CI and verified so. Running the SQLite-gated tests with the module available gives 71 passed, 0 skipped. A permanently red suite is one nobody reads, which is how the original failure stayed invisible.
Also:
vitesttimeout headroom — the 5s default sat below the ~4.5s cost of the import-boundary proofs, making them a coin flip on a loaded runner.Finding 1 needed no code change
c5-package-symbols.test.ts's.release-holdassertion was already removed by #162 before this branch. Verified withgit log 1eb7bc9..origin/main -- packages/sdk/tests/c5-package-symbols.test.ts. Only a stale header comment remained, deleted here.Verification (Windows, Node 22.12)
pnpm -r lintpnpm -r typecheckEvery remaining skip is environment-gated and runs on CI.
Reviewer note
Everything in "What changed" is code and is meant to be reviewed normally.
The ruleset section is different: merging this PR does not apply it. Both settings have to be made by hand in the GitHub UI. Until they are, the trunk is still unprotected — and this PR itself could be merged red.
Follow-up, not addressed here
packages/sdk/package.jsondeclaresengines.node >=22.12, butnode:sqlite— which the session-room store needs — is unflagged only from 22.13, and CONTRIBUTING states 22.14. A consumer on exactly 22.12 gets a runtime error from a documented feature. Tightening published engine metadata is a product call, so the tests were made honest instead of changing it silently.🤖 Generated with Claude Code