Skip to content

feat(ci): per-stack CI runners — stack link, toolchain detection, toolcache pre-warming - #35

Merged
swimmesberger merged 5 commits into
mainfrom
wt/ci-per-stack-runners
Aug 19, 2026
Merged

feat(ci): per-stack CI runners — stack link, toolchain detection, toolcache pre-warming#35
swimmesberger merged 5 commits into
mainfrom
wt/ci-per-stack-runners

Conversation

@swimmesberger

Copy link
Copy Markdown
Owner

Summary

Builds the per-stack CI runners vision on top of the existing CI runner subsystem (docs/ci-runners/design.md): stacks become the place to turn CI on, deploys feed a detected toolchain profile, and the orchestrator pre-warms the runner toolcache so setup-* actions get local cache hits instead of downloading SDKs on every job.

What's in here

1. Runner cache volumes (prerequisite from the existing design)

  • Runners now mount both per-repo cache volumes: the existing toolcache (watchtower-ci-tool-{repo}/home/runner/_work/_tool) plus the new package cache (watchtower-ci-pkg-{repo}/home/runner/_pkg).
  • Runner env (inherited by job steps): NUGET_PACKAGES, npm_config_cache, GOMODCACHE, and DOTNET_INSTALL_DIR={toolcache}/dotnet — the last one because setup-dotnet installs to that env var's dir rather than using RUNNER_TOOL_CACHE, so it's what makes warmed .NET SDKs actually get found.
  • The workspace stays ephemeral (clean checkout per job).

2. Stack ↔ CI link ("Enable CI" per stack)

  • ci.enableForStack parses owner/name from the stack's GitHub RepositoryUrl (GitHubRepoUrl: HTTPS + SCP/SSH forms, github.com only) and creates — or re-enables — the matching CiRepo. Uniqueness on owner/name means multiple stacks of one repository share one runner pool and one cache.
  • The stack's clone credential usually lacks the runner-admin scope, so the chosen credential (explicit, or defaulting to the stack's) is probed via ValidateRepoAccessAsync before anything is written; a wrong-scoped PAT fails with a message naming the missing fine-grained PAT permission (repository Administration: read and write) and the way out.
  • ci.getStackCi is the read side the stack page polls: parse result, linked repo, live runner status, toolchain profile + warm state.

