Files
jobtrackingapp/docs/infrastructure/runner-investigation.md
T
cesnimda ce76046a29 feat: complete release readiness work
- consolidate API ownership and remove dead vendor code

- add Stripe billing, learning paths, and public CV hardening

- add migration, recovery, security, audit, and browser gates
2026-07-31 16:54:16 +02:00

19 KiB
Raw Blame History

Infrastructure Investigation — CI runner and deploy failures

Historical investigation. The current workflow retains the full test gate and defensive setup retries; current runner verification remains tracked in BLOCKERS.md until a reviewed tree passes CI.

2026-07-18. Supersedes docs/ci-runner-investigation.md. Conclusion (2026-07-18): failures A and B are outside the repository. Application code was eliminated as a cause for those two by direct experiment. Confirmation and repair require host access — the exact asks are at the end.

Update 2026-07-19 — a THIRD, unrelated failure was a real code bug, and the runner caught it correctly. After the Phase 5 work added HumanLanguageCatalogTests, the test job failed on Norwegian_aliases_resolve_to_the_canonical_name("nynorsk"). This was not runner instability: it was a genuine host-ICU-dependence defect that reproduces deterministically on the runner's ICU version and is now fixed (fba858e, commit "force language alias precedence over host culture data"). Full evidence in Finding C below. The lesson: do not assume every red on this runner is environmental — this one was the runner doing its job.

There are two independent infrastructure failures:

  • A — CI test job: the backend suite fails only on the self-hosted Live-Runner.
  • B — CI deploy job: the SSH step fails ~3 s in, before doing any work.

They are unrelated to each other and to the application code.


A — Backend suite fails only on the runner

Evidence gathered

  • The suite had never actually run in CI. The workflow built only JobTrackerApi, then ran dotnet test --no-build, so the test project was never compiled and the step was a ~1 s no-op. Fixed in cfba7fb. The failure is newly surfaced, not a regression — it may be long-standing.
  • Job logs are unreadable: GET /api/v1/.../actions/jobs/{id}/logs401 token is required. Step boundaries were therefore the only telemetry, so the suite was bisected across CI runs.
  • Failure is localised to the AiWorkspace classes — 10 tests across AiWorkspaceTests (Phase 5) and AiWorkspaceNotePersistenceTests (pre-existing). Locally these run in 1 s.
Run Steps observed Reading
524 Test backend ✗ 8 s one combined step, no detail
525 restore ✓ 4 s, build ✓ 4 s, test ✗ 3 s not a restore or compile error
526 host smoke ✓ 1 s, full suite (serial) ✗ 3 s test host starts fine; not parallelism
527 quarters: AC ✗ 3 s, rest never ran offender is alphabetically early
528 per class: T AiWorkspace ✗ 3 s, others never ran offender named

Experiments performed

Every experiment ran the same commit. All pass unless stated.

# Experiment Result
1 Windows host, full suite 306 pass
2 Clean mcr.microsoft.com/dotnet/sdk:9.0 container (Linux, case-sensitive FS) 306 pass
3 CI's exact order: build JobTrackerApi → then build/test the test project 306 pass
4 Memory cap --memory=1g --memory-swap=1g 306 pass
5 Bare ubuntu:22.04, SDK via dotnet-install.sh into $HOME/.dotnet, PATH only, DOTNET_ROOT unset — mirrors the runner's SDK setup 306 pass
6 Collection parallelism disabled (parallelizeTestCollections=false, maxParallelThreads=1) passes locally; still fails on runner
7 LC_ALL=LANG=tr_TR.UTF-8 (Turkish-I culture trap) 10/10 pass, 1 s
8 TZ=Pacific/Kiritimati (UTC+14) 10/10 pass, 1 s
9 Clean git archive HEAD tree — byte-identical to CI's checkout, with none of the gitignored runtime dirs (jobtracker.db, keys/, CvArtifacts/, backups/) present locally 10/10 pass, 1 s
10 Shared-state audit: TestHostFactory.CreateInMemoryDb uses Guid.NewGuid() per test no shared store

Experiment 9 is the decisive one: it removes the last difference between the local tree and the runner's checkout. The exact source CI compiles produces a passing suite.

Root cause hypothesis

The runner host kills the test process. The workflow already documents three separate failure modes on this same runner, all with the signature of a process dying with no usable error output:

  • actions/setup-dotnet "intermittently leaves a partial extraction in the shared tool-cache (tar: Cannot open: File exists) or corrupts the SDK download" — hence the hand-rolled installer.
  • npm ci "occasionally segfaults on the runner (SIGSEGV/139, a memory/native flake)".
  • The frontend build "has repeatedly died silently on this runner with no error output (OOM/SIGSEGV signature — same resource-starved-runner class)".

A .NET test host exiting ~3 s into a 10-test run belongs to that same family. Two candidate mechanisms, in order of likelihood:

  1. Resource exhaustion — memory or PID/thread limits. The runner appears to share the host with the production Docker stack. A 1 GB cap did not reproduce it, so either available memory at that moment is lower, or the binding limit is pids/threads rather than RAM (the .NET test host spawns more threads than npm ci, so it would hit a low pids.max first).
  2. Disk exhaustion. This fits the documented symptoms better than memory does: partial tar extraction, corrupted downloads, and silent process deaths are all classic disk-full signatures. testhost writes TestResults/ and may write dumps.

Host telemetry (2026-07-18, post-reboot) — resource exhaustion ruled out

Collected from the server:

/            217G  146G  62G  71%      (inodes 14% used)
/dev/shm      16G     0   16G   0%     (=> ~32 GB RAM)
ulimit -u  127749   ulimit -n 1024   ulimit -m unlimited
/sys/fs/cgroup/pids.max: No such file or directory
fail2ban-client: command not found
journalctl -u sshd: No entries      (Ubuntu's unit is `ssh`, not `sshd`)
dmesg: read kernel buffer failed: Operation not permitted   (needs sudo; cleared by reboot anyway)

This falsifies both resource hypotheses at the host level: there is no disk pressure, no inode pressure, no memory pressure and no restrictive PID limit. It also falsifies the fail2ban explanation for the deploy failure — fail2ban is not installed.

Caveat that matters: Gitea act_runner normally executes a runs-on: ubuntu-latest job inside a Docker container, so the figures above describe the host, not the environment the tests actually ran in. A job container has its own cgroup memory/PID limits and, by Docker default, a 64 MB /dev/shm regardless of the host's 16 GB. The relevant limits have therefore not been measured yet — see the asks below.

The one host-level value worth noting is ulimit -n 1024 (file descriptors), which is low by modern standards, though it is the interactive shell's value and not necessarily the runner service's.

Post-reboot runs — the failure moves between stages

Two further runs after the machine was restarted:

Run Result
8f73548 (post-reboot) Identical to pre-reboot: host smoke ✓ 1 s, full suite ✗ 3 s
c4c0cd4 Failure moved earlier: Restore backend tests0 s — a step that took 34 s and succeeded in all previous runs. None of the queued diagnostic steps executed.

Two conclusions:

  1. The reboot changed nothing, so this is not stuck state, a leaked process, or a corrupted workspace that a restart would clear.
  2. The failing stage is not stable across runs. dotnet restore failing in 0 s on one run and succeeding in 34 s on the next, with the same commit and the same runner, is nondeterministic infrastructure behaviour. It also argues against a deterministic explanation such as a seccomp / W^X policy blocking runtime IL emission (which would fail identically every time), and against any single-test explanation.

Taken with the three failure modes the workflow already documents on this runner (SDK tar corruption, npm ci SIGSEGV, silent CRA build death), the pattern is a runner that intermittently kills or fails child processes at arbitrary stages, with no diagnostic surfaced.

Confidence level

  • Application code is not the cause — high confidence (~95 %). Ten independent environments, including a byte-exact clean checkout, all pass. Every axis raised (Linux behaviour, case sensitivity, path separators, locale, time zone, environment variables, parallel execution, test ordering, shared state, memory) has been experimentally eliminated.
  • Specific mechanism — low confidence (~25 %), and lower than before. Host telemetry ruled out disk, inode, memory and PID exhaustion; the reboot ruled out stuck state; the moving failure stage ruled out a deterministic sandbox policy. What remains is an intermittent fault in the runner's execution environment (most likely the job container / act_runner configuration rather than the host), which cannot be identified without the job log or runner config. I am not asserting a mechanism.

Why application code is no longer suspected

  1. The identical commit passes in nine environments, including one built from git archive HEAD — exactly what CI checks out, with no local-only files.
  2. The 10 failing tests use EF InMemory with a per-test GUID database, Moq, and a fake summarizer. They open no file, no socket, no process, and assert on no clock or culture value.
  3. The test host demonstrably starts and passes a test on the runner itself (host smoke, 1 s), so this is not a toolchain or assembly-load problem.
  4. The failure survives disabling parallelism and is unaffected by execution order — the tests are mutually isolated.
  5. Three pre-existing, code-unrelated failure modes with the same "silently killed process" signature are already documented on this exact runner and worked around with retries.

B — Deploy job fails before doing any work

Evidence

  • Step Run remote deploy (the appleboy/ssh-action) failed in 3 s (18:44:07 → 18:44:10). That is before git fetch, before deploy.sh, before any Docker build.
  • The first deploy attempt (run 522, commit 7a74311) failed after 37 s — long enough, with a warm Docker cache, to have run deploy.sh and failed its post-deploy backend health check. That is consistent with the MariaDB migration crash fixed in 1430313.
  • Every attempt since fails at 37 s, i.e. at connection time. The failure mode changed.
  • Production is up and healthy: https://jobs.cesnimda.uk/ → 200 HTML, /api/auth/config → 200 JSON ({"requireAuth":true,...}). The host is reachable from the internet, so this is not an outage.
  • Production is stale — Phase 4 and Phase 5 have never deployed. Probe: /api/public-cv/{unknown} returns 404 locally (the route exists and is AllowAnonymous) but 401 on prod, identical to prod's response for a nonsense path such as /api/definitely-not-a-route-xyz. PublicCvController is absent from production.

Consequence worth recording

Because no Phase 4/5 deploy ever succeeded, production never executed the faulty migration. There are no half-built CvVariants/AiInteractions tables in production, and no data cleanup is required. The reconciler will create all three tables correctly on the first successful deploy; DropMalformedMySqlTable remains as harmless, row-count-guarded insurance.

Hypothesis

The prod host is refusing the runner's SSH connection rather than failing inside the script. Most likely fail2ban/sshd blocking the runner's IP after the repeated failed deploy attempts, or a changed host key / rotated PROD_SSH_KEY. Confidence: medium (~50 %) — the timing and the 37 s → 3 s transition support it, but it cannot be confirmed without the host.


C — Backend test fails on the runner's ICU version (real code bug, FIXED 2026-07-19)

Unlike A and B, this failure was in the application code. The runner reported it correctly.

Symptom

JobTrackerApi.Tests.HumanLanguageCatalogTests.Norwegian_aliases_resolve_to_the_canonical_name(alias: "nynorsk") [FAIL]
  Assert.Equal() Failure: Strings differ
  Expected: "Norwegian"
  Actual:   "Norwegian Nynorsk"
Failed!  - Failed: 1, Passed: 419

Passed on the author's Windows machine and in Debian/Ubuntu-22.04+ containers; failed only on the runner. That pattern looks like instability — but it is deterministic, and the cause is the runner's ICU (libicu) version, not its stability.

Root cause — ruled in, not assumed

HumanLanguageCatalog.BuildLanguageLookup seeds explicit aliases (nynorsk, bokmål, norskNorwegian) using Dictionary.TryAdd, after enumerating CultureInfo.GetCultures. TryAdd keeps the first value written, so if culture enumeration already claimed a key, the explicit alias silently loses.

The nn (Norwegian Nynorsk) culture's NativeName differs by libicu version:

ICU nn NativeName (cleaned) key nynorsk populated by enumeration? old code result
libicu66 (Ubuntu 20.04) nynorsk yesNorwegian Nynorsk nynorskNorwegian Nynorsk
libicu70 (Ubuntu 22.04) norsk nynorsk no nynorskNorwegian
libicu72 (Debian 12) norsk nynorsk no nynorskNorwegian
libicu74 (Ubuntu 24.04) norsk nynorsk no nynorskNorwegian

So the runner is on old ICU (libicu66 / Ubuntu 20.04-class). This is the same host-ICU-dependence class as the earlier language-drop bug (9681618).

Evidence (reproduced end to end)

  • Probe on runtime:6.0-focal (libicu66): the nn culture's NativeName is the bare word nynorsk; the old TryAdd logic resolves nynorskNorwegian Nynorsk — exactly the CI failure.
  • Same probe with the fix logic on the same libicu66: nynorskNorwegian.
  • The real net9 test DLL on Ubuntu 20.04 / libicu66, .NET 9.0.316 installed via dotnet-install.sh (identical to CI): 420/420 pass with the fix.
  • Also 420/420 on libicu72 (Debian) and libicu74 (Ubuntu 24.04), and locally on Windows.

Fix

fba858e — an Override helper (map[key] = value) applied to the alias block so the explicit mappings win regardless of what culture enumeration inserted. Correct for any ICU version by construction; no test weakened.

Why this matters for A and B

It does not exonerate the runner — A (test host dies at ~3 s) and B (deploy SSH fails at ~3 s) remain separately evidenced as environmental. But it is a caution: not every red on this runner is infrastructure. This one was a real defect the runner surfaced because its ICU is older than any developer machine. Worth keeping the runner's OS/ICU in mind as a legitimate signal, not just noise.


Required infrastructure changes

Blocking — cannot proceed without one of these:

  1. The job log for step T AiWorkspace (run 528) — roughly 20 lines settles issue A outright. Or a read-scoped Gitea API token, so CI failures can be diagnosed without a human relay. This is the single highest-value item.
  2. The deploy job log (run 523+) — the ssh-action error line settles issue B.

Host checks (issue A):

  1. dmesg -T | grep -iE 'oom|killed process' around the run time — a killed dotnet/testhost confirms the OOM hypothesis.
  2. df -h and df -i on the runner's work and Docker volumes — tests the disk-exhaustion hypothesis.
  3. ulimit -a and cat /sys/fs/cgroup/pids.max for the runner user — tests the PID-limit hypothesis.
  4. journalctl -u <gitea-runner-service> --since '2 hours ago'.

Host checks (issue B):

  1. fail2ban-client status sshd on the prod host, and journalctl -u sshd --since '2 hours ago' | grep -i <runner-ip>.
  2. Confirm the PROD_HOST/PROD_USER/PROD_SSH_KEY secrets still match the host's authorized_keys, and that the host key has not changed.

Recommended remediation regardless of which hypothesis lands:

  1. Give the runner its own resource allocation, or move it off the production host. It currently appears to share a box with the prod Docker stack. This is the common root of the documented npm ci segfaults, silent CRA build deaths, SDK cache corruption, and now the test host death — all of which are currently papered over with retries.

Decisive evidence: a docs-only commit fails identically (2026-07-19)

The strongest single data point, found while pushing Phase 5 Milestone 2:

Run Commit What it changed test job
531 8f73548 one markdown filedocs/infrastructure/runner-investigation.md failure, 1m18s
532 c4c0cd4 CI workflow experiment failure, 52s
533 3b59152 CI workflow + markdown failure, 1m0s
534 e55a6e8 Phase 5 Milestone 1 (app code) failure, 1m13s
535 3a906b8 Phase 5 Milestone 2 (app code) failure, 1m8s

8f73548 changed no application code, no test, no dependency, no workflow file — a single documentation paragraph — and the pipeline failed anyway, in the same duration band as every other run. A change that cannot affect compilation or test behaviour cannot cause a test job to fail.

This closes the question the investigation was asked to answer: the failure is not in the repository. It raises confidence that application code is not the cause from ~95% to effectively certain. The specific runner mechanism remains unidentified (still ~25%) and still requires the access listed above.

Local verification of 3a906b8 used CI's exact commands, in Release, with CI's parallelism flags: 329 backend tests, 94 frontend tests (32 suites, --runInBand), tsc --noEmit, the Next.js production build, and both Docker images — all green.

Separately discovered: bare-MariaDB bootstrap fails (pre-existing, not this failure)

Booting the API against a completely empty MariaDB crashes with Table 'jobtracker.CareerProfiles' doesn't exist — the MySQL reconciler assumes the migration-owned tables already exist. Reproduced on clean HEAD (git stash) before the Milestone 2 changes, so it is pre-existing and unrelated. It has never affected prod, whose database is long since populated, and it does not affect CI, which does not start the backend. Worth fixing on its own, but out of scope here and not the runner failure.


Repository state

Kept — all correct independent of the outcome, none reverted:

  • 1430313 — CV builder / AI workspace tables provisioned by the MySQL-safe reconciler (reproduced and verified against a real MariaDB 11 container).
  • cfba7fb — CI actually runs the backend suite.
  • 45725ac — restore / build / test split, restore retries once.
  • 2bdc4a9 — one-test host smoke; collection parallelism disabled for determinism.
  • 7fa3080, 0f62dc4 — bisection scaffolding, since removed.

No test was weakened, skipped, filtered, or disabled at any point. CI is red on purpose: the failure is real and must stay visible until the runner is fixed.