diff --git a/.github/SMOKE_TESTING.md b/.github/SMOKE_TESTING.md index ca53bb8..f89e3e5 100644 --- a/.github/SMOKE_TESTING.md +++ b/.github/SMOKE_TESTING.md @@ -18,7 +18,7 @@ PR that touches a greffon. ## Reading the spec -Each spec reads `process.env._URL`: +Most browser-facing specs read `process.env._URL`: | Greffon | Env var | |---|---| @@ -29,6 +29,19 @@ Each spec reads `process.env._URL`: | Plausible | `PLAUSIBLE_URL` | | VS Code | `VSCODE_URL` | +The GitHub Actions Runner greffon is headless and does not expose an HTTP URL. +Its spec is skipped by default. To verify a deployed runner through GitHub's +API, set: + +| Variable | Description | +|---|---| +| `GITHUB_RUNNER_SMOKE_TOKEN` | PAT that can list self-hosted runners for the target repo or org | +| `GITHUB_RUNNER_SMOKE_SCOPE` | `repo` or `org`; defaults to `repo` | +| `GITHUB_RUNNER_SMOKE_REPO` | `owner/repo`, required for repo scope | +| `GITHUB_RUNNER_SMOKE_ORG` | Organization name, required for org scope | +| `GITHUB_RUNNER_SMOKE_NAME_PREFIX` | Required unique runner name prefix for the deployment under test | +| `GITHUB_RUNNER_SMOKE_HOST` | GitHub host; defaults to `github.com` | + The local dev workflow (greffon root `scripts/setup-dev.sh` + the install dialog or the API) deploys an instance, the greffer assigns a port, and you set the env var to `https://127.0.0.1:`. @@ -71,6 +84,36 @@ npx playwright test against a local deploy until it passes. 5. Add a row to the env-var table above. +## GitHub Actions Runner notes + +Self-hosted GitHub Actions runners execute workflow code on your greffer host. +Only attach them to trusted private repositories or controlled organizations, +and avoid exposing them to untrusted pull requests. + +The catalog entry uses one grouped install form so the UI can validate the +required auth and scope combinations before deployment. It supports two GitHub +auth modes: + +- `RUNNER_TOKEN`: the short-lived registration token from GitHub's standard + `./config.sh --url ... --token ...` flow. This is convenient for manual + first-start testing, but it expires quickly and must be refreshed before a + later reconfigure or restart. +- `ACCESS_TOKEN`: a PAT used by the container to mint fresh registration + tokens automatically. This is the recommended mode for durable catalog + installs. + +For repo scope PAT auto-registration, use a PAT from a user with admin access +to the repository; fine-grained PATs need repository Administration +read/write permission. Classic PATs typically need `repo` for private +repositories or `public_repo` for public repositories. For org scope, use an +organization owner/admin PAT with self-hosted runner management permission; +classic PATs typically need `admin:org`. + +Runner jobs that use Docker are served by a Docker-in-Docker sidecar. The +catalog intentionally does not mount the host Docker socket because greffer +rewrites compose volumes and host socket mounts would also grant broad host +control to jobs. + ## CI infrastructure note The Integration Test job in `.github/workflows/validate-greffon.yml` checks diff --git a/github-runner/1.0/docker-compose.yml b/github-runner/1.0/docker-compose.yml new file mode 100644 index 0000000..7c5cb0e --- /dev/null +++ b/github-runner/1.0/docker-compose.yml @@ -0,0 +1,41 @@ +version: "3" + +services: + github_runner: + image: myoung34/github-runner:2.333.1-ubuntu-noble + restart: unless-stopped + depends_on: + - docker + environment: + DOCKER_HOST: tcp://docker:2375 + EPHEMERAL: "1" + DISABLE_AUTO_UPDATE: "1" + START_DOCKER_SERVICE: "false" + UNSET_CONFIG_VARS: "true" + RUN_AS_ROOT: "false" + RUNNER_WORKDIR: /runner/_work + RUNNER_SCOPE: repo + RUNNER_NAME_PREFIX: greffon-{{ instance_id }} + LABELS: greffon,self-hosted + GITHUB_HOST: github.com + volumes: + - runner-work:/runner/_work + + docker: + image: docker:29.4.0-dind + restart: unless-stopped + privileged: true + environment: + DOCKER_TLS_CERTDIR: "" + command: + - dockerd + - --host=tcp://0.0.0.0:2375 + - --host=unix:///var/run/docker.sock + - --tls=false + volumes: + - runner-work:/runner/_work + - docker-data:/var/lib/docker + +volumes: + runner-work: + docker-data: diff --git a/github-runner/1.0/metadata.json b/github-runner/1.0/metadata.json new file mode 100644 index 0000000..663d00e --- /dev/null +++ b/github-runner/1.0/metadata.json @@ -0,0 +1,223 @@ +{ + "name": "GitHub Actions Runner", + "logo": "https://github.githubassets.com/favicons/favicon.svg", + "description": "Self-hosted GitHub Actions runner with Docker-in-Docker support", + "categories": [ + "ci", + "developer-tools" + ], + "images": [], + "configurations": [ + { + "title": "RUNNER_CONFIGURATION", + "schema": { + "type": "object", + "properties": { + "auth_type": { + "type": "string", + "title": "Authentication method", + "description": "Choose a durable PAT-based setup or a short-lived GitHub runner registration token.", + "enum": [ + "access_token", + "runner_token" + ], + "enumNames": [ + "Personal access token (recommended)", + "Registration token" + ] + }, + "access_token": { + "type": "string", + "title": "GitHub PAT", + "description": "PAT used to request short-lived self-hosted runner registration tokens automatically.", + "writeOnly": true + }, + "runner_token": { + "type": "string", + "title": "GitHub runner registration token", + "description": "Short-lived registration token from GitHub's standard ./config.sh flow.", + "writeOnly": true + }, + "runner_scope": { + "type": "string", + "title": "Runner scope", + "description": "Use repo for one repository, or org for an organization-level runner.", + "enum": [ + "repo", + "org" + ] + }, + "repo_url": { + "type": "string", + "title": "Repository URL", + "description": "Required when runner scope is repo, for example https://github.com/owner/repo." + }, + "org_name": { + "type": "string", + "title": "Organization name", + "description": "Required when runner scope is org." + }, + "runner_name_prefix": { + "type": "string", + "title": "Runner name prefix", + "description": "Prefix for the generated GitHub runner name." + }, + "labels": { + "type": "string", + "title": "Runner labels", + "description": "Comma-separated labels added to this self-hosted runner." + }, + "runner_group": { + "type": "string", + "title": "Runner group", + "description": "Optional runner group name. Leave blank to use GitHub's default runner group." + }, + "github_host": { + "type": "string", + "title": "GitHub host", + "description": "GitHub host for GitHub Enterprise Server installations." + } + }, + "dependencies": { + "auth_type": { + "oneOf": [ + { + "properties": { + "auth_type": { + "enum": [ + "access_token" + ] + }, + "access_token": { + "minLength": 1 + } + }, + "required": [ + "access_token" + ] + }, + { + "properties": { + "auth_type": { + "enum": [ + "runner_token" + ] + }, + "runner_token": { + "minLength": 1 + } + }, + "required": [ + "runner_token" + ] + } + ] + }, + "runner_scope": { + "oneOf": [ + { + "properties": { + "runner_scope": { + "enum": [ + "repo" + ] + }, + "repo_url": { + "minLength": 1 + } + }, + "required": [ + "repo_url" + ] + }, + { + "properties": { + "runner_scope": { + "enum": [ + "org" + ] + }, + "org_name": { + "minLength": 1 + } + }, + "required": [ + "org_name" + ] + } + ] + } + } + }, + "default_value": { + "auth_type": "access_token", + "access_token": "", + "runner_token": "", + "runner_scope": "repo", + "repo_url": "", + "org_name": "", + "runner_name_prefix": "greffon-{{ instance_id }}", + "labels": "greffon,self-hosted", + "runner_group": "", + "github_host": "github.com" + }, + "destinations": [ + { + "type": "env", + "container": "github_runner", + "key": "ACCESS_TOKEN", + "value_path": "access_token" + }, + { + "type": "env", + "container": "github_runner", + "key": "RUNNER_TOKEN", + "value_path": "runner_token" + }, + { + "type": "env", + "container": "github_runner", + "key": "RUNNER_SCOPE", + "value_path": "runner_scope" + }, + { + "type": "env", + "container": "github_runner", + "key": "REPO_URL", + "value_path": "repo_url" + }, + { + "type": "env", + "container": "github_runner", + "key": "ORG_NAME", + "value_path": "org_name" + }, + { + "type": "env", + "container": "github_runner", + "key": "RUNNER_NAME_PREFIX", + "value_path": "runner_name_prefix" + }, + { + "type": "env", + "container": "github_runner", + "key": "LABELS", + "value_path": "labels" + }, + { + "type": "env", + "container": "github_runner", + "key": "RUNNER_GROUP", + "value_path": "runner_group" + }, + { + "type": "env", + "container": "github_runner", + "key": "GITHUB_HOST", + "value_path": "github_host" + } + ], + "x-greffon-allow-empty-secret": true + } + ] +} diff --git a/github-runner/1.0/smoke_test.spec.ts b/github-runner/1.0/smoke_test.spec.ts new file mode 100644 index 0000000..4ce2ce6 --- /dev/null +++ b/github-runner/1.0/smoke_test.spec.ts @@ -0,0 +1,89 @@ +import { expect, test, type APIRequestContext } from '@playwright/test'; + +const token = process.env.GITHUB_RUNNER_SMOKE_TOKEN; +const scope = process.env.GITHUB_RUNNER_SMOKE_SCOPE ?? 'repo'; +const repo = process.env.GITHUB_RUNNER_SMOKE_REPO; +const org = process.env.GITHUB_RUNNER_SMOKE_ORG; +const namePrefix = process.env.GITHUB_RUNNER_SMOKE_NAME_PREFIX; +const githubHost = process.env.GITHUB_RUNNER_SMOKE_HOST ?? 'github.com'; +const pageSize = 100; + +function apiBaseUrl() { + return githubHost === 'github.com' + ? 'https://api.github.com' + : `https://${githubHost}/api/v3`; +} + +function runnersPath(page: number) { + if (scope === 'org') { + return `/orgs/${org}/actions/runners?per_page=${pageSize}&page=${page}`; + } + + return `/repos/${repo}/actions/runners?per_page=${pageSize}&page=${page}`; +} + +async function findRunnerByPrefix( + request: APIRequestContext, + authToken: string, + prefix: string, +) { + for (let page = 1; page <= 10; page += 1) { + const response = await request.get(`${apiBaseUrl()}${runnersPath(page)}`, { + headers: { + Accept: 'application/vnd.github+json', + Authorization: `Bearer ${authToken}`, + 'X-GitHub-Api-Version': '2022-11-28', + }, + timeout: 15_000, + }); + + expect(response.ok()).toBeTruthy(); + const body = await response.json(); + const runners = body.runners ?? []; + const runner = runners.find((candidate: { name?: string }) => + candidate.name?.startsWith(prefix), + ); + + if (runner) { + return runner; + } + + if (runners.length < pageSize) { + return null; + } + } + + return null; +} + +test.describe('GitHub Actions Runner', () => { + test('registered runner appears online in GitHub', async ({ request }) => { + const missing = [ + !token && 'GITHUB_RUNNER_SMOKE_TOKEN', + scope === 'repo' && !repo && 'GITHUB_RUNNER_SMOKE_REPO', + scope === 'org' && !org && 'GITHUB_RUNNER_SMOKE_ORG', + !namePrefix && 'GITHUB_RUNNER_SMOKE_NAME_PREFIX', + ].filter(Boolean); + + test.skip( + missing.length > 0, + `Set ${missing.join(', ')} to check a deployed runner through the GitHub API.`, + ); + + expect(['repo', 'org']).toContain(scope); + + await expect + .poll( + async () => { + const runner = await findRunnerByPrefix(request, token!, namePrefix!); + return runner?.status ?? 'missing'; + }, + { + message: `Runner with prefix "${namePrefix}" should be online`, + timeout: 120_000, + intervals: [5_000, 10_000], + }, + ) + .toBe('online'); + }); +});