Skip to content

Architecture

Component and sequence view of the warm pool. See also the ASCII summary in the root README.md and the operational failure-mode table in docs/DR-OPS.md.

Component diagram

flowchart TD
    subgraph vault["KeePass vault (master copy)"]
        PEM["4x App private key .pem\n(one per PC)"]
    end

    subgraph pc["Each of 4 home PCs"]
        DPAPI["Windows DPAPI blob\n(config.KeyRef, CurrentUser-bound)"]
        CFG["config.psd1\n(per-PC, gitignored)"]
        MOD["watchdog/GitHubApp.psm1\nkey -> JWT -> install token -> reg token"]
        WLIN["Watchdog-Linux.ps1\nkeep-one-alive loop"]
        WWIN["Watchdog-Windows.ps1\nkeep-one-alive loop"]
        DOCKER["Docker Desktop\nww-runner:linux container(s)"]
        HOSTRUNNER["actions/runner\non Windows host"]
    end

    subgraph gh["GitHub"]
        APP["GitHub App\nrepo mode: Administration RW\norg mode: Self-hosted runners RW (org perm)"]
        SCOPE["Actions runner pool\nrepo mode: repos/{owner}/{repo}\norg mode: orgs/{org} (+ home-ci group/label)"]
        QUEUE["Job queue / scheduler"]
    end

    PEM -- "Provision-Key.ps1 (one-time)" --> DPAPI
    DPAPI --> MOD
    CFG -- "Org / MaxRunners / Stale* keys" --> WLIN
    CFG --> WWIN
    MOD --> WLIN
    MOD --> WWIN
    WLIN -- "docker run -e RUNNER_TOKEN=..." --> DOCKER
    WWIN -- "config.cmd --token ... ; run.cmd" --> HOSTRUNNER
    MOD -- "JWT (RS256)" --> APP
    APP -- "installation token" --> MOD
    MOD -- "registration-token request\n(backoff + jitter on retry)" --> SCOPE
    WLIN -- "stale-runner sweep\n(DELETE offline ww-<pc>-* past threshold)" --> SCOPE
    DOCKER -- "registers ephemeral runner" --> SCOPE
    HOSTRUNNER -- "registers ephemeral runner" --> SCOPE
    QUEUE -- "routes queued job" --> SCOPE

Four PCs run this same set of components independently. There is no coordination service between them - GitHub's own scheduler is the only shared state (see docs/DR-OPS.md "Model").

Repo mode vs org mode is a per-PC config switch (Org in config.psd1, see docs/ORG-MIGRATION.md): the auth flow is identical, only the runner API scope changes (repos/{owner}/{repo} vs orgs/{org}), the runner registers against https://github.com/<org> instead of the repo URL, and the shared home-ci label is appended so every fleet repo can target runs-on: [self-hosted, home-ci, <os>]. The Linux watchdog keeps up to MaxRunners (default 1, hard cap 4) containers warm and runs the per-PC stale-runner sweep; the Windows host runner is always exactly one.

Sequence: one reconcile pass launching a fresh runner

sequenceDiagram
    participant WD as Watchdog (Linux or Windows loop)
    participant MOD as GitHubApp.psm1
    participant KEY as DPAPI / CredMan (host-local)
    participant GH as GitHub API
    participant RT as Runner (container or host process)

    WD->>WD: reconcile pass - is a runner already alive?
    alt runner alive
        WD->>WD: steady state, do nothing
    else no runner
        WD->>MOD: Get-WWRunnerRegistration(AppId, InstallationId, Repo, KeyRef, KeySource)
        MOD->>KEY: Get-WWAppPrivateKey (unprotect / cred read)
        KEY-->>MOD: RSA private key (in-memory only)
        MOD->>MOD: New-WWAppJwt (RS256, ~9 min)
        MOD->>GH: POST /app/installations/{id}/access_tokens  (Bearer JWT)
        GH-->>MOD: installation token (~1h)
        alt repo mode (config.Repo)
            MOD->>GH: POST /repos/{owner}/{repo}/actions/runners/registration-token (token install)
        else org mode (config.Org)
            MOD->>GH: POST /orgs/{org}/actions/runners/registration-token (token install)
        end
        Note over MOD,GH: every call retried with exponential backoff + jitter on transient failure
        GH-->>MOD: registration token (~1h)
        MOD-->>WD: { RegistrationToken, InstallationToken }
        Note over MOD: RSA key disposed immediately after use
        WD->>RT: launch with ONLY the registration token (docker run -e / config.cmd --token)
        RT->>GH: config.sh/config.cmd --ephemeral --token ...
        GH-->>RT: runner registered, picks up next matching job
        RT->>RT: serve exactly one job, then unregister + exit
        WD->>WD: next poll reaps the exited runner, reconciles again
    end