diff --git a/.env.example b/.env.example index cd1bedf..f7e9e37 100644 --- a/.env.example +++ b/.env.example @@ -25,6 +25,7 @@ LINUX_DOCKER_PROJECT_ENV_FILE=.env LINUX_DOCKER_INSTALL_PULL_IMAGES=true LINUX_DOCKER_INSTALL_FORCE_RECREATE=true LINUX_DOCKER_INSTALL_REMOVE_ORPHANS=true +LINUX_DOCKER_ALLOW_ALL_REPOSITORIES=false WINDOWS_DOCKER_RUNNER_BASE_DIR=C:\github-runner-fleet\windows-docker WINDOWS_DOCKER_HOST=windows-host.internal WINDOWS_DOCKER_PORT=22 @@ -35,9 +36,11 @@ WINDOWS_DOCKER_PROJECT_ENV_FILE=.env WINDOWS_DOCKER_INSTALL_PULL_IMAGES=true WINDOWS_DOCKER_INSTALL_FORCE_RECREATE=true WINDOWS_DOCKER_INSTALL_REMOVE_ORPHANS=true +WINDOWS_DOCKER_ALLOW_ALL_REPOSITORIES=false LUME_RUNNER_BASE_DIR=~/Library/Application Support/github-runner-fleet/lume LUME_RUNNER_ENV_FILE=~/Library/Application Support/github-runner-fleet/lume/runner.env LUME_RUNNER_IPSW_PATH=~/Library/Application Support/github-runner-fleet/lume/cache/latest.ipsw +LUME_GUEST_PASSWORD=change-me COMPOSE_PROJECT_NAME=github-runner-fleet RUNNER_VERSION=2.333.0 OTEL_EXPORTER_OTLP_ENDPOINT=https://otel-collector.example.com:4318 diff --git a/.github/workflows/pr-fast-ci.yml b/.github/workflows/pr-fast-ci.yml index cbf46e9..fa37991 100644 --- a/.github/workflows/pr-fast-ci.yml +++ b/.github/workflows/pr-fast-ci.yml @@ -23,7 +23,7 @@ defaults: jobs: changes: name: Detect Relevant Changes - runs-on: ['self-hosted', 'synology', 'shell-only', 'public'] + runs-on: ubuntu-latest outputs: app: ${{ steps.filter.outputs.app }} ci: ${{ steps.filter.outputs.ci }} @@ -62,6 +62,7 @@ jobs: needs: changes if: >- github.event.pull_request.draft == false && + github.event.pull_request.head.repo.full_name == github.repository && (needs.changes.outputs.app == 'true' || needs.changes.outputs.ci == 'true') steps: - uses: actions/checkout@v6 @@ -75,7 +76,9 @@ jobs: name: Validate Secrets runs-on: ['self-hosted', 'synology', 'shell-only', 'public'] timeout-minutes: 10 - if: github.event.pull_request.draft == false + if: >- + github.event.pull_request.draft == false && + github.event.pull_request.head.repo.full_name == github.repository steps: - uses: actions/checkout@v6 with: @@ -83,14 +86,60 @@ jobs: - name: Scan repository for secret patterns run: bash scripts/check-detect-secrets.sh --all-files + hosted-fork-fast-checks: + name: Hosted Fork Fast Checks + runs-on: ubuntu-latest + timeout-minutes: 15 + needs: changes + if: >- + github.event.pull_request.draft == false && + github.event.pull_request.head.repo.full_name != github.repository && + (needs.changes.outputs.app == 'true' || needs.changes.outputs.ci == 'true') + steps: + - uses: actions/checkout@v6 + with: + repository: ${{ github.event.pull_request.head.repo.full_name }} + ref: ${{ github.event.pull_request.head.sha }} + + - uses: pnpm/action-setup@v6 + with: + version: 10.32.1 + + - uses: actions/setup-node@v6 + with: + node-version: "24" + cache: pnpm + + - run: pnpm install --frozen-lockfile + - run: pnpm lint + - run: pnpm test + - run: pnpm build + + hosted-fork-validate-secrets: + name: Hosted Fork Validate Secrets + runs-on: ubuntu-latest + timeout-minutes: 10 + if: >- + github.event.pull_request.draft == false && + github.event.pull_request.head.repo.full_name != github.repository + steps: + - uses: actions/checkout@v6 + with: + repository: ${{ github.event.pull_request.head.repo.full_name }} + ref: ${{ github.event.pull_request.head.sha }} + - name: Scan repository for secret patterns + run: bash scripts/check-detect-secrets.sh --all-files + ci-gate: name: CI Gate - runs-on: ['self-hosted', 'synology', 'shell-only', 'public'] + runs-on: ubuntu-latest if: always() needs: - changes - fast-checks - validate-secrets + - hosted-fork-fast-checks + - hosted-fork-validate-secrets steps: - name: Check required PR jobs env: @@ -98,6 +147,8 @@ jobs: changes=${{ needs.changes.result }} fast-checks=${{ needs.fast-checks.result }} validate-secrets=${{ needs.validate-secrets.result }} + hosted-fork-fast-checks=${{ needs.hosted-fork-fast-checks.result }} + hosted-fork-validate-secrets=${{ needs.hosted-fork-validate-secrets.result }} run: | failed=0 for entry in $RESULTS; do diff --git a/README.md b/README.md index a3db592..67d52c3 100644 --- a/README.md +++ b/README.md @@ -373,12 +373,17 @@ Keep the Lume runner env file outside git and locked down with `chmod 600`. The - Do not add Compose `init: true` for these services. The image already uses `tini`, and double-init setups on Synology produce noisy subreaper warnings. - For public pools, use DSM firewall rules to reduce unnecessary LAN reachability. - Keep the Docker socket restricted to the dedicated Linux Docker host. Do not mount it into the Synology shell-only plane. +- Keep external fork pull requests on GitHub-hosted runners; self-hosted runner groups are for trusted same-repository or explicitly allowed private workflows. +- Treat Docker-capable runner groups as host-control boundaries because they mount the Docker socket or Windows named pipe. +- Keep `LUME_GUEST_PASSWORD` out of git and rotate the base VM guest credential when rebuilding or resealing Lume images. ## Useful Commands ```bash pnpm doctor -- full --env .env pnpm doctor -- synology --env .env +pnpm doctor -- linux-docker --env .env +pnpm doctor -- windows-docker --env .env pnpm doctor -- lume --env .env pnpm validate-linux-docker-config -- --config config/linux-docker-runners.yaml --env .env pnpm validate-linux-docker-github -- --config config/linux-docker-runners.yaml --env .env @@ -437,7 +442,7 @@ SMOKE_KEEP_ARTIFACTS=1 pnpm smoke-test ## Troubleshooting Starting Points -- `pnpm doctor -- full --env .env` for one preflight/status summary across Synology and Lume checks +- `pnpm doctor -- full --env .env` for one preflight/status summary across Synology, Linux Docker, Windows Docker, and Lume checks - `pnpm validate-linux-docker-config -- --config config/linux-docker-runners.yaml --env .env` for Docker-capable Linux pool schema and label validation - `pnpm validate-linux-docker-github -- --config config/linux-docker-runners.yaml --env .env` for Docker-capable Linux runner-group verification - `pnpm render-linux-docker-project-manifest -- --config config/linux-docker-runners.yaml --env .env` for the remote Linux Docker install plan before you push it diff --git a/SECURITY.md b/SECURITY.md index c8951d4..31ba892 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -20,3 +20,20 @@ We will acknowledge receipt, validate the report, and coordinate a fix and discl ## Scope Notes This repository publishes software intended to manage self-hosted GitHub Actions runners. Public repositories should not route untrusted workflow code to privileged or host-sensitive runner environments. + +External fork pull requests must stay on GitHub-hosted runners. Self-hosted +runner groups are for trusted same-repository or explicitly allowed private +repository workflows only. + +Runner registration currently uses `GITHUB_PAT` to mint short-lived runner +registration and removal tokens. Treat that PAT as fleet-wide infrastructure +auth: keep it out of images and base VMs, stage it only through generated +environment files or GitHub environments, rotate it after runner-host incidents, +and prefer narrowly scoped/fine-grained credentials where GitHub supports the +required runner APIs. + +Docker-capable runner planes mount the host Docker socket or Windows Docker +named pipe. Any repository allowed onto those runner groups can effectively +control the Docker host, so `repositoryAccess: all` requires an explicit +break-glass environment flag and should not be used for public or untrusted +repositories. diff --git a/config/lume-runners.yaml b/config/lume-runners.yaml index 310727f..c525a76 100644 --- a/config/lume-runners.yaml +++ b/config/lume-runners.yaml @@ -17,7 +17,7 @@ pool: diskSize: 80GB network: nat guestUser: lume - guestPassword: lume + guestPassword: ${LUME_GUEST_PASSWORD} guestRunnerRoot: /Users/lume/actions-runner guestWorkRoot: /Users/lume/actions-runner/_work runnerVersion: 2.333.0 diff --git a/docs/bootstrap/claude-environment.md b/docs/bootstrap/claude-environment.md index 27691b8..f41d138 100644 --- a/docs/bootstrap/claude-environment.md +++ b/docs/bootstrap/claude-environment.md @@ -9,7 +9,7 @@ ## Claude Code On The Web - Hosted entrypoint: `https://claude.ai/code` -- Repo: `OMT-Global/synology-github-runner` +- Repo: `OMT-Global/github-runner-fleet` - Setup script: `bash scripts/claude-cloud/setup.sh` - Network access: start with limited access; only expand it when a task truly needs more than registries and GitHub - Environment variables: configure them in the Claude environment UI as `.env`-style key-value pairs @@ -52,5 +52,5 @@ ## Project - - Repository: `OMT-Global/synology-github-runner` + - Repository: `OMT-Global/github-runner-fleet` - Default branch: `main` diff --git a/docs/bootstrap/codex-cloud-environment.md b/docs/bootstrap/codex-cloud-environment.md index 34f481a..130583f 100644 --- a/docs/bootstrap/codex-cloud-environment.md +++ b/docs/bootstrap/codex-cloud-environment.md @@ -2,7 +2,7 @@ Configure the Codex Web environment in Codex settings for: -- Repo: `OMT-Global/synology-github-runner` +- Repo: `OMT-Global/github-runner-fleet` - Base image: `universal` - Setup mode: manual setup script - Setup script: `bash scripts/codex-cloud/setup.sh` diff --git a/docs/bootstrap/onboarding.md b/docs/bootstrap/onboarding.md index ca169aa..fb5dc67 100644 --- a/docs/bootstrap/onboarding.md +++ b/docs/bootstrap/onboarding.md @@ -2,7 +2,7 @@ ## Repo Governance -- Confirm the repository exists at `OMT-Global/synology-github-runner`. +- Confirm the repository exists at `OMT-Global/github-runner-fleet`. - Confirm branch protection or rulesets on `main` require one approval and code owner review. - Confirm branch protection points at the `CI Gate` status. - Confirm `delete branch on merge` and `allow auto-merge` are enabled. diff --git a/docs/workflow-cookbook.md b/docs/workflow-cookbook.md index c45cb37..8c1351b 100644 --- a/docs/workflow-cookbook.md +++ b/docs/workflow-cookbook.md @@ -1,30 +1,32 @@ # Shell-safe workflow cookbook -This guide is for downstream repositories that want to consume the runner contracts from this repo without guessing which jobs belong on self-hosted Synology runners, which jobs belong on the Lume macOS pool, and which jobs should stay on GitHub-hosted runners. +This guide is for downstream repositories that want to consume the runner contracts from this repo without guessing which jobs belong on self-hosted Synology runners, Linux Docker runners, Windows Docker runners, the Lume macOS pool, and GitHub-hosted runners. ## Runner compatibility matrix -| Job class | Synology shell-only pool | Lume macOS pool | GitHub-hosted runners | Notes | -| --- | --- | --- | --- | --- | -| Node install, lint, test, build | Yes | Usually unnecessary | Yes | On Synology, use `OMT-Global/synology-github-runner/actions/setup-shell-safe-node` instead of `actions/setup-node`. | -| Python 3.12 lint/test | Yes | Optional | Yes | `actions/setup-python@v6` with `python-version: '3.12'` resolves locally on the Synology image. Other Python versions should stay hosted unless you control the full toolchain. | -| Terraform fmt/validate/init without cloud sidecars | Yes | Optional | Yes | Keep plugin cache under `RUNNER_TEMP` or another writable container-local path. | -| Docs checks, markdown lint, shell validation | Yes | Optional | Yes | Good fit for the shell-only pool when the job only needs baked-in CLI tools. | -| Release image builds, Buildx, QEMU, registry publish | No | No | Yes | Keep these on GitHub-hosted runners. | -| Docker daemon, `docker build`, `docker compose`, service containers | No | No | Yes | The Synology runner class intentionally avoids Docker socket mounts and does not support service containers. | -| `container:` jobs | No | No | Yes | Route these back to GitHub-hosted runners. | -| Browser/UI/E2E jobs needing extra distro packages | No | Sometimes | Yes | Prefer hosted runners unless the macOS requirement is explicit and owned. | -| macOS signing, Xcode builds, Swift/macOS validation | No | Yes | Yes | Use the Lume pool when you need a self-hosted macOS environment. | -| Public fork pull requests | No | No | Yes | Keep fork PRs on GitHub-hosted runners so untrusted code does not land on self-hosted infrastructure. | +| Job class | Synology shell-only pool | Linux Docker pool | Windows Docker pool | Lume macOS pool | GitHub-hosted runners | Notes | +| --- | --- | --- | --- | --- | --- | --- | +| Node install, lint, test, build | Yes | Yes | Case-by-case | Usually unnecessary | Yes | On Synology, use `OMT-Global/github-runner-fleet/actions/setup-shell-safe-node` instead of `actions/setup-node`. | +| Python 3.12 lint/test | Yes | Yes | Case-by-case | Optional | Yes | `actions/setup-python@v6` with `python-version: '3.12'` resolves locally on the Synology image. Other Python versions should stay hosted unless you control the full toolchain. | +| Terraform fmt/validate/init without cloud sidecars | Yes | Yes | Case-by-case | Optional | Yes | Keep plugin cache under `RUNNER_TEMP` or another writable container-local path. | +| Docs checks, markdown lint, shell validation | Yes | Yes | Case-by-case | Optional | Yes | Good fit for the shell-only pool when the job only needs baked-in CLI tools. | +| Release image builds, Buildx, QEMU, registry publish | No | Yes | No | No | Yes | Use Linux Docker for trusted private workloads; keep untrusted or public fork builds hosted. | +| Docker daemon, `docker build`, `docker compose`, service containers | No | Yes | Windows containers only | No | Yes | The Synology runner class intentionally avoids Docker socket mounts and does not support service containers. | +| `container:` jobs | No | Yes | Windows containers only | No | Yes | Use Docker-capable private planes for trusted repos; use hosted runners for untrusted code. | +| Browser/UI/E2E jobs needing extra distro packages | No | Case-by-case | Case-by-case | Sometimes | Yes | Prefer hosted runners unless the self-hosted requirement is explicit and owned. | +| macOS signing, Xcode builds, Swift/macOS validation | No | No | No | Yes | Yes | Use the Lume pool when you need a self-hosted macOS environment. | +| Public fork pull requests | No | No | No | No | Yes | Keep fork PRs on GitHub-hosted runners so untrusted code does not land on self-hosted infrastructure. | ## Routing rules Use these rules when deciding where a workflow job should run: - Use `runs-on: [self-hosted, synology, shell-only, public]` for trusted shell-safe jobs that can run with the baked-in Linux toolchain. +- Use `runs-on: [self-hosted, linux, docker-capable, private]` for trusted private Linux jobs that need Docker, `container:`, or service containers. +- Use `runs-on: [self-hosted, windows, docker-capable, private]` only for trusted private Windows container work. - Use `runs-on: [self-hosted, macos, arm64]` only when you intentionally target the Lume macOS pool and control the repo trust boundary. - Keep pull requests from forks on GitHub-hosted runners. -- Keep any workflow using `container:`, `services:`, browsers, Docker daemon access, Buildx, or extra distro package assumptions on GitHub-hosted runners. +- Keep any untrusted workflow using `container:`, `services:`, browsers, Docker daemon access, Buildx, or extra distro package assumptions on GitHub-hosted runners. - Prefer a split workflow over forcing one runner class to handle incompatible jobs. ## Recipe: trusted Node job on the Synology shell-only pool @@ -57,7 +59,7 @@ jobs: - uses: pnpm/action-setup@v5 with: version: 10.32.1 - - uses: OMT-Global/synology-github-runner/actions/setup-shell-safe-node@main + - uses: OMT-Global/github-runner-fleet/actions/setup-shell-safe-node@main with: node-version: 24.14.1 - run: pnpm install --frozen-lockfile @@ -100,7 +102,7 @@ jobs: - uses: pnpm/action-setup@v5 with: version: 10.32.1 - - uses: OMT-Global/synology-github-runner/actions/setup-shell-safe-node@main + - uses: OMT-Global/github-runner-fleet/actions/setup-shell-safe-node@main with: node-version: 24.14.1 - run: pnpm install --frozen-lockfile @@ -180,7 +182,7 @@ jobs: - run: terraform validate ``` -This works well for pure CLI Terraform jobs. If the workflow also builds containers, talks to Docker, or needs sidecar services, split those parts back to GitHub-hosted runners. +This works well for pure CLI Terraform jobs. If the workflow also builds containers, talks to Docker, or needs sidecar services, split those parts onto the Linux Docker private plane for trusted repos or back to GitHub-hosted runners for untrusted code. ## Recipe: Lume macOS contract job @@ -213,9 +215,9 @@ Use the hosted `macos-latest` image instead when the repository does not need se ## Force jobs back to GitHub-hosted runners when -- the workflow uses `container:` -- the workflow uses `services:` -- the job requires Docker daemon access, Buildx, or QEMU +- the workflow uses `container:` and is not trusted private work assigned to a Docker-capable plane +- the workflow uses `services:` and is not trusted private work assigned to a Docker-capable plane +- the job requires Docker daemon access, Buildx, or QEMU and is not trusted private Linux Docker work - the job needs browsers or large sets of distro packages not already present in the runner contract - the change comes from a public fork or another untrusted source - the job depends on a language/version combination outside the documented self-hosted contract diff --git a/project.bootstrap.yaml b/project.bootstrap.yaml index b572198..aa98567 100644 --- a/project.bootstrap.yaml +++ b/project.bootstrap.yaml @@ -1,7 +1,7 @@ version: 1 project: - name: synology-github-runner - description: Shell-only GitHub self-hosted runner pools for Synology NAS deployments. + name: github-runner-fleet + description: Self-hosted GitHub runner fleet for Synology, Linux Docker, Windows Docker, and Lume macOS planes. visibility: public owner: OMT-Global defaultBranch: main @@ -24,7 +24,7 @@ repo: archetype: kind: generic-empty packageManager: npm - moduleName: synology_github_runner + moduleName: github_runner_fleet github: createRepo: false reviewers: diff --git a/scripts/smoke/mock-api.mjs b/scripts/smoke/mock-api.mjs index 5b2f98c..4533c08 100644 --- a/scripts/smoke/mock-api.mjs +++ b/scripts/smoke/mock-api.mjs @@ -4,6 +4,7 @@ import path from "node:path"; const logPath = process.env.MOCK_LOG_PATH ?? "/tmp/mock-api.log"; const port = Number(process.env.MOCK_PORT ?? "8080"); +const host = process.env.MOCK_HOST ?? "0.0.0.0"; fs.mkdirSync(path.dirname(logPath), { recursive: true }); const server = http.createServer((req, res) => { @@ -29,10 +30,10 @@ const server = http.createServer((req, res) => { res.end(JSON.stringify({ error: "not found" })); }); -server.listen(port, "0.0.0.0", () => { +server.listen(port, host, () => { fs.appendFileSync( logPath, - `${new Date().toISOString()} listening 0.0.0.0:${port}\n`, + `${new Date().toISOString()} listening ${host}:${port}\n`, "utf8" ); }); diff --git a/src/cli.ts b/src/cli.ts index 8327e5f..9f6be96 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -250,11 +250,12 @@ async function doctorCommand(args: string[]): Promise { mode, envPath: getOption(args, "--env", ".env"), configPath: getOption(args, "--config", "config/pools.yaml"), - linuxDockerConfigPath: getOption( + linuxConfigPath: getOption( args, - "--linux-docker-config", - "config/linux-docker-runners.yaml" + "--linux-config", + getOption(args, "--linux-docker-config", "config/linux-docker-runners.yaml") ), + windowsConfigPath: getOption(args, "--windows-config", "config/windows-runners.yaml"), lumeConfigPath: getOption(args, "--lume-config", "config/lume-runners.yaml") }); @@ -2461,7 +2462,9 @@ function getDoctorMode(args: string[]): DoctorMode { const optionFlags = new Set([ "--env", "--config", + "--linux-config", "--linux-docker-config", + "--windows-config", "--lume-config", "--format" ]); @@ -2477,7 +2480,13 @@ function getDoctorMode(args: string[]): DoctorMode { continue; } - if (arg === "full" || arg === "synology" || arg === "linux-docker" || arg === "lume") { + if ( + arg === "full" || + arg === "synology" || + arg === "linux-docker" || + arg === "windows-docker" || + arg === "lume" + ) { return arg; } @@ -2842,7 +2851,7 @@ function powerShellQuote(value: string): string { function printUsage(): void { process.stderr.write(`Usage: - pnpm doctor [full|synology|linux-docker|lume] [--env .env] [--config config/pools.yaml] [--linux-docker-config config/linux-docker-runners.yaml] [--lume-config config/lume-runners.yaml] [--format text|json] + pnpm doctor [full|synology|linux-docker|windows-docker|lume] [--env .env] [--config config/pools.yaml] [--linux-config config/linux-docker-runners.yaml] [--linux-docker-config config/linux-docker-runners.yaml] [--windows-config config/windows-runners.yaml] [--lume-config config/lume-runners.yaml] [--format text|json] pnpm synology-status [--config config/pools.yaml] [--env .env] [--result .tmp/synology-status.json] [--format text|json] pnpm linux-docker-status [--config config/linux-docker-runners.yaml] [--env .env] [--result .tmp/linux-docker-status.json] [--format text|json] pnpm audit-log [--file /var/log/runner-fleet/audit.jsonl] [--max-size-bytes 10485760] < event.json diff --git a/src/lib/compose.ts b/src/lib/compose.ts index 02e085f..031c100 100644 --- a/src/lib/compose.ts +++ b/src/lib/compose.ts @@ -1,10 +1,8 @@ import YAML from "yaml"; import type { DeploymentEnv } from "./env.js"; -import { - renderTelemetryEnvironment, - type PoolConfig, - type ResolvedConfig -} from "./config.js"; +import type { PoolConfig, ResolvedConfig } from "./config.js"; +import { buildCommonRunnerEnv } from "./runner-plane.js"; +import { renderTelemetryEnvironment } from "./config.js"; export function renderCompose( config: ResolvedConfig, @@ -45,26 +43,22 @@ export function buildRunnerStateDir(pool: PoolConfig, index: number): string { function renderService(pool: PoolConfig, index: number): Record { const runnerStateDir = buildRunnerStateDir(pool, index); - const environment: Record = { - GITHUB_PAT: "${GITHUB_PAT}", - GITHUB_API_URL: "${GITHUB_API_URL:-https://api.github.com}", - GITHUB_ORG: pool.organization, - RUNNER_SCOPE: "organization", - RUNNER_NAME: buildRunnerName(pool, index), - RUNNER_GROUP: pool.runnerGroup, - FLEET_POOL_KEY: pool.key, - FLEET_PLANE: "synology", - RUNNER_LABELS: pool.labels.join(","), - RUNNER_VISIBILITY: pool.visibility, - RUNNER_REPOSITORY_ACCESS: pool.repositoryAccess, - RUNNER_STATE_DIR: runnerStateDir, - RUNNER_LOG_DIR: `${runnerStateDir}/logs`, - RUNNER_WORK_DIR: "/tmp/github-runner-work", - RUNNER_TEMP: "/tmp/github-runner-temp", - RUNNER_TOOL_CACHE: "/opt/hostedtoolcache", - AGENT_TOOLSDIRECTORY: "/opt/hostedtoolcache", - RUNNER_EPHEMERAL: "true", - RUNNER_DISABLE_UPDATE: "true", + const environment = { + ...buildCommonRunnerEnv({ + organization: pool.organization, + runnerName: buildRunnerName(pool, index), + runnerGroup: pool.runnerGroup, + poolKey: pool.key, + plane: "synology", + labels: pool.labels, + visibility: pool.visibility, + repositoryAccess: pool.repositoryAccess, + runnerStateDir, + runnerLogDir: `${runnerStateDir}/logs`, + runnerWorkDir: "/tmp/github-runner-work", + runnerTemp: "/tmp/github-runner-temp", + runnerToolCache: "/opt/hostedtoolcache" + }), ...renderTelemetryEnvironment(pool.telemetry, { serviceName: "github-runner-fleet.synology", resourceAttributes: { diff --git a/src/lib/doctor.ts b/src/lib/doctor.ts index 707f1bf..63d0c8e 100644 --- a/src/lib/doctor.ts +++ b/src/lib/doctor.ts @@ -2,12 +2,13 @@ import fs from "node:fs"; import { auditLogFileFromEnv } from "./audit.js"; import { collectConfigWarnings, loadConfig } from "./config.js"; import { loadDeploymentEnv } from "./env.js"; +import { loadLinuxDockerConfig } from "./linux-docker-config.js"; +import { loadWindowsDockerConfig } from "./windows-config.js"; import { type FetchLike, verifyContainerImageTag, verifyRunnerGroups } from "./github.js"; -import { loadLinuxDockerConfig } from "./linux-docker-config.js"; import { log, type LogLevel } from "./logger.js"; import { loadLumeConfig } from "./lume-config.js"; import { @@ -21,12 +22,13 @@ import { type MetricSample } from "./metrics.js"; -export type DoctorMode = "full" | "synology" | "linux-docker" | "lume"; +export type DoctorMode = "full" | "synology" | "linux-docker" | "windows-docker" | "lume"; export type DoctorCheckStatus = "pass" | "warn" | "fail" | "skip"; +export type DoctorTarget = "synology" | "linux-docker" | "windows-docker" | "lume"; export interface DoctorCheck { id: string; - target: "synology" | "linux-docker" | "lume"; + target: DoctorTarget; status: DoctorCheckStatus; summary: string; detail?: string; @@ -43,7 +45,9 @@ export interface RunDoctorOptions { mode?: DoctorMode; envPath?: string; configPath?: string; + linuxConfigPath?: string; linuxDockerConfigPath?: string; + windowsConfigPath?: string; lumeConfigPath?: string; fetchImpl?: FetchLike; } @@ -54,8 +58,12 @@ export async function runDoctor( const mode = options.mode ?? "full"; const envPath = options.envPath ?? ".env"; const configPath = options.configPath ?? "config/pools.yaml"; - const linuxDockerConfigPath = - options.linuxDockerConfigPath ?? "config/linux-docker-runners.yaml"; + const linuxConfigPath = + options.linuxConfigPath ?? + options.linuxDockerConfigPath ?? + "config/linux-docker-runners.yaml"; + const windowsConfigPath = + options.windowsConfigPath ?? "config/windows-runners.yaml"; const lumeConfigPath = options.lumeConfigPath ?? "config/lume-runners.yaml"; const fetchImpl = options.fetchImpl; const env = loadDeploymentEnv({ @@ -76,12 +84,21 @@ export async function runDoctor( if (mode === "full" || mode === "linux-docker") { const linuxDockerChecks = await runLinuxDockerDoctor({ env, - configPath: linuxDockerConfigPath, + configPath: linuxConfigPath, fetchImpl }); checks.push(...linuxDockerChecks); } + if (mode === "full" || mode === "windows-docker") { + const windowsDockerChecks = await runWindowsDockerDoctor({ + env, + configPath: windowsConfigPath, + fetchImpl + }); + checks.push(...windowsDockerChecks); + } + if (mode === "full" || mode === "lume") { const lumeChecks = await runLumeDoctor({ env, @@ -101,6 +118,113 @@ export async function runDoctor( return report; } +async function runWindowsDockerDoctor(input: { + env: ReturnType; + configPath: string; + fetchImpl?: FetchLike; +}): Promise { + const checks: DoctorCheck[] = []; + const missingDeploymentEnv = [ + ["GITHUB_PAT", input.env.githubPat] + ] + .filter(([, value]) => !value) + .map(([key]) => key); + + checks.push( + missingDeploymentEnv.length === 0 + ? { + id: "windows-docker-env", + target: "windows-docker", + status: "pass", + summary: "required Windows Docker GitHub env is configured" + } + : { + id: "windows-docker-env", + target: "windows-docker", + status: "fail", + summary: "required Windows Docker GitHub env is incomplete", + detail: `missing ${missingDeploymentEnv.join(", ")}` + } + ); + + let config: ReturnType | undefined; + try { + config = loadWindowsDockerConfig(input.configPath, input.env); + const missingHostFields = config.pools.flatMap((pool) => [ + ...(pool.host ? [] : [`${pool.key}:host`]), + ...(pool.sshUser ? [] : [`${pool.key}:sshUser`]) + ]); + checks.push({ + id: "windows-docker-config", + target: "windows-docker", + status: missingHostFields.length === 0 ? "pass" : "fail", + summary: + missingHostFields.length === 0 + ? `loaded ${input.configPath} with ${config.pools.length} pool${config.pools.length === 1 ? "" : "s"}` + : "Windows Docker config is missing target host fields", + detail: + missingHostFields.length === 0 + ? undefined + : `missing ${missingHostFields.join(", ")}`, + data: { + pools: config.pools.map((pool) => ({ + key: pool.key, + size: pool.size + })) + } + }); + } catch (error) { + checks.push({ + id: "windows-docker-config", + target: "windows-docker", + status: "fail", + summary: `failed to load ${input.configPath}`, + detail: formatError(error) + }); + return checks; + } + + if (!input.env.githubPat) { + checks.push({ + id: "windows-docker-runner-groups", + target: "windows-docker", + status: "skip", + summary: "skipped Windows Docker runner-group verification", + detail: "GITHUB_PAT is not configured" + }); + return checks; + } + + try { + const pools = await verifyRunnerGroups( + input.env.githubApiUrl, + input.env.githubPat, + config.pools.map((pool) => ({ + poolKey: pool.key, + organization: pool.organization, + runnerGroup: pool.runnerGroup + })), + input.fetchImpl + ); + checks.push({ + id: "windows-docker-runner-groups", + target: "windows-docker", + status: "pass", + summary: `verified ${pools.length} Windows Docker runner group${pools.length === 1 ? "" : "s"} in GitHub` + }); + } catch (error) { + checks.push({ + id: "windows-docker-runner-groups", + target: "windows-docker", + status: "fail", + summary: "failed Windows Docker runner-group verification", + detail: formatError(error) + }); + } + + return checks; +} + export function renderDoctorReport(report: DoctorReport): string { const lines = [`doctor mode: ${report.mode}`]; @@ -630,20 +754,10 @@ function levelForStatus(status: DoctorCheckStatus): LogLevel { } function poolSlotMetricsForCheck(check: DoctorCheck): MetricSample[] { - if (check.target === "synology" && isSynologyConfigData(check.data)) { - return check.data.pools.map((pool) => - poolSlotCount({ - plane: "synology", - pool: pool.key, - count: pool.size - }) - ); - } - - if (check.target === "linux-docker" && isSynologyConfigData(check.data)) { + if (isPoolConfigData(check.data)) { return check.data.pools.map((pool) => poolSlotCount({ - plane: "linux-docker", + plane: check.target, pool: pool.key, count: pool.size }) @@ -663,7 +777,7 @@ function poolSlotMetricsForCheck(check: DoctorCheck): MetricSample[] { return []; } -function isSynologyConfigData( +function isPoolConfigData( value: unknown ): value is { pools: Array<{ key: string; size: number }> } { return ( diff --git a/src/lib/linux-docker-compose.ts b/src/lib/linux-docker-compose.ts index 739dae1..8455560 100644 --- a/src/lib/linux-docker-compose.ts +++ b/src/lib/linux-docker-compose.ts @@ -4,6 +4,7 @@ import type { ResolvedLinuxDockerConfig } from "./linux-docker-config.js"; import type { DeploymentEnv } from "./env.js"; +import { buildCommonRunnerEnv } from "./runner-plane.js"; import { renderTelemetryEnvironment } from "./telemetry.js"; export function renderLinuxDockerCompose( @@ -54,26 +55,22 @@ function renderService( const runnerTempDir = `${runnerStateDir}/_temp`; const runnerToolCache = `${runnerStateDir}/toolcache`; const environment: Record = { - GITHUB_PAT: "${GITHUB_PAT}", - GITHUB_API_URL: "${GITHUB_API_URL:-https://api.github.com}", - GITHUB_ORG: pool.organization, - RUNNER_SCOPE: "organization", - RUNNER_NAME: buildLinuxDockerServiceName(pool, index), - RUNNER_GROUP: pool.runnerGroup, - FLEET_POOL_KEY: pool.key, - FLEET_PLANE: "linux-docker", - RUNNER_LABELS: pool.labels.join(","), - RUNNER_VISIBILITY: pool.visibility, - RUNNER_REPOSITORY_ACCESS: pool.repositoryAccess, - RUNNER_STATE_DIR: runnerStateDir, - RUNNER_LOG_DIR: `${runnerStateDir}/logs`, - RUNNER_WORK_DIR: runnerWorkDir, - RUNNER_TEMP: runnerTempDir, - RUNNER_TOOL_CACHE: runnerToolCache, - AGENT_TOOLSDIRECTORY: runnerToolCache, + ...buildCommonRunnerEnv({ + organization: pool.organization, + runnerName: buildLinuxDockerServiceName(pool, index), + runnerGroup: pool.runnerGroup, + poolKey: pool.key, + plane: "linux-docker", + labels: pool.labels, + visibility: pool.visibility, + repositoryAccess: pool.repositoryAccess, + runnerStateDir, + runnerLogDir: `${runnerStateDir}/logs`, + runnerWorkDir, + runnerTemp: runnerTempDir, + runnerToolCache + }), RUNNER_EXEC_MODE_OVERRIDE: "root", - RUNNER_EPHEMERAL: "true", - RUNNER_DISABLE_UPDATE: "true", DOCKER_HOST: "unix:///var/run/docker.sock", ...renderTelemetryEnvironment(pool.telemetry, { serviceName: "github-runner-fleet.linux-docker", diff --git a/src/lib/linux-docker-config.ts b/src/lib/linux-docker-config.ts index 9c78c1f..6bc573a 100644 --- a/src/lib/linux-docker-config.ts +++ b/src/lib/linux-docker-config.ts @@ -8,6 +8,13 @@ import type { RunnerPlatform } from "./config.js"; import type { DeploymentEnv } from "./env.js"; +import { + interpolateEnv, + repositoryPattern, + uniqueRunnerLabels, + validateDockerRepositoryAccess, + validateRepositoryOwner +} from "./runner-plane.js"; import { telemetrySchema, type TelemetryConfig } from "./telemetry.js"; export interface LinuxDockerPoolConfig { @@ -35,8 +42,6 @@ export interface ResolvedLinuxDockerConfig { pools: LinuxDockerPoolConfig[]; } -const repositoryPattern = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/; - const poolSchema = z .object({ key: z.string().regex(/^[a-z0-9][a-z0-9-]*$/), @@ -95,7 +100,7 @@ export function loadLinuxDockerConfig( const absolutePath = path.resolve(configPath); const source = fs.readFileSync(absolutePath, "utf8"); const parsed = YAML.parse(source); - const interpolated = interpolate(parsed, env.raw); + const interpolated = interpolateEnv(parsed, env.raw); const result = configSchema.parse(interpolated); const seenKeys = new Set(); @@ -107,15 +112,19 @@ export function loadLinuxDockerConfig( seenKeys.add(pool.key); if (pool.repositoryAccess === "selected") { - for (const repository of pool.allowedRepositories) { - const [owner] = repository.split("/"); - if (owner !== pool.organization) { - throw new Error( - `linux-docker pool ${pool.key} includes ${repository}, which is outside organization ${pool.organization}` - ); - } - } + validateRepositoryOwner({ + plane: "linux-docker", + poolKey: pool.key, + organization: pool.organization, + repositories: pool.allowedRepositories + }); } + validateDockerRepositoryAccess({ + plane: "linux-docker", + poolKey: pool.key, + repositoryAccess: pool.repositoryAccess, + env: env.raw + }); if (!path.isAbsolute(pool.runnerRoot)) { throw new Error( @@ -126,7 +135,10 @@ export function loadLinuxDockerConfig( return { ...poolValues, visibility: "private" as const, - labels: uniqueLabels(pool.labels), + labels: uniqueRunnerLabels( + ["linux", "docker-capable", "private"], + pool.labels + ), resources: { cpus: pool.resources.cpus, memory: pool.resources.memory, @@ -143,40 +155,3 @@ export function loadLinuxDockerConfig( pools }; } - -function uniqueLabels(labels: string[]): string[] { - return [...new Set(["linux", "docker-capable", "private", ...labels])]; -} - -function interpolate(value: unknown, env: Record): unknown { - if (typeof value === "string") { - return value.replace( - /\$\{([A-Z0-9_]+)(?::-(.*?))?\}/g, - (_match, name: string, defaultValue?: string) => { - const envValue = env[name]; - if (envValue !== undefined) { - return envValue; - } - if (defaultValue !== undefined) { - return defaultValue; - } - throw new Error(`missing environment value for ${name}`); - } - ); - } - - if (Array.isArray(value)) { - return value.map((item) => interpolate(item, env)); - } - - if (value && typeof value === "object") { - return Object.fromEntries( - Object.entries(value).map(([key, nestedValue]) => [ - key, - interpolate(nestedValue, env) - ]) - ); - } - - return value; -} diff --git a/src/lib/lume-config.ts b/src/lib/lume-config.ts index 939c3ab..033a267 100644 --- a/src/lib/lume-config.ts +++ b/src/lib/lume-config.ts @@ -3,6 +3,7 @@ import path from "node:path"; import YAML from "yaml"; import { z } from "zod"; import type { DeploymentEnv } from "./env.js"; +import { interpolateEnv, uniqueRunnerLabels } from "./runner-plane.js"; import { renderTelemetryEnvironment, telemetrySchema, @@ -76,7 +77,7 @@ const poolSchema = z.object({ network: z.string().min(1).default("nat"), storage: z.string().min(1).optional(), guestUser: z.string().min(1).default("lume"), - guestPassword: z.string().min(1).default("lume"), + guestPassword: z.string().min(1).optional(), guestRunnerRoot: z.string().min(1).default("/Users/lume/actions-runner"), guestWorkRoot: z.string().min(1).default("/Users/lume/actions-runner/_work"), runnerVersion: z.string().min(1).optional(), @@ -95,7 +96,7 @@ export function loadLumeConfig( const absolutePath = path.resolve(configPath); const source = fs.readFileSync(absolutePath, "utf8"); const parsed = YAML.parse(source); - const interpolated = interpolate(parsed, env.raw); + const interpolated = interpolateEnv(parsed, env.raw); const result = configSchema.parse(interpolated); if (!path.isAbsolute(env.lumeRunnerBaseDir)) { @@ -106,10 +107,18 @@ export function loadLumeConfig( throw new Error("LUME_RUNNER_ENV_FILE must resolve to an absolute path"); } + const normalizedLabels = normalizeLabels(result.pool.labels); const { telemetry, ...poolValues } = result.pool; - const normalizedLabels = normalizeLabels(poolValues.labels); + const guestPassword = + result.pool.guestPassword ?? env.raw.LUME_GUEST_PASSWORD?.trim(); + if (!guestPassword) { + throw new Error( + "Lume guestPassword must be set in config or LUME_GUEST_PASSWORD" + ); + } const pool: LumePoolConfig = { ...poolValues, + guestPassword, labels: normalizedLabels, runnerVersion: poolValues.runnerVersion ?? env.runnerVersion, ...(telemetry.enabled ? { telemetry } : {}) @@ -226,40 +235,7 @@ function buildSlots(pool: LumePoolConfig, baseDir: string): LumeSlotManifest[] { } function normalizeLabels(labels: string[]): string[] { - return [...new Set(["self-hosted", "macos", "arm64", "private", ...labels])]; -} - -function interpolate(value: unknown, env: Record): unknown { - if (typeof value === "string") { - return value.replace( - /\$\{([A-Z0-9_]+)(?::-(.*?))?\}/g, - (_match, name: string, defaultValue?: string) => { - const envValue = env[name]; - if (envValue !== undefined) { - return envValue; - } - if (defaultValue !== undefined) { - return defaultValue; - } - throw new Error(`missing environment value for ${name}`); - } - ); - } - - if (Array.isArray(value)) { - return value.map((item) => interpolate(item, env)); - } - - if (value && typeof value === "object") { - return Object.fromEntries( - Object.entries(value).map(([key, nestedValue]) => [ - key, - interpolate(nestedValue, env) - ]) - ); - } - - return value; + return uniqueRunnerLabels(["self-hosted", "macos", "arm64", "private"], labels); } function shellQuote(value: string): string { diff --git a/src/lib/runner-plane.ts b/src/lib/runner-plane.ts new file mode 100644 index 0000000..d7dc57b --- /dev/null +++ b/src/lib/runner-plane.ts @@ -0,0 +1,129 @@ +import type { RepositoryAccess } from "./config.js"; + +export type DockerCapablePlane = "linux-docker" | "windows-docker"; +export type RunnerPlane = "synology" | DockerCapablePlane; + +const repositoryPattern = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/; + +export function repositoryAccessSchemaValues(): ["all", "selected"] { + return ["all", "selected"]; +} + +export function validateRepositoryOwner(input: { + plane: string; + poolKey: string; + organization: string; + repositories: string[]; +}): void { + for (const repository of input.repositories) { + const [owner] = repository.split("/"); + if (owner !== input.organization) { + throw new Error( + `${input.plane} pool ${input.poolKey} includes ${repository}, which is outside organization ${input.organization}` + ); + } + } +} + +export function validateDockerRepositoryAccess(input: { + plane: DockerCapablePlane; + poolKey: string; + repositoryAccess: RepositoryAccess; + env: Record; +}): void { + if (input.repositoryAccess !== "all") { + return; + } + + const flagName = + input.plane === "linux-docker" + ? "LINUX_DOCKER_ALLOW_ALL_REPOSITORIES" + : "WINDOWS_DOCKER_ALLOW_ALL_REPOSITORIES"; + if (input.env[flagName] === "true") { + return; + } + + throw new Error( + `${input.plane} pool ${input.poolKey} uses repositoryAccess=all; set ${flagName}=true to explicitly allow all repositories on Docker-capable runners` + ); +} + +export function uniqueRunnerLabels( + defaultLabels: string[], + labels: string[] +): string[] { + return [...new Set([...defaultLabels, ...labels])]; +} + +export function buildCommonRunnerEnv(input: { + organization: string; + runnerName: string; + runnerGroup: string; + poolKey: string; + plane: RunnerPlane; + labels: string[]; + visibility: string; + repositoryAccess: RepositoryAccess; + runnerStateDir: string; + runnerLogDir: string; + runnerWorkDir: string; + runnerTemp: string; + runnerToolCache: string; +}): Record { + return { + GITHUB_PAT: "${GITHUB_PAT}", + GITHUB_API_URL: "${GITHUB_API_URL:-https://api.github.com}", + GITHUB_ORG: input.organization, + RUNNER_SCOPE: "organization", + RUNNER_NAME: input.runnerName, + RUNNER_GROUP: input.runnerGroup, + FLEET_POOL_KEY: input.poolKey, + FLEET_PLANE: input.plane, + RUNNER_LABELS: input.labels.join(","), + RUNNER_VISIBILITY: input.visibility, + RUNNER_REPOSITORY_ACCESS: input.repositoryAccess, + RUNNER_STATE_DIR: input.runnerStateDir, + RUNNER_LOG_DIR: input.runnerLogDir, + RUNNER_WORK_DIR: input.runnerWorkDir, + RUNNER_TEMP: input.runnerTemp, + RUNNER_TOOL_CACHE: input.runnerToolCache, + AGENT_TOOLSDIRECTORY: input.runnerToolCache, + RUNNER_EPHEMERAL: "true", + RUNNER_DISABLE_UPDATE: "true" + }; +} + +export function interpolateEnv(value: unknown, env: Record): unknown { + if (typeof value === "string") { + return value.replace( + /\$\{([A-Z0-9_]+)(?::-(.*?))?\}/g, + (_match, name: string, defaultValue?: string) => { + const envValue = env[name]; + if (envValue !== undefined) { + return envValue; + } + if (defaultValue !== undefined) { + return defaultValue; + } + throw new Error(`missing environment value for ${name}`); + } + ); + } + + if (Array.isArray(value)) { + return value.map((item) => interpolateEnv(item, env)); + } + + if (value && typeof value === "object") { + return Object.fromEntries( + Object.entries(value).map(([key, nestedValue]) => [ + key, + interpolateEnv(nestedValue, env) + ]) + ); + } + + return value; +} + +export { repositoryPattern }; diff --git a/src/lib/windows-compose.ts b/src/lib/windows-compose.ts index 31bc334..ef5fc07 100644 --- a/src/lib/windows-compose.ts +++ b/src/lib/windows-compose.ts @@ -5,6 +5,7 @@ import type { ResolvedWindowsDockerConfig, WindowsDockerPoolConfig } from "./windows-config.js"; +import { buildCommonRunnerEnv } from "./runner-plane.js"; import { renderTelemetryEnvironment } from "./telemetry.js"; export function renderWindowsDockerCompose( @@ -56,23 +57,21 @@ function renderService( const runnerToolCache = path.win32.join(runnerStateDir, "toolcache"); const serviceName = buildWindowsDockerServiceName(pool, index); const environment: Record = { - GITHUB_PAT: "${GITHUB_PAT}", - GITHUB_API_URL: "${GITHUB_API_URL:-https://api.github.com}", - GITHUB_ORG: pool.organization, - RUNNER_SCOPE: "organization", - RUNNER_NAME: serviceName, - RUNNER_GROUP: pool.runnerGroup, - RUNNER_LABELS: pool.labels.join(","), - RUNNER_VISIBILITY: pool.visibility, - RUNNER_REPOSITORY_ACCESS: pool.repositoryAccess, - RUNNER_STATE_DIR: runnerStateDir, - RUNNER_LOG_DIR: path.win32.join(runnerStateDir, "logs"), - RUNNER_WORK_DIR: runnerWorkDir, - RUNNER_TEMP: runnerTempDir, - RUNNER_TOOL_CACHE: runnerToolCache, - AGENT_TOOLSDIRECTORY: runnerToolCache, - RUNNER_EPHEMERAL: "true", - RUNNER_DISABLE_UPDATE: "true", + ...buildCommonRunnerEnv({ + organization: pool.organization, + runnerName: serviceName, + runnerGroup: pool.runnerGroup, + poolKey: pool.key, + plane: "windows-docker", + labels: pool.labels, + visibility: pool.visibility, + repositoryAccess: pool.repositoryAccess, + runnerStateDir, + runnerLogDir: path.win32.join(runnerStateDir, "logs"), + runnerWorkDir, + runnerTemp: runnerTempDir, + runnerToolCache + }), DOCKER_HOST: "npipe:////./pipe/docker_engine", ...renderTelemetryEnvironment(pool.telemetry, { serviceName: "github-runner-fleet.windows-docker", diff --git a/src/lib/windows-config.ts b/src/lib/windows-config.ts index 16660f9..db0af33 100644 --- a/src/lib/windows-config.ts +++ b/src/lib/windows-config.ts @@ -4,6 +4,13 @@ import YAML from "yaml"; import { z } from "zod"; import type { PoolResources, RepositoryAccess } from "./config.js"; import type { DeploymentEnv } from "./env.js"; +import { + interpolateEnv, + repositoryPattern, + uniqueRunnerLabels, + validateDockerRepositoryAccess, + validateRepositoryOwner +} from "./runner-plane.js"; import { telemetrySchema, type TelemetryConfig } from "./telemetry.js"; export interface WindowsDockerPoolConfig { @@ -34,7 +41,6 @@ export interface ResolvedWindowsDockerConfig { pools: WindowsDockerPoolConfig[]; } -const repositoryPattern = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/; const windowsAbsolutePathPattern = /^[A-Za-z]:[\\/]/; const poolSchema = z @@ -122,7 +128,7 @@ export function loadWindowsDockerConfig( const absolutePath = path.resolve(configPath); const source = fs.readFileSync(absolutePath, "utf8"); const parsed = YAML.parse(source); - const interpolated = interpolate(parsed, env.raw); + const interpolated = interpolateEnv(parsed, env.raw); const result = configSchema.parse(interpolated); const seenKeys = new Set(); @@ -137,15 +143,19 @@ export function loadWindowsDockerConfig( const allowedRepositories = pool.allowedRepositories ?? pool.repositories ?? []; const organization = pool.organization ?? inferOrganization(key, allowedRepositories); if (pool.repositoryAccess === "selected") { - for (const repository of allowedRepositories) { - const [owner] = repository.split("/"); - if (owner !== organization) { - throw new Error( - `windows-docker pool ${key} includes ${repository}, which is outside organization ${organization}` - ); - } - } + validateRepositoryOwner({ + plane: "windows-docker", + poolKey: key, + organization, + repositories: allowedRepositories + }); } + validateDockerRepositoryAccess({ + plane: "windows-docker", + poolKey: key, + repositoryAccess: pool.repositoryAccess, + env: env.raw + }); const runnerRoot = path.win32.normalize( pool.runnerRoot ?? @@ -173,7 +183,10 @@ export function loadWindowsDockerConfig( runnerGroup: pool.runnerGroup ?? pool.group!, repositoryAccess: pool.repositoryAccess, allowedRepositories, - labels: uniqueLabels(pool.labels), + labels: uniqueRunnerLabels( + ["windows", "docker-capable", "private"], + pool.labels + ), size: pool.size ?? pool.slots ?? 1, host: pool.host ?? env.windowsDockerHost ?? "", sshUser: pool.sshUser ?? env.windowsDockerUsername ?? "", @@ -223,40 +236,3 @@ function validateSingleInstallHost(pools: WindowsDockerPoolConfig[]): void { } } } - -function uniqueLabels(labels: string[]): string[] { - return [...new Set(["windows", "docker-capable", "private", ...labels])]; -} - -function interpolate(value: unknown, env: Record): unknown { - if (typeof value === "string") { - return value.replace( - /\$\{([A-Z0-9_]+)(?::-(.*?))?\}/g, - (_match, name: string, defaultValue?: string) => { - const envValue = env[name]; - if (envValue !== undefined) { - return envValue; - } - if (defaultValue !== undefined) { - return defaultValue; - } - throw new Error(`missing environment value for ${name}`); - } - ); - } - - if (Array.isArray(value)) { - return value.map((item) => interpolate(item, env)); - } - - if (value && typeof value === "object") { - return Object.fromEntries( - Object.entries(value).map(([key, nestedValue]) => [ - key, - interpolate(nestedValue, env) - ]) - ); - } - - return value; -} diff --git a/test/__snapshots__/linux-docker-compose.snapshot.test.ts.snap b/test/__snapshots__/linux-docker-compose.snapshot.test.ts.snap index ad1c6bb..8159d97 100644 --- a/test/__snapshots__/linux-docker-compose.snapshot.test.ts.snap +++ b/test/__snapshots__/linux-docker-compose.snapshot.test.ts.snap @@ -29,9 +29,9 @@ services: RUNNER_TEMP: /srv/github-runner-fleet/linux-docker/pools/linux-docker-private/runner-01/_temp RUNNER_TOOL_CACHE: /srv/github-runner-fleet/linux-docker/pools/linux-docker-private/runner-01/toolcache AGENT_TOOLSDIRECTORY: /srv/github-runner-fleet/linux-docker/pools/linux-docker-private/runner-01/toolcache - RUNNER_EXEC_MODE_OVERRIDE: root RUNNER_EPHEMERAL: "true" RUNNER_DISABLE_UPDATE: "true" + RUNNER_EXEC_MODE_OVERRIDE: root DOCKER_HOST: unix:///var/run/docker.sock RUNNER_ALLOWED_REPOSITORIES: example/private-app volumes: @@ -72,9 +72,9 @@ services: RUNNER_TEMP: /srv/github-runner-fleet/linux-docker/pools/linux-docker-private/runner-02/_temp RUNNER_TOOL_CACHE: /srv/github-runner-fleet/linux-docker/pools/linux-docker-private/runner-02/toolcache AGENT_TOOLSDIRECTORY: /srv/github-runner-fleet/linux-docker/pools/linux-docker-private/runner-02/toolcache - RUNNER_EXEC_MODE_OVERRIDE: root RUNNER_EPHEMERAL: "true" RUNNER_DISABLE_UPDATE: "true" + RUNNER_EXEC_MODE_OVERRIDE: root DOCKER_HOST: unix:///var/run/docker.sock RUNNER_ALLOWED_REPOSITORIES: example/private-app volumes: diff --git a/test/__snapshots__/linux-docker-install.snapshot.test.ts.snap b/test/__snapshots__/linux-docker-install.snapshot.test.ts.snap index 67dcf39..9e9a482 100644 --- a/test/__snapshots__/linux-docker-install.snapshot.test.ts.snap +++ b/test/__snapshots__/linux-docker-install.snapshot.test.ts.snap @@ -30,9 +30,9 @@ services: RUNNER_TEMP: /srv/github-runner-fleet/linux-docker/pools/linux-docker-private/runner-01/_temp RUNNER_TOOL_CACHE: /srv/github-runner-fleet/linux-docker/pools/linux-docker-private/runner-01/toolcache AGENT_TOOLSDIRECTORY: /srv/github-runner-fleet/linux-docker/pools/linux-docker-private/runner-01/toolcache - RUNNER_EXEC_MODE_OVERRIDE: root RUNNER_EPHEMERAL: "true" RUNNER_DISABLE_UPDATE: "true" + RUNNER_EXEC_MODE_OVERRIDE: root DOCKER_HOST: unix:///var/run/docker.sock RUNNER_ALLOWED_REPOSITORIES: example/private-app volumes: @@ -73,9 +73,9 @@ services: RUNNER_TEMP: /srv/github-runner-fleet/linux-docker/pools/linux-docker-private/runner-02/_temp RUNNER_TOOL_CACHE: /srv/github-runner-fleet/linux-docker/pools/linux-docker-private/runner-02/toolcache AGENT_TOOLSDIRECTORY: /srv/github-runner-fleet/linux-docker/pools/linux-docker-private/runner-02/toolcache - RUNNER_EXEC_MODE_OVERRIDE: root RUNNER_EPHEMERAL: "true" RUNNER_DISABLE_UPDATE: "true" + RUNNER_EXEC_MODE_OVERRIDE: root DOCKER_HOST: unix:///var/run/docker.sock RUNNER_ALLOWED_REPOSITORIES: example/private-app volumes: diff --git a/test/__snapshots__/windows-install.snapshot.test.ts.snap b/test/__snapshots__/windows-install.snapshot.test.ts.snap index fab2c55..62e60d9 100644 --- a/test/__snapshots__/windows-install.snapshot.test.ts.snap +++ b/test/__snapshots__/windows-install.snapshot.test.ts.snap @@ -19,6 +19,8 @@ services: RUNNER_SCOPE: organization RUNNER_NAME: windows-private-runner-01 RUNNER_GROUP: windows-private + FLEET_POOL_KEY: windows-private + FLEET_PLANE: windows-docker RUNNER_LABELS: windows,docker-capable,private,x64 RUNNER_VISIBILITY: private RUNNER_REPOSITORY_ACCESS: selected @@ -60,6 +62,8 @@ services: RUNNER_SCOPE: organization RUNNER_NAME: windows-private-runner-02 RUNNER_GROUP: windows-private + FLEET_POOL_KEY: windows-private + FLEET_PLANE: windows-docker RUNNER_LABELS: windows,docker-capable,private,x64 RUNNER_VISIBILITY: private RUNNER_REPOSITORY_ACCESS: selected diff --git a/test/cli.test.ts b/test/cli.test.ts index 3d1c7b0..5c10453 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -2123,6 +2123,7 @@ WINDOWS_DOCKER_USERNAME=administrator WINDOWS_DOCKER_PROJECT_DIR=C:\\github-runner-fleet\\windows-docker LUME_RUNNER_BASE_DIR=${directory}/lume LUME_RUNNER_ENV_FILE=${directory}/lume/runner.env +LUME_GUEST_PASSWORD=secret RUNNER_VERSION=2.333.0 COMPOSE_PROJECT_NAME=github-runner-fleet-test `, diff --git a/test/compose.test.ts b/test/compose.test.ts index cd9ca13..90e943c 100644 --- a/test/compose.test.ts +++ b/test/compose.test.ts @@ -39,11 +39,16 @@ describe("renderCompose", () => { ]); expect(privateService.security_opt).toEqual(["no-new-privileges:true"]); expect(privateService.cap_drop).toEqual(["ALL"]); + expect(privateService).not.toHaveProperty("ports"); + expect(privateService).not.toHaveProperty("privileged"); + expect(JSON.stringify(privateService.volumes)).not.toContain( + "/var/run/docker.sock" + ); expect(privateService).not.toHaveProperty("init"); expect(privateService).not.toHaveProperty("platform"); expect(privateService).not.toHaveProperty("cpus"); expect(privateService).not.toHaveProperty("pids_limit"); - expect(JSON.stringify(privateService)).not.toContain("/var/run/docker.sock"); + expect(JSON.stringify(privateService)).not.toContain("docker_engine"); const publicService = payload.services["synology-public-runner-01"]; expect(publicService.environment).toMatchObject({ diff --git a/test/doctor.test.ts b/test/doctor.test.ts index 4e3a315..abc7a97 100644 --- a/test/doctor.test.ts +++ b/test/doctor.test.ts @@ -60,12 +60,16 @@ SYNOLOGY_HOST=nas.example.com SYNOLOGY_USERNAME=admin SYNOLOGY_PASSWORD=secret SYNOLOGY_RUNNER_BASE_DIR=${directory}/synology -LINUX_DOCKER_HOST=docker.example.com +LINUX_DOCKER_HOST=docker-host.example.com LINUX_DOCKER_USERNAME=runner LINUX_DOCKER_PROJECT_DIR=${directory}/linux-docker LINUX_DOCKER_RUNNER_BASE_DIR=${directory}/linux-docker +WINDOWS_DOCKER_HOST=windows-host.example.com +WINDOWS_DOCKER_USERNAME=administrator +WINDOWS_DOCKER_RUNNER_BASE_DIR=C:\\github-runner-fleet\\windows-docker LUME_RUNNER_BASE_DIR=${directory}/lume LUME_RUNNER_ENV_FILE=${lumeRunnerEnvPath} +LUME_GUEST_PASSWORD=secret `, "utf8" ); @@ -128,6 +132,49 @@ pool: "utf8" ); + const linuxConfigPath = path.join(directory, "linux-docker-runners.yaml"); + fs.writeFileSync( + linuxConfigPath, + `version: 1 +image: + repository: ghcr.io/example/github-runner-fleet + tag: 0.1.9 +pools: + - key: linux-docker-private + organization: example + runnerGroup: linux-docker-private + repositoryAccess: selected + allowedRepositories: + - example/private-app + labels: [] + size: 1 + architecture: amd64 + runnerRoot: \${LINUX_DOCKER_RUNNER_BASE_DIR}/pools/linux-docker-private +`, + "utf8" + ); + + const windowsConfigPath = path.join(directory, "windows-runners.yaml"); + fs.writeFileSync( + windowsConfigPath, + `version: 1 +plane: windows-docker +image: + repository: ghcr.io/example/github-runner-fleet + tag: 0.1.9-windows +pools: + - key: windows-private + organization: example + runnerGroup: windows-private + repositoryAccess: selected + allowedRepositories: + - example/windows-app + host: windows-host.example.com + sshUser: administrator +`, + "utf8" + ); + const fetchMock = vi.fn(async (url: string) => { if (url.includes("/actions/runner-groups")) { return { @@ -150,6 +197,12 @@ pool: }, { id: 3, + name: "windows-private", + visibility: "selected", + default: false + }, + { + id: 4, name: "macos-private", visibility: "selected", default: false @@ -185,7 +238,8 @@ pool: mode: "full", envPath, configPath: poolsPath, - linuxDockerConfigPath: linuxDockerPath, + linuxConfigPath, + windowsConfigPath, lumeConfigPath: lumePath, fetchImpl: fetchMock }); @@ -209,6 +263,10 @@ pool: id: "linux-docker-image", status: "pass" }), + expect.objectContaining({ + id: "windows-docker-runner-groups", + status: "pass" + }), expect.objectContaining({ id: "lume-runner-group", status: "pass" @@ -505,6 +563,7 @@ pools: envPath, `LINUX_DOCKER_PROJECT_DIR=${directory}/linux-docker LINUX_DOCKER_RUNNER_BASE_DIR=${directory}/linux-docker +LINUX_DOCKER_ALLOW_ALL_REPOSITORIES=true `, "utf8" ); @@ -562,6 +621,70 @@ pools: ); }); + test("fails Windows Docker doctor when required env is missing and skips GitHub checks without a PAT", async () => { + const directory = createTempDir(); + const envPath = path.join(directory, ".env"); + fs.writeFileSync( + envPath, + `WINDOWS_DOCKER_RUNNER_BASE_DIR=C:\\github-runner-fleet\\windows-docker +`, + "utf8" + ); + + const windowsConfigPath = path.join(directory, "windows-runners.yaml"); + fs.writeFileSync( + windowsConfigPath, + `version: 1 +plane: windows-docker +image: + repository: ghcr.io/example/github-runner-fleet + tag: 0.1.9-windows +pools: + - key: windows-private + organization: example + runnerGroup: windows-private + repositoryAccess: selected + allowedRepositories: + - example/windows-app + host: windows-host.example.com + sshUser: administrator +`, + "utf8" + ); + + const report = await withEnv( + { + GITHUB_PAT: undefined, + GITHUB_TOKEN: undefined, + GH_TOKEN: undefined + }, + () => + runDoctor({ + mode: "windows-docker", + envPath, + windowsConfigPath + }) + ); + + expect(report.ok).toBe(false); + expect(report.checks).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: "windows-docker-env", + status: "fail" + }), + expect.objectContaining({ + id: "windows-docker-config", + status: "pass" + }), + expect.objectContaining({ + id: "windows-docker-runner-groups", + status: "skip" + }) + ]) + ); + }); + test("warns in Lume mode when the runner env file is missing", async () => { const directory = createTempDir(); const envPath = path.join(directory, ".env"); @@ -572,6 +695,7 @@ pools: `GITHUB_PAT=secret LUME_RUNNER_BASE_DIR=${directory}/lume LUME_RUNNER_ENV_FILE=${lumeRunnerEnvPath} +LUME_GUEST_PASSWORD=secret `, "utf8" ); @@ -672,6 +796,7 @@ pool: `GITHUB_PAT=secret LUME_RUNNER_BASE_DIR=${lumeBaseDir} LUME_RUNNER_ENV_FILE=${lumeRunnerEnvPath} +LUME_GUEST_PASSWORD=secret `, "utf8" ); @@ -741,6 +866,7 @@ pool: envPath, `LUME_RUNNER_BASE_DIR=${directory}/lume LUME_RUNNER_ENV_FILE=${lumeRunnerEnvPath} +LUME_GUEST_PASSWORD=secret `, "utf8" ); @@ -803,6 +929,7 @@ pool: envPath, `GITHUB_PAT=secret LUME_RUNNER_BASE_DIR=${directory}/lume +LUME_GUEST_PASSWORD=secret `, "utf8" ); diff --git a/test/linux-docker-config.test.ts b/test/linux-docker-config.test.ts index d2fe496..0f20866 100644 --- a/test/linux-docker-config.test.ts +++ b/test/linux-docker-config.test.ts @@ -28,7 +28,9 @@ pools: - key: linux-docker-private organization: example runnerGroup: linux-docker-private - repositoryAccess: all + repositoryAccess: selected + allowedRepositories: + - example/private-app labels: - x64 size: 1 @@ -80,9 +82,45 @@ pools: /outside organization example/ ); }); + + test("rejects repositoryAccess all unless break-glass is explicit", () => { + const directory = createTempDir(); + const configPath = path.join(directory, "linux-docker-runners.yaml"); + + fs.writeFileSync( + configPath, + `version: 1 +image: + repository: ghcr.io/example/github-runner-fleet + tag: 0.1.9 +pools: + - key: linux-docker-private + organization: example + runnerGroup: linux-docker-private + repositoryAccess: all + labels: [] + size: 1 + architecture: amd64 + runnerRoot: /srv/github-runner-fleet/linux-docker/pools/linux-docker-private +`, + "utf8" + ); + + expect(() => loadLinuxDockerConfig(configPath, deploymentEnv())).toThrow( + /LINUX_DOCKER_ALLOW_ALL_REPOSITORIES=true/ + ); + expect( + loadLinuxDockerConfig( + configPath, + deploymentEnv({ + LINUX_DOCKER_ALLOW_ALL_REPOSITORIES: "true" + }) + ).pools[0].repositoryAccess + ).toBe("all"); + }); }); -function deploymentEnv(): DeploymentEnv { +function deploymentEnv(raw: Record = {}): DeploymentEnv { return { githubApiUrl: "https://api.github.com", synologyRunnerBaseDir: "/volume1/docker/github-runner-fleet", @@ -117,7 +155,8 @@ function deploymentEnv(): DeploymentEnv { composeProjectName: "github-runner-fleet", runnerVersion: "2.333.0", raw: { - LINUX_DOCKER_RUNNER_BASE_DIR: "/srv/github-runner-fleet/linux-docker" + LINUX_DOCKER_RUNNER_BASE_DIR: "/srv/github-runner-fleet/linux-docker", + ...raw } }; } diff --git a/test/lume-config.test.ts b/test/lume-config.test.ts index 87f3395..d12689b 100644 --- a/test/lume-config.test.ts +++ b/test/lume-config.test.ts @@ -117,6 +117,27 @@ pool: ); }); + test("requires the guest password from config or LUME_GUEST_PASSWORD", () => { + const directory = createTempDir(); + const configPath = path.join(directory, "lume-runners.yaml"); + + fs.writeFileSync( + configPath, + `version: 1 +pool: + key: macos-private + size: 1 + vmBaseName: macos-runner-base + vmSlotPrefix: macos-runner-slot +`, + "utf8" + ); + + expect(() => + loadLumeConfig(configPath, deploymentEnv({ LUME_GUEST_PASSWORD: "" })) + ).toThrow(/LUME_GUEST_PASSWORD/); + }); + test("renders OTEL shell exports when telemetry is enabled", () => { const directory = createTempDir(); const configPath = path.join(directory, "lume-runners.yaml"); @@ -154,7 +175,7 @@ pool: }); }); -function deploymentEnv(): DeploymentEnv { +function deploymentEnv(raw: Record = {}): DeploymentEnv { return { githubApiUrl: "https://api.github.com", githubPat: undefined, @@ -201,8 +222,10 @@ function deploymentEnv(): DeploymentEnv { "/Users/tester/Library/Application Support/github-runner-fleet/lume/runner.env", LUME_RUNNER_IPSW_PATH: "/Users/tester/Library/Application Support/github-runner-fleet/lume/cache/latest.ipsw", + LUME_GUEST_PASSWORD: "secret", COMPOSE_PROJECT_NAME: "github-runner-fleet", - RUNNER_VERSION: "2.333.0" + RUNNER_VERSION: "2.333.0", + ...raw } }; } diff --git a/test/smoke-harness.test.ts b/test/smoke-harness.test.ts index f6d37a9..7cfe206 100644 --- a/test/smoke-harness.test.ts +++ b/test/smoke-harness.test.ts @@ -18,11 +18,11 @@ function makeTempRoot() { return tempRoot; } -async function waitForReady(logPath: string, port: number) { +async function waitForReady(logPath: string, host: string, port: number) { for (let attempt = 0; attempt < 50; attempt += 1) { if ( fs.existsSync(logPath) && - fs.readFileSync(logPath, "utf8").includes(`listening 0.0.0.0:${port}`) + fs.readFileSync(logPath, "utf8").includes(`listening ${host}:${port}`) ) { return; } @@ -38,9 +38,11 @@ describe("runner registration smoke harness", () => { const tempRoot = makeTempRoot(); const logPath = path.join(tempRoot, "mock-api.log"); const port = 18080 + Number(process.env.VITEST_POOL_ID ?? "0"); + const host = "127.0.0.1"; const server = spawn("node", ["scripts/smoke/mock-api.mjs"], { env: { ...process.env, + MOCK_HOST: host, MOCK_LOG_PATH: logPath, MOCK_PORT: String(port), }, @@ -48,7 +50,7 @@ describe("runner registration smoke harness", () => { }); try { - await waitForReady(logPath, port); + await waitForReady(logPath, host, port); await expect( fetch( diff --git a/test/synology-status.test.ts b/test/synology-status.test.ts index ed0a645..841c39a 100644 --- a/test/synology-status.test.ts +++ b/test/synology-status.test.ts @@ -33,7 +33,7 @@ describe("synology status", () => { JSON.stringify({ ok: true, project: { - name: "synology-github-runner", + name: "github-runner-fleet", status: "running", updated_at: "2026-04-12T08:00:00Z" }, @@ -45,7 +45,7 @@ describe("synology status", () => { end_time: "2026-04-12T08:00:00Z" } }, - remoteLogPath: "/volume1/docker/synology-github-runner/logs/install-project.log" + remoteLogPath: "/volume1/docker/github-runner-fleet/logs/install-project.log" }) ); @@ -133,7 +133,7 @@ function configFixture(): ResolvedConfig { return { version: 1, image: { - repository: "ghcr.io/example/synology-github-runner", + repository: "ghcr.io/example/github-runner-fleet", tag: "0.1.9" }, pools: [ @@ -147,11 +147,11 @@ function configFixture(): ResolvedConfig { labels: ["synology", "shell-only", "private"], size: 1, architecture: "auto", - runnerRoot: "/volume1/docker/synology-github-runner/pools/synology-private", + runnerRoot: "/volume1/docker/github-runner-fleet/pools/synology-private", resources: { memory: "2g" }, - imageRef: "ghcr.io/example/synology-github-runner:0.1.9" + imageRef: "ghcr.io/example/github-runner-fleet:0.1.9" } ] }; @@ -161,7 +161,7 @@ function envFixture(apiRepo: string): DeploymentEnv { return { githubPat: "test-pat", githubApiUrl: "https://api.github.com", - synologyRunnerBaseDir: "/volume1/docker/synology-github-runner", + synologyRunnerBaseDir: "/volume1/docker/github-runner-fleet", synologyHost: "nas.example.com", synologyPort: "5001", synologyUsername: "admin", @@ -170,17 +170,17 @@ function envFixture(apiRepo: string): DeploymentEnv { synologyCertVerify: false, synologyDsmVersion: 7, synologyApiRepo: apiRepo, - synologyProjectDir: "/volume1/docker/synology-github-runner", + synologyProjectDir: "/volume1/docker/github-runner-fleet", synologyProjectComposeFile: "compose.yaml", synologyProjectEnvFile: ".env", synologyInstallPullImages: true, synologyInstallForceRecreate: true, synologyInstallRemoveOrphans: true, lumeRunnerBaseDir: - "/Users/tester/Library/Application Support/synology-github-runner/lume", + "/Users/tester/Library/Application Support/github-runner-fleet/lume", lumeRunnerEnvFile: - "/Users/tester/Library/Application Support/synology-github-runner/lume/runner.env", - composeProjectName: "synology-github-runner", + "/Users/tester/Library/Application Support/github-runner-fleet/lume/runner.env", + composeProjectName: "github-runner-fleet", runnerVersion: "2.333.0", raw: {} }; diff --git a/test/windows-config.test.ts b/test/windows-config.test.ts index 4306ea4..c64c446 100644 --- a/test/windows-config.test.ts +++ b/test/windows-config.test.ts @@ -99,13 +99,17 @@ pools: - key: windows-one organization: example runnerGroup: windows-one - repositoryAccess: all + repositoryAccess: selected + allowedRepositories: + - example/windows-one host: windows-one.example.com sshUser: administrator - key: windows-two organization: example runnerGroup: windows-two - repositoryAccess: all + repositoryAccess: selected + allowedRepositories: + - example/windows-two host: windows-two.example.com sshUser: administrator `, @@ -116,9 +120,44 @@ pools: /must target the same host/ ); }); + + test("rejects repositoryAccess all unless break-glass is explicit", () => { + const directory = createTempDir(); + const configPath = path.join(directory, "windows-runners.yaml"); + + fs.writeFileSync( + configPath, + `version: 1 +plane: windows-docker +image: + repository: ghcr.io/example/github-runner-fleet + tag: 0.1.9-windows +pools: + - key: windows-private + organization: example + runnerGroup: windows-private + repositoryAccess: all + host: windows-host.example.com + sshUser: administrator +`, + "utf8" + ); + + expect(() => loadWindowsDockerConfig(configPath, deploymentEnv())).toThrow( + /WINDOWS_DOCKER_ALLOW_ALL_REPOSITORIES=true/ + ); + expect( + loadWindowsDockerConfig( + configPath, + deploymentEnv({ + WINDOWS_DOCKER_ALLOW_ALL_REPOSITORIES: "true" + }) + ).pools[0].repositoryAccess + ).toBe("all"); + }); }); -function deploymentEnv(): DeploymentEnv { +function deploymentEnv(raw: Record = {}): DeploymentEnv { return { githubApiUrl: "https://api.github.com", synologyRunnerBaseDir: "/volume1/docker/github-runner-fleet", @@ -163,7 +202,8 @@ function deploymentEnv(): DeploymentEnv { composeProjectName: "github-runner-fleet", runnerVersion: "2.333.0", raw: { - WINDOWS_DOCKER_RUNNER_BASE_DIR: "C:\\github-runner-fleet\\windows-docker" + WINDOWS_DOCKER_RUNNER_BASE_DIR: "C:\\github-runner-fleet\\windows-docker", + ...raw } }; } diff --git a/test/workflow-cookbook.test.ts b/test/workflow-cookbook.test.ts index 62db48d..dd69b8c 100644 --- a/test/workflow-cookbook.test.ts +++ b/test/workflow-cookbook.test.ts @@ -11,7 +11,9 @@ describe("workflow cookbook docs", () => { expect(cookbook).toContain("## Runner compatibility matrix"); expect(cookbook).toContain("| Node install, lint, test, build | Yes |"); - expect(cookbook).toContain("| Public fork pull requests | No | No | Yes |"); + expect(cookbook).toContain( + "| Public fork pull requests | No | No | No | No | Yes |" + ); expect(cookbook).toContain("## Recipe: trusted Node job on the Synology shell-only pool"); expect(cookbook).toContain("## Recipe: trusted jobs on self-hosted, fork PRs on GitHub-hosted"); @@ -19,7 +21,9 @@ describe("workflow cookbook docs", () => { expect(cookbook).toContain("## Recipe: Terraform validation on the Synology shell-only pool"); expect(cookbook).toContain("## Recipe: Lume macOS contract job"); - expect(cookbook).toContain("OMT-Global/synology-github-runner/actions/setup-shell-safe-node@main"); + expect(cookbook).toContain("OMT-Global/github-runner-fleet/actions/setup-shell-safe-node@main"); + expect(cookbook).toContain("[self-hosted, linux, docker-capable, private]"); + expect(cookbook).toContain("[self-hosted, windows, docker-capable, private]"); expect(cookbook).toContain("actions/setup-python@v6"); expect(cookbook).toContain("github.event.pull_request.head.repo.full_name != github.repository"); expect(cookbook).toContain("runs-on: ubuntu-latest"); diff --git a/test/workflow.test.ts b/test/workflow.test.ts index ba6c480..ebfee59 100644 --- a/test/workflow.test.ts +++ b/test/workflow.test.ts @@ -201,6 +201,53 @@ describe("CI workflow", () => { expect(workflow.jobs.test_public_fork_pr["runs-on"]).toBe("ubuntu-latest"); }); + test("keeps PR fast checks on self-hosted only for same-repo PRs", () => { + const workflow = YAML.parse( + fs.readFileSync(path.resolve(".github/workflows/pr-fast-ci.yml"), "utf8") + ) as { + jobs: Record>; + }; + + const selfHostedJobs = [ + workflow.jobs["fast-checks"], + workflow.jobs["validate-secrets"] + ]; + for (const job of selfHostedJobs) { + expect(job["runs-on"]).toEqual([ + "self-hosted", + "synology", + "shell-only", + "public" + ]); + expect(String(job.if)).toContain( + "github.event.pull_request.head.repo.full_name == github.repository" + ); + } + + expect(workflow.jobs.changes["runs-on"]).toBe("ubuntu-latest"); + expect(workflow.jobs["hosted-fork-fast-checks"]["runs-on"]).toBe( + "ubuntu-latest" + ); + expect(String(workflow.jobs["hosted-fork-fast-checks"].if)).toContain( + "github.event.pull_request.head.repo.full_name != github.repository" + ); + expect(workflow.jobs["hosted-fork-validate-secrets"]["runs-on"]).toBe( + "ubuntu-latest" + ); + expect(String(workflow.jobs["hosted-fork-validate-secrets"].if)).toContain( + "github.event.pull_request.head.repo.full_name != github.repository" + ); + expect(workflow.jobs["ci-gate"].needs).toEqual( + expect.arrayContaining([ + "fast-checks", + "validate-secrets", + "hosted-fork-fast-checks", + "hosted-fork-validate-secrets" + ]) + ); + expect(workflow.jobs["ci-gate"]["runs-on"]).toBe("ubuntu-latest"); + }); + test("renders the Linux Docker contract on hosted Linux before operators provision the pool", () => { const workflow = YAML.parse( fs.readFileSync(path.resolve(".github/workflows/ci.yml"), "utf8")