Add more details about the API in the readme (#47)

This commit is contained in:
Francesco Renzi
2026-01-13 17:19:32 +00:00
committed by GitHub
parent 0015641f99
commit 145b7e382f
+103 -20
View File
@@ -4,24 +4,116 @@
This repository provides a standalone Go client for the GitHub Actions **Runner Scale Set** APIs. It is extracted from the `actions-runner-controller` project so that platform teams, integrators, and infrastructure providers can build **their own custom autoscaling solutions** for GitHub Actions runners.
You do *not* need to adopt the full controller (and Kubernetes) to take advantage of scale sets. This package contains all the primitives you need: create/update/delete scale sets, generate justintime (JIT) runner configs, manage message sessions, and react to job lifecycle events.
You do *not* need to adopt the full controller (and Kubernetes) to take advantage of scale sets. This package contains all the primitives you need: create/update/delete scale sets, generate justintime (JIT) runner configs, and manage message sessions.
---
## What is a Scale Set?
A runner scale set is a group of self-hosted runners that autoscales based on workflow demand. Here's how it works:
1. **Registration**: You create a scale set with a name, which also serves as the label workflows use to target it (e.g., `runs-on: my-scale-set`). Like regular self-hosted runners, scale sets can be registered at the repository, organization, or enterprise level.
2. **Polling**: Your scale set client continuously polls the API, reporting its maximum capacity (how many runners it can produce).
3. **Job matching**: GitHub matches jobs to your scale set based on the label and runner group policies, just like regular self-hosted runners.
4. **Scaling signal**: The API responds with how many runners your scale set needs online (`statistics.TotalAssignedJobs`).
5. **Runner provisioning**: Your client creates or maintains enough runners to meet demand. Runners can be created just-in-time as jobs arrive, or pre-provisioned ahead of demand to reduce latency.
6. **Job assignment**: GitHub assigns a pending job to any idle runner in the scale set.
Runners in a scale set are ephemeral by default: each runner executes one job and is then removed. This ensures a clean environment for every job.
---
## High-Level Flow
1. Create a `Client` with either a GitHub App credential (recommended) or a PAT.
2. Create a Runner Scale Set specifying name & settings.
3. Start a message session to receive scale / job events: the `listener` package in this repo can give you a headstart with this.
4. What you need to bring is what it means **to your infrastructure** to provision/tear down a runner:
- Call `GenerateJitRunnerConfig` to obtain an encoded JIT config for a new runner belonging to your scale set.
- Start a fresh runner process/container/VM passing the JIT config.
- 🎉 You have a new runner!
2. Create a Runner Scale Set with a name.
3. Start a message session and poll for scaling events. The `listener` package handles this for you.
4. When the API indicates runners are needed:
- Call `GenerateJitRunnerConfig` to get a JIT config for a new runner.
- Start your runner (process, container, VM, etc.) with the JIT config.
5. Idle runners are assigned jobs automatically by GitHub.
You can find a complete example of a Docker-based scale set in [`examples/dockerscaleset`](./examples/dockerscaleset).
You can also pre-provision runners before jobs arrive to reduce startup latency. See [`examples/dockerscaleset`](./examples/dockerscaleset) for a complete example that supports both `minRunners` (pre-provisioned) and just-in-time scaling.
> [!NOTE]
> It's important to let the API know about your scale set maximum capacity using the `X-ScaleSetMaxCapacity` header when starting a message session so that job assignment can be as accurate as possible.
---
## Autoscaling
Use `statistics.TotalAssignedJobs` from each message response to determine how many runners your scale set needs online. This value represents the total number of jobs assigned to your scale set, including both jobs waiting for a runner and jobs already running (`TotalAssignedJobs >= TotalRunningJobs`).
Do not count individual job messages (`JobAssigned`, `JobStarted`, `JobCompleted`) in the response body to determine scaling:
- Responses contain at most 50 messages. Large backlogs will be truncated.
- The `statistics` field is always current and reflects the true state of your scale set.
When polling for messages, include your scale set's maximum capacity via the `maxCapacity` parameter (sent as the `X-ScaleSetMaxCapacity` header). This allows the backend to assign jobs accurately and avoid creating backlogs your scale set cannot fulfill.
Here's a simplified polling loop:
```go
var lastMessageID int
for {
msg, err := client.GetMessage(ctx, lastMessageID, maxCapacity)
if err != nil {
return err
}
if msg == nil {
// No messages available (202 response), poll again
continue
}
lastMessageID = msg.MessageID
// Scale based on statistics, not message counts
desiredRunners := msg.Statistics.TotalAssignedJobs
scaleToDesired(desiredRunners)
// Acknowledge the message
if err := client.DeleteMessage(ctx, msg.MessageID); err != nil {
return err
}
}
```
The `listener` package provides a ready-to-use implementation of this pattern, handling session management, polling, and acknowledgment. See [`listener/listener.go`](./listener/listener.go).
### Job lifecycle messages
Individual job messages (`JobStarted`, `JobCompleted`, etc.) are useful for purposes beyond scaling. For example, [actions-runner-controller](https://github.com/actions/actions-runner-controller) uses `JobStarted` to mark runner pods as busy, preventing premature cleanup during scale-down. These messages can also be used for metrics or logging.
See [`types.go`](./types.go) for payload definitions.
---
## How the Message API Works
### Long Polling
`GetMessage` uses long polling:
1. If messages are available, they are returned immediately.
2. Otherwise, the request blocks for up to ~50 seconds.
3. If no messages arrive, a 202 response is returned (`nil, nil` in the Go client).
Poll again immediately after handling each response.
### Message Acknowledgment
Call `DeleteMessage` after processing a message. This acts as an acknowledgment:
- Unacknowledged messages are redelivered on the next poll.
- This prevents message loss if your client crashes mid-processing.
### Message ID Tracking
Pass the ID of the last processed message to `GetMessage`. Omitting this (or passing 0) returns the first available message, potentially causing reprocessing.
### Job Reassignment
Jobs may appear multiple times as `JobAssigned` followed by `JobCompleted` (with `result: "canceled"`). This occurs when a job is assigned to your scale set but not acquired by a runner in time—GitHub cancels the assignment and requeues the job. This can happen up to 3 times with incremental delays.
Each attempt generates new messages, but they represent the same workflow job. This is why `statistics.TotalAssignedJobs` is the correct scaling metric: it reflects the current state, not the message history.
---
@@ -59,16 +151,7 @@ The client automatically exchanges credentials for a registration token + admin
You can find more details on required permissions in the [GitHub Docs](https://docs.github.com/en/actions/tutorials/use-actions-runner-controller/authenticate-to-the-api).
---
## Working With Messages
- Call `CreateMessageSession` to obtain `messageQueueUrl` and `messageQueueAccessToken`.
- Poll with `GetMessage(lastMessageID, maxCapacity)`; you get scaling / job events.
- After processing a message, call `DeleteMessage(messageId)`.
- Refresh or delete the session as needed (`RefreshMessageSession`, `DeleteMessageSession`).
Scaling logic uses the statistics & job events to decide how many new JIT configs to generate.
GitHub Enterprise Server (GHES) is supported out of the box—just use your GHES URL when creating the client.
---