import InfoBox from '../components/InfoBox.astro';

Diagnosing a CI-only test failure ran into a wall, twice over. The failure did not reproduce locally, and two different tools that should have helped close that gap were both blocked by the devcontainer's own [outbound firewall](/posts/the-allowlist-was-too-generous#keep-the-friction-where-the-decision-is): installing the Node major version CI actually runs under, and `gh api`'s job-logs endpoint for reading what CI printed. Both calls came back the same way: no route to host.

The Node gap was straightforward. CI's workflow pins a newer major version than the devcontainer's default install, and the site nvm downloads Node releases from was unreachable from inside the sandbox, so there was no way to install a matching version and rule out a version-specific cause locally. The logs gap was subtler. `gh api repos/.../actions/jobs/<id>/logs` does not serve log content from `api.github.com` directly, it redirects to a signed URL on an externally hosted storage backend that GitHub rotates per run, and that backend was not reachable either. This compounds a separate, already-known limitation where the same token cannot read check-run results through the GraphQL API at all. Between the two, there was no way to see a failing job's real output short of asking a human to paste it in by hand.

<InfoBox title="Enumerate the hosts, don't wildcard them">
The firewall resolves each allowlisted domain to its current A records and only permits traffic to those specific IPs, so a broad wildcard pattern for the log-storage backend was never going to work with that mechanism. The provider's own API enumerates the fixed, bounded set of accounts that can back a run's logs, so the fix listed each of them individually rather than either extreme: a single guessed hostname that would break the next time the backing account rotated, or ingesting the provider's entire published IP range for that service, which would have pulled in far more than this allowlist is meant to cover.
</InfoBox>

Both hosts went into the firewall scripts, and the two verifications passed: the matching Node version installed, and `gh api`'s job-logs call started returning real content instead of a connection failure.

That same pull request's own CI run then failed on something unrelated: a duel-mode unit test expecting a Norwegian announcement string got a stale English one instead. The cause turned up in the test file rather than the game code. The suite shares one `duelGame` store instance across every test in the file, and a successful word submission schedules a real, un-mocked `setTimeout` to fire the turn announcement fifty milliseconds later. An earlier test in the same file submitted a word and moved on without waiting for that timer, leaving it dangling and free to fire mid-way through a later, unrelated test:

```typescript
await duelGame.submitWord();
expect(duelGame.pendingCombos).toEqual([]);
// A successful submission schedules announce()'s real setTimeout(…, 50).
// Drain it before the test ends so it can't fire mid-way through a later
// test and clobber that test's own assertion, this shared duelGame
// singleton persists across the whole file.
await new Promise((r) => setTimeout(r, 60));
vi.unstubAllGlobals();
```

Nothing about the flaky test was related to the firewall gap that led to it. It surfaced only because the CI run that would have caught it earlier could finally be inspected properly, instead of being read as an opaque pass or fail. The immediate diagnostic gap is closed for this kind of failure; whether the same shared-singleton pattern leaves other dangling timers elsewhere in the duel test suite is not yet checked.