3. Toolchain detection (heuristics, zero extra cost)

  • Piggybacks on the clone every deploy already does: after a successful clone, CiToolchainRecorder (best-effort by contract — it can never fail a deploy) detects and persists a profile on the matching CiRepo.
  • Signal order: .github/workflows/*.yml setup-dotnet/node/go version inputs (strongest — what jobs actually install; supersedes manifests per kind, matrix expressions ignored) → global.json, *.csproj TFMs (bounded walk skipping node_modules/bin/obj/…), .nvmrc, package.json engines.node, go.mod; Dockerfile presence recorded as a flag.
  • Malformed/unreadable files contribute nothing; an empty profile is a valid result. Detection never blocks deploys or runners.
  • Persisted as JSON on ci_repos (migration AddCiToolchainProfile), shown in the UI ("detected: .NET 10.0, Node 22").

4. Toolcache pre-warming (re-warm on profile change only)

  • The orchestrator's reconcile loop spawns a one-shot warmer container (label watchtower.managed=ci-warmer, same restart-safe label tracking as runners) whenever the profile hash differs from the last successfully warmed hash. The hash covers kind+version pairs only, so source attribution / Dockerfile changes don't trigger pointless re-warms.
  • The generated bash script (CiWarmerScript, pure + unit-tested) installs into the shared volume in exactly the layout the setup-* actions probe: node/{ver}/{arch} + .complete, go/{ver}/{arch} + .complete, dotnet-install.sh --channel into the DOTNET_INSTALL_DIR dir. Partial versions ("22", "1.24") resolve to the latest release of the line at warm time; already-cached versions are skipped.
  • Outcomes persist on the repo (warmed_profile_hash/last_warmed_at on success, last_warm_error with log tail on failure) and surface in the UI as warmed/warming/failed/pending. Failures retry after a fixed 15-minute backoff and are never fatal — a cold cache just means jobs download their own tools.
  • Security: warmer containers get no PAT, no JIT config, no Docker socket — only the cache volume and public SDK hosts (nodejs.org, dot.net, go.dev). Versions are re-validated against a strict numeric pattern at the script boundary, so a hostile repo manifest can't smuggle shell syntax into the warmer.

5. Frontend — CI tab on the stack page

  • New modules/ci contributing a CI tab via stackDetailTabs (the volumes-module pattern): explanatory empty state for non-GitHub repos, "Enable CI" card with credential picker (defaults to the clone credential; shows the server's missing-permission message verbatim), and for linked stacks the live runner-slot status, toolchain chips + warm badge (incl. warm-error tail), and enable/max-runners/docker-socket settings.
  • rpc-schema.json + generated client regenerated (96 methods).

Future work (documented, not implemented)

Generated per-profile runner images built over the Docker socket, and warm-aware cache GC — see the design doc's new section.

Testing

  • 63 new tests: CiToolchainDetectorTests (signal precedence, normalization, bounded walk, malformed input), GitHubRepoUrlTests, CiWarmerScriptTests (incl. shell-injection guard), CiStackLinkTests (real generated handler pipelines with a stubbed GitHub probe: shared-pool convergence, credential fallback, named-permission error, re-enable).
  • Full suites green: Watchtower.Application.Tests 584/584, Watchtower.Api.Tests 178/178; npm run build (tsc + vite) passes; solution builds with 0 warnings.

Docs

docs/ci-runners/design.md gains a "Stack-linked CI, toolchain detection & cache pre-warming" section (link semantics, detection contract, warmer security posture, future work) and updated caching/milestone status.

Deploys already clone the repository, so toolchain detection piggybacks on
that clone at zero extra cost: after a successful clone, CiToolchainRecorder
(best-effort by contract - it can never fail a deploy) detects and persists
a toolchain profile on the CiRepo matching the stack's GitHub repository.

Signals, strongest first: .github/workflows setup-dotnet/node/go version
inputs (what jobs actually install; supersedes manifests per kind), then
global.json / csproj TFMs / .nvmrc / package.json engines / go.mod, plus
Dockerfile presence. All parsing is heuristic and degrades to 'no signal'
on malformed input; an empty profile is a valid result.

The profile is stored as JSON on ci_repos together with the warm-state
columns the toolcache warmer (next commit) converges on.
…ners

Runner containers now get both per-repo cache volumes from the design doc:
the existing toolcache volume plus watchtower-ci-pkg-{repo} at
/home/runner/_pkg, exposed to job steps via runner env (NUGET_PACKAGES,
npm_config_cache, GOMODCACHE). DOTNET_INSTALL_DIR points at the toolcache's
dotnet dir because setup-dotnet installs there rather than using
RUNNER_TOOL_CACHE.

The orchestrator's reconcile loop additionally converges the toolcache on
the detected toolchain profile: when the profile hash differs from the last
successfully warmed hash it spawns a one-shot warmer container (label
watchtower.managed=ci-warmer, same restart-safe tracking as runners)
running a generated bash script that installs the detected SDKs in exactly
the layout the setup-* actions probe, so jobs get local cache hits and skip
their downloads - the whole point of warming.

Warmers get no PAT, no JIT config, no Docker socket - only the cache volume
and public SDK downloads (nodejs.org, dot.net, go.dev); versions are
re-validated against a strict numeric pattern at the script boundary.
Outcomes persist on the repo (warmed hash / error tail); failures retry
after a fixed 15-minute backoff and never block runners or deploys.
Stacks are where repositories already live in Watchtower, so they become
the place to turn CI on. ci.enableForStack parses owner/name from the
stack's GitHub repository URL and creates - or re-enables - the matching
CiRepo; uniqueness on owner/name means multiple stacks deploying the same
repository converge on one shared runner pool and one cache.
ci.getStackCi is the read side the stack page polls: parse result, linked
repo, live runner status and the toolchain profile with its warm state.

The stack's clone credential usually lacks the runner-admin scope, so the
chosen credential (explicit, or defaulting to the stack's) is probed up
front and a wrong-scoped PAT fails with a message naming the missing
fine-grained PAT permission - repository Administration (read and write) -
and the way out, instead of failing silently in the reconcile loop.

CiRepoDto now carries the detected toolchain profile + warm status, and
GitHubApiClient is unsealed with a virtual scope probe so handler tests can
stub GitHub out (the GitCloneService precedent).
New ci frontend module contributing a 'CI' tab via the stackDetailTabs
extension point (the volumes-module pattern). Non-GitHub repositories get
an explanatory empty state; unlinked GitHub stacks get an 'Enable CI' card
with a credential picker (defaulting to the stack's clone credential) that
surfaces the server's precise missing-permission message verbatim; linked
stacks get live runner-slot status, the detected toolchain chips with the
toolcache warm badge (warmed/warming/failed/pending incl. the warm error
tail), and the enable/max-runners/docker-socket settings.

rpc-schema.json regenerated for the two new ci.* methods; typed wrappers
added to lib/api.ts with the matching lib/types.ts shapes.
Extends the design record with the implemented per-stack CI section: the
stack<->CI link semantics (shared CiRepo per owner/name), the detection
signal order and its never-blocks-deploys contract, the warmer mechanism
with its security posture (no PAT - public SDK downloads only), and what
stays future work (generated per-profile runner images, cache GC). Updates
the caching section and milestone 3 to reflect implementation status.
@swimmesberger
swimmesberger force-pushed the wt/ci-per-stack-runners branch from c054031 to 5d6d0b3 Compare August 19, 2026 07:15
@swimmesberger
swimmesberger merged commit 86ea591 into main Aug 19, 2026
2 checks passed
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