markdown · 3763 bytes Raw Blame History

Actions runner API

The runner-facing HTTP surface lives in internal/web/handlers/api/runners.go. It is mounted under /api/v1 in the CSRF-exempt API group, but it does not use PAT auth. Runners authenticate first with a long-lived registration token and then with short-lived per-job JWTs.

Auth model

Operators register a runner with:

shithubd admin runner register --name runner-1 --labels self-hosted,linux,ubuntu-latest

The command inserts workflow_runners, stores only a SHA-256 hash in runner_tokens, and prints the 32-byte hex token once.

POST /api/v1/runners/heartbeat accepts:

Authorization: Bearer <registration-token>

When a queued job matches the runner labels and capacity is available, the response includes a job payload and a 15-minute job JWT. That JWT has claims:

{"sub":"runner:<id>","job_id":1,"run_id":1,"repo_id":1,"exp":0,"jti":"..."}

The signing key is derived from auth.totp_key_b64 with HKDF label actions-runner-jwt-v1; the raw TOTP/secretbox key is not used directly for JWT signing.

Job JWTs are single-use. Every job endpoint verifies the signature and expiry, checks that the path job belongs to the claimed runner/run, and then inserts jti into runner_jwt_used. A replay returns 401. To support multi-step runner flows, successful non-terminal job endpoints return next_token and next_token_expires_at.

shithubd-runner consumes the same token chain: it claims with the registration token, marks the job running with the first job JWT, then uses the returned next_token for the terminal status update. Reusing any consumed job JWT is a replay and must fail with 401.

Endpoints

POST /api/v1/runners/heartbeat

Request body:

{"labels":["ubuntu-latest","linux"],"capacity":1}

Returns 204 when no matching job is claimable. Returns 200 with token, expires_at, and job when a job is claimed. Capacity is enforced server-side by counting current workflow_jobs.status = 'running' rows for the runner while holding a row lock on the runner.

POST /api/v1/jobs/{id}/logs

Auth: job JWT. Body:

{"seq":0,"chunk":"aGVsbG8K","step_id":123}

step_id is optional for the S41c curl smoke path; when omitted the first step in the job receives the chunk. Chunks are base64-decoded, capped at 512 KiB raw, and appended to workflow_step_log_chunks. Duplicate (step_id, seq) inserts are accepted as idempotent retries.

POST /api/v1/jobs/{id}/status

Auth: job JWT. Body:

{"status":"completed","conclusion":"success"}

Valid transitions are queued|running -> running|completed|cancelled. Completed jobs require a valid check conclusion. The handler updates workflow_jobs, rolls up workflow_runs, and best-effort updates the matching check_runs row created by the trigger pipeline.

S41d PR1 runner execution supports containerized run: steps. uses: aliases such as actions/checkout@v4 and artifact upload/download are reserved for the later S41d slices that add checkout metadata, log streaming, and artifact transfer.

POST /api/v1/jobs/{id}/artifacts/upload

Auth: job JWT. Body:

{"name":"test-results.tgz","size_bytes":12345}

Creates a workflow_artifacts row and returns a pre-signed S3 PUT URL. The object key is actions/runs/<run_id>/artifacts/<name>.

POST /api/v1/jobs/{id}/cancel-check

Auth: job JWT. Returns:

{"cancelled":false,"next_token":"..."}

The boolean mirrors workflow_jobs.cancel_requested; the actual cancel request UI lands later in S41g.

Metrics

  • shithub_actions_runner_registrations_total
  • shithub_actions_runner_heartbeats_total{result="claimed|no_job"}
  • shithub_actions_runner_jwt_total{result="issued|rejected|replay"}
