Skip to content

fix(ci): make the test suite run truthfully and let CI report on main - #167

Open
yaron-thenvoi wants to merge 9 commits into
mainfrom
fix/int-1312-test-suite-ci-reliability
Open

fix(ci): make the test suite run truthfully and let CI report on main#167
yaron-thenvoi wants to merge 9 commits into
mainfrom
fix/int-1312-test-suite-ci-reliability

Conversation

@yaron-thenvoi

@yaron-thenvoi yaron-thenvoi commented Aug 31, 2026

Copy link
Copy Markdown
Collaborator

Closes INT-1312.

main carried 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.

⚠️ Action required by an admin — merging this PR does not enforce it

Verified against the live API on 2026-08-31 (read-only; nothing was changed):

$ gh api repos/band-ai/band-sdk-typescript/rulesets/14198025 \
    --jq '{rules: [.rules[].type], bypass: .bypass_actors}'

name:          main-branch-protection
enforcement:   active
applies to:    refs/heads/main
bypass_actors: none
rules:         deletion, non_fast_forward, pull_request
required_status_checks: ABSENT

Review is already enforced — the pull_request rule sets required_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 both test and the aggregate ci-status concluding failure, and because CI never re-ran on main, 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):

  • Rule: required_status_checks
  • Required check: ci-status — and only that one (integration: GitHub Actions)
  • Strict: ✅ enabled — "Require branches to be up to date before merging"

No workflow change is needed for either. The ci-status gate job already exists, already depends on every other job, and a test asserts it keeps doing so. docs/ci-cd-workflows.md records both settings and the reasoning behind them.

Why ci-status and only ci-status

GitHub reports a job skipped by a job-level condition as successful. lint and test are intentionally conditional; changes and packaging are mandatory. ci-status encodes 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 C3C7 guards 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:

  1. CI takes ~2 minutes (measured across the last 25 runs), so bringing a branch up to date is one button and a short wait.
  2. main is 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.
  3. There is no integration branch. dev is a legacy compatibility lane, not a buffer, so nothing catches a bad interaction before main.
  4. Long-lived PRs are the real risk — several open PRs are months behind 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.md records 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/C5 mean (they are defined nowhere but the filenames)

C1C7 are the seven slices of the Thenvoi→Band rename, shipped as #150 (3173431). The labels live on only in test filenames, so for reference:

Slice Renamed Proof test
C1 Release hold, package-content assertions, pnpm overrides (release-hardening)
C2 @thenvoi/rest-client@band-ai/rest-client, ThenvoiClientBandClient band-client-conformance
C3 Linear public types/config — LinearThenvoiBridgeConfigLinearBandBridgeConfig, plus Band-first env fallback linear-c3-compatibility
C4 Public room-id field thenvoiRoomIdbandRoomId, while deliberately retaining the physical thenvoi_room_id SQLite schema c4-room-metadata-storage
C5 The package itself — @thenvoi/sdk@band-ai/sdk and every remaining public symbol c5-package-symbols
C6 Default env prefix BAND_, default host app.band.ai c6-no-stale-live-thenvoi, c6-urls
C7 Platform tools → band_*, MCP → mcp__band__ c7-tools-mcp

P-C5-3, P-C3-1, P-STO-01 are 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.0 rename 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 are tsc.CMD/tsc.ps1. Reproduced directly:

spawnSync("packages/sdk/node_modules/.bin/tsc", ["--version"])
// → { status: null, error: { code: 'ENOENT' } }

status ?? 1 then 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 in tests/support/compileProof.ts, which also raises a clear error when dist/ 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-safe npm pack, a tar invocation that does not read D: as a remote host, a junction instead of an elevation-requiring symlink, and a Node-native scan replacing grep -rlthat 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.yml triggered on pull_request only, so a PR run only ever tested a merge preview, never main itself. Added push: [main], plus three changes that stop the trunk run being fake-green:

  • both package filters forced true on push — otherwise ci-status could pass having skipped lint and test;
  • cancel-in-progress limited to PRs — a cancelled trunk run leaves that commit with no verdict;
  • release-intent kept PR-only — it has no baseline on a push and would fail every trunk build.

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.autocrlf with no .gitattributes meant 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; .sh and the Dockerfiles positively require LF.

4. Honest skips instead of permanent red

  • SQLite store tests skip when node:sqlite is unavailable (unflagged only from Node 22.13; the pinned toolchain and CI are both well past it).
  • Four release-publisher tests skip off POSIX — they drive a #!/bin/sh npm stub for a script that spawns a bare npm.

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: vitest timeout 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-hold assertion was already removed by #162 before this branch. Verified with git 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)

Check Before After
SDK suite 29 failed / 903 passed 0 failed / 922 passed / 14 skipped
Release hardening 7 failed / 42 passed 0 failed / 46 passed / 4 skipped
pnpm -r lint 0 errors 0 errors
pnpm -r typecheck clean clean
OpenClaw suite 202 passed 202 passed

Every 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.json declares engines.node >=22.12, but node: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

yaron-thenvoi and others added 4 commits August 31, 2026 03:42
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>
@linear-code

linear-code Bot commented Aug 31, 2026

Copy link
Copy Markdown

INT-1312

yaron-thenvoi and others added 3 commits August 31, 2026 09:35
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")], {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@yaron-thenvoi yaron-thenvoi Aug 31, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@yaron-thenvoi yaron-thenvoi Aug 31, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread .github/workflows/ci.yml
sdk: ${{ github.event_name == 'push' && 'true' || steps.filter.outputs.sdk }}
openclaw: ${{ github.event_name == 'push' && 'true' || steps.filter.outputs.openclaw }}
steps:
- name: Checkout code

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@yaron-thenvoi yaron-thenvoi Aug 31, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 AlexanderZ-Band left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:79spawnSync has no timeout, so COMPILE_PROOF_TIMEOUT_MS can't actually rescue a hung tsc/npm (same gap in runNpm at 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.)

@AlexanderZ-Band

Copy link
Copy Markdown
Collaborator

suggestion: unrelated to this diff, but while verifying — root README.md's Development section (line 367-372) lists pnpm test before pnpm build, while CONTRIBUTING.md's new note (added in this PR) says some proofs need a build first (compileProof.ts's linkBuiltSdk throws if dist/ is missing). The dependency isn't new, just newly documented in one place and not the other. Worth a follow-up to reorder or cross-reference.

yaron-thenvoi and others added 2 commits August 31, 2026 13:27
…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
@yaron-thenvoi
yaron-thenvoi force-pushed the fix/int-1312-test-suite-ci-reliability branch from 7941de8 to e8fffc0 Compare August 31, 2026 13:28
@yaron-thenvoi

Copy link
Copy Markdown
Collaborator Author

Agreed and fixed here rather than deferred — reordered README's Development section to pnpm build before pnpm test, with a cross-reference to CONTRIBUTING.md's note on which tests need that build first.

@AlexanderZ-Band AlexanderZ-Band left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants