Skip to content

ci: 2-3x faster pipeline — parallel jobs, layer/build caches, parallel e2e - #47

Merged
ValgulNecron merged 12 commits into
mainfrom
ci/faster-pipeline
Jul 4, 2026
Merged

ValgulNecron merged 12 commits into
mainfrom
ci/faster-pipeline

Conversation

@ValgulNecron

@ValgulNecron ValgulNecron commented Jul 4, 2026 •

Copy link
Copy Markdown
Collaborator

What

Cuts CI wall-clock from a measured 28.5 min toward ~10 min, running the same suites (plus five e2e tests the old regexes had silently dropped). Coverage gates are untouched.

The workflow was still tuned for the old memory-constrained shared Forgejo runner. On GitHub-hosted runners every job is an isolated VM, so all of that serialization was pure wall-clock waste.

Commits

  1. ci: restructure job graph — drop max-parallel: 2 on the go matrix, drop needs: [go, helm] on e2e (started ~6.6 min late), drop the cross-run kind concurrency group (queued PRs behind each other), split the single 21.8-min serial e2e job into independent jobs each with its own kind cluster. Also ports the web coverage commit-status step off the dead Forgejo API path.
  2. test(e2e): bucket script — the -run regex lists move from ci.yaml into test/e2e/buckets.sh with a verify subcommand (new cheap CI job) proving buckets are disjoint + exhaustive, so a new test can never silently not run in CI again.
  3. test(e2e): run the five orphaned tests — TestGameServer_HeartbeatReachesRunning, TestModuleSourceUpload, TestAPI_CustomRole_Lifecycle, TestAPI_BuiltinRole_Immutable, TestAPI_PerNamespaceBinding_GrantsScopedAccess had never run in CI. Isolated commit → cleanly revertable if any is red.
  4. ci: caching — buildx + type=gha layer cache for the three e2e images (composite action, pattern from publish-edge), per-module go module+build caches (setup-go's default cache is broken by the go.work layout), pinned setup-envtest@release-0.19 + cached K8s 1.31 envtest assets, cached playwright chromium.
  5. test(e2e): parallelize the suite — t.Parallel() everywhere safe (waits overlap instead of summing); ociPushMu serializes the four module tests sharing the fixed-name oras-push Job; a one-shot restic warm-up Job (ensureResticRepo, sync.Once) kills the restic init race on the shared repo; TestHelmInstall_AllPodsReady scoped to chart pods so one-shot helper pods can't flake it. Buckets re-cut by login pressure (the per-IP/per-user login rate limiter, not CPU, bounds API-test wall time): operator (32 tests, no logins, -parallel 8) / api-auth (9, -parallel 4) / api-servers (14, -parallel 4), with TestAPI_LoginRateLimit as a separate final step since it deliberately drains the shared limiter. API pod memory 512Mi→1Gi for concurrent argon2id logins.
  6. test(operator): halve consistently() windows — ~10s of unconditional sleep trimmed; every guarded misbehavior is event-driven.
  7. test(api): cheap argon2id in tests — auth.SetFastHashParams (t=1/8MiB, restores on cleanup) wired into the hash-heavy helpers; dummyHash now lazy (sync.OnceValue) so linking the package no longer costs a 64MiB hash at init. One round-trip test stays on production params.

Measured baseline (main run 28681350157)

Job Duration Cause
e2e (kind) 21.8 min, starts +6.6 min needs + everything serial on one cluster
go (api/agent/audit) queued +1.1 / +4.8 / +5.6 min max-parallel: 2
e2e internals images 3:18, cluster 1:00, core 4:45, playwright 1:30, extended 7:22, bot 3:47 no caches, one cluster

Early results (run on commits 1–4, cold caches)

All go jobs concurrent and green; e2e web-live green in 10 min; the advisory Minecraft bot test passed (it consistently failed on the old shared runner — the port-forward corruption was load-induced).

🤖 Generated with Claude Code

ValgulNecron and others added 12 commits July 4, 2026 12:21
The workflow was still tuned for the old memory-constrained shared
self-hosted runner. On GitHub-hosted runners every job gets its own
isolated VM, so the serialization is pure wall-clock waste:

- drop max-parallel: 2 from the go matrix (5 modules now run
  concurrently instead of in three waves)
- drop needs: [go, helm] from e2e — it delayed the ~22 min e2e job by
  ~6.6 min for no resource benefit
- drop the cross-run gameplane-kind-e2e concurrency group that queued
  e2e between unrelated PRs (clusters no longer share a machine)
- split the single serial e2e job into four independent jobs, each
  with its own kind cluster: core, extended, web-live (playwright),
  and game-bot (advisory, unchanged continue-on-error)
- the web-live job bootstraps the admin via TestAPI_BootstrapAndLogin
  first, which writes test/e2e/.tmp/admin-password for globalSetup.ts
- drop the teardown and vite port-cleanup steps (ephemeral VMs) and the
  per-run cluster-name suffix (no shared docker daemon to collide in)
- paths-ignore: keep "website" (matches the submodule gitlink) and add
  website/** for future in-tree files
- port the web coverage commit-status step from the Forgejo API path to
  the GitHub API (github.api_url); it was silently 404ing
- add timeout-minutes to every job

Buckets are unchanged: same tests, same regexes, same -timeout values.

Measured baseline: 28.5 min wall. Expected after this change: ~12.5 min
(critical path becomes the extended e2e job).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: valgulnecron <39313199+ValgulNecron@users.noreply.github.com>
Move the two giant inline RUN_REGEX lists out of ci.yaml into
test/e2e/buckets.sh, which prints each bucket's anchored regex and —
the point of the exercise — verifies that the buckets are pairwise
disjoint and exhaustive against the actual Test functions in
test/e2e/*_test.go. A new cheap e2e-buckets CI job runs that check, so
a newly added e2e test that isn't assigned to a bucket now fails CI
instead of silently never running.

The verify pass found five tests the old inline regexes had silently
omitted (they have NEVER run in CI): TestGameServer_HeartbeatReachesRunning,
TestModuleSourceUpload, TestAPI_CustomRole_Lifecycle,
TestAPI_BuiltinRole_Immutable, TestAPI_PerNamespaceBinding_GrantsScopedAccess.
They are parked in an explicit, documented 'unbucketed' allowlist here
and get bucketed in the next commit, keeping that change revertable on
its own.

Also adds 'make test-e2e-bucket BUCKET=...' so local runs use the same
bucket definitions as CI. Bucket contents are byte-identical to the old
inline lists.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: valgulnecron <39313199+ValgulNecron@users.noreply.github.com>
TestGameServer_HeartbeatReachesRunning, TestModuleSourceUpload,
TestAPI_CustomRole_Lifecycle, TestAPI_BuiltinRole_Immutable, and
TestAPI_PerNamespaceBinding_GrantsScopedAccess existed in the suite but
were missing from the old inline ci.yaml regexes, so they had never run
in CI. Assign them to buckets (GameServer/roles → core, module upload →
extended) and empty the unbucketed allowlist.

Isolated commit on purpose: if any of these turns out red in CI, revert
this without touching the pipeline restructure.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: valgulnecron <39313199+ValgulNecron@users.noreply.github.com>
Nothing in the pipeline was cached, so every run re-downloaded modules,
recompiled from scratch, re-fetched the envtest apiserver binaries, and
re-downloaded chromium:

- new .github/actions/e2e-images composite action builds the three e2e
  images through buildx with cache-from/to type=gha (same pattern as
  publish-edge.yaml), replacing plain 'make e2e-images' in all four e2e
  jobs — warm builds skip the module download and most of the compile
- explicit per-module go module+build caches in the go matrix (setup-go's
  built-in cache keys off a repo-root go.sum this go.work layout doesn't
  have, and one shared key would only ever save the first leg's cache);
  the e2e jobs share one 'e2e' cache for the test-binary compile
- pin setup-envtest to release-0.19 (matches controller-runtime v0.19.0;
  @latest was unpinned) in ci.yaml and the Makefile, and cache the
  ~/.local/share/kubebuilder-envtest assets so the K8s 1.31 binaries
  aren't re-downloaded per run
- cache ~/.cache/ms-playwright in web-e2e-mock and e2e-web-live keyed on
  web/package-lock.json

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: valgulnecron <39313199+ValgulNecron@users.noreply.github.com>
The suite was strictly sequential, yet most tests are ~all waiting:
cron windows (1-min floor), restic round-trips, agent mTLS startup,
module registry indexing. t.Parallel() on every test overlaps those
waits instead of summing them. Two tests stay serial by design: the
login rate-limit test (below) and the advisory Minecraft bot.

Shared state that parallelism exposes, and how it's handled:

- the four module tests delete-and-recreate the fixed-name oras-push
  Job (and the signed test the cosign keypair) — a new ociPushMu mutex
  serializes them against each other while they still overlap with the
  rest of the suite
- concurrent first backups race 'restic init' on the empty shared repo;
  the loser's Job pod fails once and the Backup controller reports
  Failed (Failed>0 is checked before Active), which waitBackupSucceeded
  treats as terminal. New ensureResticRepo(t) helper (sync.Once) seeds
  the repo via a one-shot warm-up Job before any backup test runs,
  replacing the per-test fixture applies
- TestHelmInstall_AllPodsReady demanded Ready on EVERY gameplane-system
  pod; one-shot helper pods (healthz probes, oras/cosign jobs) in
  Succeeded phase would flake it. Scoped to the operator/api pods,
  which is what its doc comment promises anyway
- the API login rate limiter (per-IP 5/min burst 10, per-user 3/min
  burst 6) is the real bound on API-test wall clock: every test shares
  one client IP through the port-forward and logs in as e2e-admin. The
  CI buckets are re-cut by login pressure — operator (32 tests, zero
  logins, -parallel 8), api-auth (9, -parallel 4), api-servers (14,
  -parallel 4) — instead of core/extended
- TestAPI_LoginRateLimit deliberately drains those buckets. Go runs
  non-parallel tests FIRST, so keeping it serial in-bucket would starve
  everyone else's logins — it runs as a separate final go test step
- APIClient's 429 retry ceiling raised 5→7 attempts (capped 30s step)
  for logins queued behind the refill; Eventually polls at 500ms
  instead of 1s; the e2e API memory limit goes 512Mi→1Gi since up to
  4 argon2id logins (64Mi each) can now hash concurrently

Same tests, same assertions — only the scheduling changed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: valgulnecron <39313199+ValgulNecron@users.noreply.github.com>
consistently() sleeps its entire window unconditionally on every call —
~19.5s of guaranteed serial sleeping across 11 call sites, plus two
500ms settling sleeps. Every misbehavior these windows guard against is
event-driven: a wrong reconcile fires within milliseconds of the
triggering write in envtest (poll interval is 200ms), so a 750ms-1.5s
window observes the same failures the old 1-3s windows did. The
cron-shaped guards (suspend honored, invalid cron, Forbid) could only
ever catch the immediate-fire bug class anyway — a fires-at-the-next-
minute bug is out of reach of any sane window, shortened or not.

agent_certs' window is already at the 1s floor and stays put.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: valgulnecron <39313199+ValgulNecron@users.noreply.github.com>
Every HashPassword in tests ran at production cost — 64 MiB and t=3 per
call — multiplied across seedUser in the whole handler suite, the auth
round-trip tests, and each bootstrap-admin invocation. On top of that,
dummyHash was computed at package init, so every test binary that so
much as links the auth package paid one full-cost hash before running
anything.

- argonTime/argonMemory become package vars; new auth.SetFastHashParams
  (takes just the Cleanup-bearing subset of testing.TB, so no testing
  import in prod code) drops them to t=1/8MiB for a test and restores
  on cleanup. VerifyPassword decodes its parameters from the PHC hash
  string, so cheap hashes verify cheap and production hashes are
  untouched.
- dummyHash moves to sync.OnceValue: computed on first VerifyDummy, not
  at init.
- Wired into the hash-heavy helpers: auth's and handlers' seedUser, the
  cmd runBootstrap helper, and the salt/round-trip tests.
  TestHashAndVerify deliberately stays on production parameters so the
  real cost path keeps getting exercised.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: valgulnecron <39313199+ValgulNecron@users.noreply.github.com>
First parallel CI run confirmed the login rate limiter as the binding
constraint, two ways:

- TestAPI_LoginPrivacy observes RAW login status codes (it bypasses
  APIClient's 429 retry on purpose) and got a 429 where it asserts 401
  because parallel neighbors had drained the per-IP bucket. Drop its
  t.Parallel(): Go runs non-parallel tests to completion before the
  parallel phase, so it now sees a fresh limiter by construction.
- the api-servers bucket made ~14 admin logins against a per-username
  budget of burst 6 + 3/min refill; two tests exhausted even the 7-try
  retry ceiling, and the retry storm's argon2id load knocked on into
  TestAPI_LifecycleRestart's restart budget. Split it into api-rbac (7
  tests) and api-agent (7 tests) so each cluster's limiter only absorbs
  ~7 admin logins — about one burst — per job.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: valgulnecron <39313199+ValgulNecron@users.noreply.github.com>
The composite action ran three sequential build-push-action builds and
every e2e job exported the same cache scopes. Measured on the first
cached run: ~6 min per job — WORSE than the uncached 3:18 baseline —
because the compile layers can never cache across commits (the source
COPY invalidates them), each job paid ~1 min re-uploading identical
cache, and the three compiles ran back to back.

Switch to docker buildx bake (new docker-bake.hcl, shared with local
use) so the three images build concurrently, and add an export-cache
input to the composite action so exactly one job per run (e2e-web-live,
the shortest) writes the cache while everyone else only imports the
dependency-download layers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: valgulnecron <39313199+ValgulNecron@users.noreply.github.com>
New e2e tests must land in a bucket (buckets.sh verify gates CI), call
t.Parallel(), and budget API logins against the per-IP/per-user rate
limiter — none of which is discoverable without reading the CI history.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: valgulnecron <39313199+ValgulNecron@users.noreply.github.com>
The test POSTed :restart as soon as pod-0 existed, without waiting for
the agent sidecar. The restart handler patches suspend=true, waits up
to 45s for the StatefulSet to drain, then patches suspend=false; the
operator's graceful stop needs the agent to run the stop sequence and
otherwise waits out the 30s stop-grace period. Under the parallel
suite's load the agentless drain exceeds the 45s window, the resume
patch races the reconcile, the operator coalesces the two suspend
patches, and the restart becomes a silent no-op — pod UID never
changes and the test times out. Reproduced twice in CI, both times as
the bucket's only failure.

Wait for the agent (requireAgentReady, same helper the other agent
tests use) so the test restarts what the dashboard restarts: a running
server. The drain-window race itself is a real product wart under
load — flagged in PR #47 as an operator-side follow-up (rule 10: a
restart primitive belongs in the reconciler, not in handler patch
choreography).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: valgulnecron <39313199+ValgulNecron@users.noreply.github.com>
Two more parallel-suite findings from CI runs 28704030433/28704239141:

- api-auth at 9 tests still overran the per-IP login bucket (~14 logins
  vs burst 10 + 5/min): TestAPI_PerNamespaceBinding_GrantsScopedAccess
  exhausted the retry ceiling and its siblings passed only after
  60-90s of 429 queueing. Move the three role-management tests to their
  own api-roles bucket/cluster.
- TestAPI_LifecycleRestart kept failing even after waiting for the
  agent: restart is two suspend patches around a bounded drain-wait,
  and with the reconcile queue deep (parallel bucket) both patches land
  between two reconciles of the GameServer — the operator coalesces
  them and the restart is silently lost (pod UID never changes). Run it
  without t.Parallel(): Go executes non-parallel tests before the
  parallel phase, giving it the idle operator its contract currently
  needs. The lost-restart race itself is an operator-side follow-up
  (restart should be a reconciler primitive, not handler patch
  choreography).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: valgulnecron <39313199+ValgulNecron@users.noreply.github.com>
@ValgulNecron
ValgulNecron merged commit 6c361a6 into main Jul 4, 2026
18 checks passed
@ValgulNecron
ValgulNecron deleted the ci/faster-pipeline branch July 4, 2026 11:21
@ValgulNecron
ValgulNecron restored the ci/faster-pipeline branch July 4, 2026 11:30
@ValgulNecron
ValgulNecron deleted the ci/faster-pipeline branch July 5, 2026 11:19
@ValgulNecron
ValgulNecron restored the ci/faster-pipeline branch July 5, 2026 11:30
@ValgulNecron
ValgulNecron deleted the ci/faster-pipeline branch July 5, 2026 21:03
ValgulNecron pushed a commit that referenced this pull request Oct 9, 2026
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Claude <noreply@anthropic.com>
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.

1 participant