feat(ci): per-stack CI runners — stack link, toolchain detection, toolcache pre-warming - #35
Merged
Merged
Conversation
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
force-pushed
the
wt/ci-per-stack-runners
branch
from
August 19, 2026 07:15
c054031 to
5d6d0b3
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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)
watchtower-ci-tool-{repo}→/home/runner/_work/_tool) plus the new package cache (watchtower-ci-pkg-{repo}→/home/runner/_pkg).NUGET_PACKAGES,npm_config_cache,GOMODCACHE, andDOTNET_INSTALL_DIR={toolcache}/dotnet— the last one becausesetup-dotnetinstalls to that env var's dir rather than usingRUNNER_TOOL_CACHE, so it's what makes warmed .NET SDKs actually get found.2. Stack ↔ CI link ("Enable CI" per stack)
ci.enableForStackparsesowner/namefrom the stack's GitHubRepositoryUrl(GitHubRepoUrl: HTTPS + SCP/SSH forms, github.com only) and creates — or re-enables — the matchingCiRepo. Uniqueness onowner/namemeans multiple stacks of one repository share one runner pool and one cache.ValidateRepoAccessAsyncbefore 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.getStackCiis 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)
CiToolchainRecorder(best-effort by contract — it can never fail a deploy) detects and persists a profile on the matchingCiRepo..github/workflows/*.ymlsetup-dotnet/node/goversion inputs (strongest — what jobs actually install; supersedes manifests per kind, matrix expressions ignored) →global.json,*.csprojTFMs (bounded walk skippingnode_modules/bin/obj/…),.nvmrc,package.jsonengines.node,go.mod; Dockerfile presence recorded as a flag.ci_repos(migrationAddCiToolchainProfile), shown in the UI ("detected: .NET 10.0, Node 22").4. Toolcache pre-warming (re-warm on profile change only)
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.CiWarmerScript, pure + unit-tested) installs into the shared volume in exactly the layout thesetup-*actions probe:node/{ver}/{arch}+.complete,go/{ver}/{arch}+.complete,dotnet-install.sh --channelinto theDOTNET_INSTALL_DIRdir. Partial versions ("22", "1.24") resolve to the latest release of the line at warm time; already-cached versions are skipped.warmed_profile_hash/last_warmed_aton success,last_warm_errorwith 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.5. Frontend — CI tab on the stack page
modules/cicontributing a CI tab viastackDetailTabs(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
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).npm run build(tsc + vite) passes; solution builds with 0 warnings.Docs
docs/ci-runners/design.mdgains a "Stack-linked CI, toolchain detection & cache pre-warming" section (link semantics, detection contract, warmer security posture, future work) and updated caching/milestone status.