View source
1 # Actions runner API
2
3 The runner-facing HTTP surface lives in
4 `internal/web/handlers/api/runners.go`. It is mounted under `/api/v1`
5 in the CSRF-exempt API group, but it does not use PAT auth. Runners
6 authenticate first with a long-lived registration token and then with
7 short-lived per-job JWTs.
8
9 ## Auth model
10
11 Operators register a runner with:
12
13 ```sh
14 shithubd admin runner register --name runner-1 --labels self-hosted,linux,ubuntu-latest
15 ```
16
17 The command inserts `workflow_runners`, stores only a SHA-256 hash in
18 `runner_tokens`, and prints the 32-byte hex token once.
19
20 `POST /api/v1/runners/heartbeat` accepts:
21
22 ```http
23 Authorization: Bearer <registration-token>
24 ```
25
26 When a queued job matches the runner labels and capacity is available,
27 the response includes a job payload and a 15-minute job JWT. That JWT
28 has claims:
29
30 ```json
31 {"sub":"runner:<id>","job_id":1,"run_id":1,"repo_id":1,"exp":0,"jti":"..."}
32 ```
33
34 The signing key is derived from `auth.totp_key_b64` with HKDF label
35 `actions-runner-jwt-v1`; the raw TOTP/secretbox key is not used
36 directly for JWT signing.
37
38 Job JWTs are single-use. Every job endpoint verifies the signature and
39 expiry, checks that the path job belongs to the claimed runner/run, and
40 then inserts `jti` into `runner_jwt_used`. A replay returns 401. To
41 support multi-step runner flows, successful non-terminal job endpoints
42 return `next_token` and `next_token_expires_at`.
43
44 `shithubd-runner` consumes the same token chain: it claims with the
45 registration token, marks the job `running` with the first job JWT, then
46 uses the returned `next_token` for the terminal status update. Reusing
47 any consumed job JWT is a replay and must fail with 401.
48
49 ## Endpoints
50
51 `POST /api/v1/runners/heartbeat`
52
53 Request body:
54
55 ```json
56 {"labels":["ubuntu-latest","linux"],"capacity":1}
57 ```
58
59 Returns 204 when no matching job is claimable. Returns 200 with
60 `token`, `expires_at`, and `job` when a job is claimed. Capacity is
61 enforced server-side by counting current `workflow_jobs.status =
62 'running'` rows for the runner while holding a row lock on the runner.
63
64 `POST /api/v1/jobs/{id}/logs`
65
66 Auth: job JWT. Body:
67
68 ```json
69 {"seq":0,"chunk":"aGVsbG8K","step_id":123}
70 ```
71
72 `step_id` is optional for the S41c curl smoke path; when omitted the
73 first step in the job receives the chunk. Chunks are base64-decoded,
74 capped at 512 KiB raw, and appended to `workflow_step_log_chunks`.
75 Duplicate `(step_id, seq)` inserts are accepted as idempotent retries.
76
77 `POST /api/v1/jobs/{id}/status`
78
79 Auth: job JWT. Body:
80
81 ```json
82 {"status":"completed","conclusion":"success"}
83 ```
84
85 Valid transitions are `queued|running -> running|completed|cancelled`.
86 Completed jobs require a valid check conclusion. The handler updates
87 `workflow_jobs`, rolls up `workflow_runs`, and best-effort updates the
88 matching `check_runs` row created by the trigger pipeline.
89
90 S41d PR1 runner execution supports containerized `run:` steps. `uses:`
91 aliases such as `actions/checkout@v4` and artifact upload/download are
92 reserved for the later S41d slices that add checkout metadata, log
93 streaming, and artifact transfer.
94
95 `POST /api/v1/jobs/{id}/artifacts/upload`
96
97 Auth: job JWT. Body:
98
99 ```json
100 {"name":"test-results.tgz","size_bytes":12345}
101 ```
102
103 Creates a `workflow_artifacts` row and returns a pre-signed S3 PUT URL.
104 The object key is `actions/runs/<run_id>/artifacts/<name>`.
105
106 `POST /api/v1/jobs/{id}/cancel-check`
107
108 Auth: job JWT. Returns:
109
110 ```json
111 {"cancelled":false,"next_token":"..."}
112 ```
113
114 The boolean mirrors `workflow_jobs.cancel_requested`; the actual cancel
115 request UI lands later in S41g.
116
117 ## Metrics
118
119 - `shithub_actions_runner_registrations_total`
120 - `shithub_actions_runner_heartbeats_total{result="claimed|no_job"}`
121 - `shithub_actions_runner_jwt_total{result="issued|rejected|replay"}`