scaleset

package module
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Feb 5, 2026 License: MIT Imports: 21 Imported by: 0

README

GitHub Actions Runner Scale Set Client (Public Preview)

Status: Public Preview – While the API is stable, interfaces and examples in this repository may change.

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 just‑in‑time (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). Multiple labels can be assigned per 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 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 also pre-provision runners before jobs arrive to reduce startup latency. See examples/dockerscaleset for a complete example that supports both minRunners (pre-provisioned) and just-in-time scaling.


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:

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.

Job lifecycle messages

Individual job messages (JobStarted, JobCompleted, etc.) are useful for purposes beyond scaling. For example, 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 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.


Getting Started

go get github.com/actions/scaleset@latest

Import:

import "github.com/actions/scaleset"
Using Without Go Experience

If you are not a Go developer, you can still:

  • Treat this repo as reference documentation to design an API integration in another language.
  • Vendor the code and compile a minimal binary that exposes a simpler CLI.
  • Use the example CLI (examples/dockerscaleset) as inspiration—its flags show required inputs.
  • Copilot can also help you translate this Go code into your language of choice.

Authentication

Two options:

  1. GitHub App (preferred): Stronger scoping & rotation. Provide: ClientID, InstallationID, PrivateKey.
  2. PAT (personal access token): Simpler but broader scoped.

The client automatically exchanges credentials for a registration token + admin token behind the scenes and refreshes them before expiry.

You can find more details on required permissions in the GitHub Docs.

GitHub Enterprise Server (GHES) is supported out of the box—just use your GHES URL when creating the client.


Security Notes

  • Always prefer GitHub App credentials; rotate PATs if you must use them.
  • Treat JIT configs as secrets until consumed.

Requirements

  • Go 1.25 or later

License

This project is licensed under the terms of the MIT open source license. Please refer to LICENSE for the full terms.


Maintainers

See CODEOWNERS for the list of maintainers.


Support

Please refer to SUPPORT.md for information on how to get help with this project.

Documentation

Overview

Package scaleset package provides a client to interact with GitHub Scale Set APIs.

Index

Constants

View Source
const DefaultRunnerGroup = "default"
View Source
const HeaderScaleSetMaxCapacity = "X-ScaleSetMaxCapacity"

HeaderScaleSetMaxCapacity is used to propagate the scale set max capacity when polling for messages.

Variables

View Source
var (
	RunnerNotFoundError           = scalesetError("runner not found")
	RunnerExistsError             = scalesetError("runner exists")
	JobStillRunningError          = scalesetError("job still running")
	MessageQueueTokenExpiredError = scalesetError("message queue token expired")
)
View Source
var ErrInvalidGitHubConfigURL = fmt.Errorf("invalid config URL, should point to an enterprise, org, or repository")

Functions

This section is empty.

Types

type Client

type Client struct {
	// contains filtered or unexported fields
}

Client implements a GitHub Actions Scale Set client.

func NewClientWithGitHubApp

func NewClientWithGitHubApp(config ClientWithGitHubAppConfig, options ...HTTPOption) (*Client, error)

NewClientWithGitHubApp creates a new Client using GitHub App credentials.

func NewClientWithPersonalAccessToken

func NewClientWithPersonalAccessToken(config NewClientWithPersonalAccessTokenConfig, options ...HTTPOption) (*Client, error)

NewClientWithPersonalAccessToken creates a new Client using a personal access token.

func (*Client) CreateRunnerScaleSet

func (c *Client) CreateRunnerScaleSet(ctx context.Context, runnerScaleSet *RunnerScaleSet) (*RunnerScaleSet, error)

CreateRunnerScaleSet creates a new runner scale set. Note that runner scale set names must be unique within a runner group.

func (*Client) DebugInfo

func (c *Client) DebugInfo() string

DebugInfo returns a JSON string containing debug information about the client, including whether a proxy or custom root CA is configured, and the current system info. This method is intended for diagnostic and troubleshooting purposes.

func (*Client) DeleteRunnerScaleSet

func (c *Client) DeleteRunnerScaleSet(ctx context.Context, runnerScaleSetID int) error

DeleteRunnerScaleSet deletes a runner scale set by its ID.

func (*Client) GenerateJitRunnerConfig

func (c *Client) GenerateJitRunnerConfig(ctx context.Context, jitRunnerSetting *RunnerScaleSetJitRunnerSetting, scaleSetID int) (*RunnerScaleSetJitRunnerConfig, error)

GenerateJitRunnerConfig generates a JIT runner configuration for the specified runner scale set. This returns an encoded configuration that can be used to directly start a new runner.

func (*Client) GetRunner

func (c *Client) GetRunner(ctx context.Context, runnerID int) (*RunnerReference, error)

GetRunner fetches a runner by its ID. This can be used to check if a runner exists.

func (*Client) GetRunnerByName

func (c *Client) GetRunnerByName(ctx context.Context, runnerName string) (*RunnerReference, error)

GetRunnerByName fetches a runner by its name. This can be used to check if a runner exists.

func (*Client) GetRunnerGroupByName

func (c *Client) GetRunnerGroupByName(ctx context.Context, runnerGroup string) (*RunnerGroup, error)

GetRunnerGroupByName fetches a runner group by its name.

func (*Client) GetRunnerScaleSet

func (c *Client) GetRunnerScaleSet(ctx context.Context, runnerGroupID int, runnerScaleSetName string) (*RunnerScaleSet, error)

GetRunnerScaleSet fetches a runner scale set by its name within a runner group.

func (*Client) GetRunnerScaleSetByID

func (c *Client) GetRunnerScaleSetByID(ctx context.Context, runnerScaleSetID int) (*RunnerScaleSet, error)

GetRunnerScaleSetByID fetches a runner scale set by its ID.

func (*Client) MessageSessionClient

func (c *Client) MessageSessionClient(ctx context.Context, runnerScaleSetID int, owner string, options ...HTTPOption) (*MessageSessionClient, error)

MessageSessionClient creates a new MessageSessionClient for the specified runner scale set ID and owner.

It exposes client options that could be overwritten, providing ability to specify different retry policies or TLS settings, proxy, etc.

func (*Client) RemoveRunner

func (c *Client) RemoveRunner(ctx context.Context, runnerID int64) error

RemoveRunner removes a runner by its ID.

func (*Client) SetSystemInfo

func (c *Client) SetSystemInfo(info SystemInfo)

SetSystemInfo updates the information about the system.

func (*Client) SystemInfo

func (c *Client) SystemInfo() SystemInfo

SystemInfo returns the current system info that the client has configured.

func (*Client) UpdateRunnerScaleSet

func (c *Client) UpdateRunnerScaleSet(ctx context.Context, runnerScaleSetID int, runnerScaleSet *RunnerScaleSet) (*RunnerScaleSet, error)

UpdateRunnerScaleSet updates an existing runner scale set.

type ClientWithGitHubAppConfig

type ClientWithGitHubAppConfig struct {
	GitHubConfigURL string
	GitHubAppAuth   GitHubAppAuth
	SystemInfo      SystemInfo
}

type GitHubAppAuth

type GitHubAppAuth struct {
	// ClientID is the Client ID of the application (app id also works)
	ClientID string
	// InstallationID is the installation ID of the GitHub App
	InstallationID int64
	// PrivateKey is the private key of the GitHub App in PEM format
	PrivateKey string
}

GitHubAppAuth contains the GitHub App authentication credentials. All fields are required.

func (*GitHubAppAuth) Validate

func (a *GitHubAppAuth) Validate() error

Validate returns an error if any required field is missing.

type HTTPOption

type HTTPOption func(*httpClientOption)

HTTPOption defines a functional option for configuring the Client.

func WithLogger

func WithLogger(logger slog.Logger) HTTPOption

WithLogger sets a custom logger for the Client.

func WithProxy

func WithProxy(proxyFunc ProxyFunc) HTTPOption

WithProxy sets a custom proxy function for the Client.

func WithRetryMax

func WithRetryMax(retryMax int) HTTPOption

WithRetryMax sets the maximum number of retries for the Client.

func WithRetryWaitMax

func WithRetryWaitMax(retryWaitMax time.Duration) HTTPOption

WithRetryWaitMax sets the maximum wait time between retries for the Client.

func WithRootCAs

func WithRootCAs(rootCAs *x509.CertPool) HTTPOption

WithRootCAs sets custom root certificate authorities for the Client.

func WithoutTLSVerify

func WithoutTLSVerify() HTTPOption

WithoutTLSVerify disables TLS certificate verification for the Client.

type JobAssigned

type JobAssigned struct {
	JobMessageBase
}

type JobCompleted

type JobCompleted struct {
	Result     string `json:"result"`
	RunnerID   int    `json:"runnerId"`
	RunnerName string `json:"runnerName"`
	JobMessageBase
}

type JobMessageBase

type JobMessageBase struct {
	JobMessageType
	RunnerRequestID    int64     `json:"runnerRequestId"`
	RepositoryName     string    `json:"repositoryName"`
	OwnerName          string    `json:"ownerName"`
	JobID              string    `json:"jobId"`
	JobWorkflowRef     string    `json:"jobWorkflowRef"`
	JobDisplayName     string    `json:"jobDisplayName"`
	WorkflowRunID      int64     `json:"workflowRunId"`
	EventName          string    `json:"eventName"`
	RequestLabels      []string  `json:"requestLabels"`
	QueueTime          time.Time `json:"queueTime"`
	ScaleSetAssignTime time.Time `json:"scaleSetAssignTime"`
	RunnerAssignTime   time.Time `json:"runnerAssignTime"`
	FinishTime         time.Time `json:"finishTime"`
}

type JobMessageType

type JobMessageType struct {
	MessageType MessageType `json:"messageType"`
}

type JobStarted

type JobStarted struct {
	RunnerID   int    `json:"runnerId"`
	RunnerName string `json:"runnerName"`
	JobMessageBase
}

type Label

type Label struct {
	Type string `json:"type"`
	Name string `json:"name"`
}

type MessageSessionClient

type MessageSessionClient struct {
	// contains filtered or unexported fields
}

MessageSessionClient is a client used to interact with a message session for a runner scale set. It provides methods to Get and Delete messages from the message queue associated with the session, handling session token expiration and refreshing as needed.

It is safe for concurrent use by multiple goroutines. Please do not forget to call Close when done to clean up the session.

func (*MessageSessionClient) Close

func (c *MessageSessionClient) Close(ctx context.Context) error

Close deletes the message session associated with this client.

func (*MessageSessionClient) DeleteMessage

func (c *MessageSessionClient) DeleteMessage(ctx context.Context, messageID int) error

DeleteMessage deletes a message from the runner scale set message queue. This should typically be done after processing the message and acts as an acknowledgment. If the current session token is expired, it refreshes the session and tries one more time.

func (*MessageSessionClient) GetMessage

func (c *MessageSessionClient) GetMessage(ctx context.Context, lastMessageID int, maxCapacity int) (*RunnerScaleSetMessage, error)

GetMessage fetches a message from the runner scale set message queue. If there are no messages available, it returns (nil, nil). Unless a message is deleted after being processed (using DeleteMessage), it will be returned again in subsequent calls. If the current session token is expired, it refreshes the session and tries one more time.

func (*MessageSessionClient) Session

type MessageType

type MessageType string
const (
	MessageTypeJobAssigned  MessageType = "JobAssigned"
	MessageTypeJobStarted   MessageType = "JobStarted"
	MessageTypeJobCompleted MessageType = "JobCompleted"
)

message types

type NewClientWithPersonalAccessTokenConfig

type NewClientWithPersonalAccessTokenConfig struct {
	GitHubConfigURL     string
	PersonalAccessToken string
	SystemInfo          SystemInfo
}

NewClientWithPersonalAccessTokenConfig contains the configuration for creating a new Client using a personal access token.

type ProxyFunc

type ProxyFunc func(req *http.Request) (*url.URL, error)

ProxyFunc defines the function signature for a proxy function.

type RunnerGroup

type RunnerGroup struct {
	ID        int    `json:"id"`
	Name      string `json:"name"`
	Size      int    `json:"size"`
	IsDefault bool   `json:"isDefaultGroup"`
}

type RunnerGroupList

type RunnerGroupList struct {
	Count        int           `json:"count"`
	RunnerGroups []RunnerGroup `json:"value"`
}

type RunnerReference

type RunnerReference struct {
	ID               int    `json:"id"`
	Name             string `json:"name"`
	RunnerScaleSetID int    `json:"runnerScaleSetId"`
}

type RunnerReferenceList

type RunnerReferenceList struct {
	Count            int               `json:"count"`
	RunnerReferences []RunnerReference `json:"value"`
}

type RunnerScaleSet

type RunnerScaleSet struct {
	ID                 int                      `json:"id,omitempty"`
	Name               string                   `json:"name,omitempty"`
	RunnerGroupID      int                      `json:"runnerGroupId,omitempty"`
	RunnerGroupName    string                   `json:"runnerGroupName,omitempty"`
	Labels             []Label                  `json:"labels,omitempty"`
	RunnerSetting      RunnerSetting            `json:"RunnerSetting,omitempty"`
	CreatedOn          time.Time                `json:"createdOn,omitempty"`
	RunnerJitConfigURL string                   `json:"runnerJitConfigUrl,omitempty"`
	Statistics         *RunnerScaleSetStatistic `json:"statistics,omitempty"`
}

type RunnerScaleSetJitRunnerConfig

type RunnerScaleSetJitRunnerConfig struct {
	Runner           *RunnerReference `json:"runner"`
	EncodedJITConfig string           `json:"encodedJITConfig"`
}

type RunnerScaleSetJitRunnerSetting

type RunnerScaleSetJitRunnerSetting struct {
	Name       string `json:"name"`
	WorkFolder string `json:"workFolder"`
}

type RunnerScaleSetMessage

type RunnerScaleSetMessage struct {
	MessageID            int
	Statistics           *RunnerScaleSetStatistic
	JobAssignedMessages  []*JobAssigned
	JobStartedMessages   []*JobStarted
	JobCompletedMessages []*JobCompleted
}

type RunnerScaleSetSession

type RunnerScaleSetSession struct {
	SessionID               uuid.UUID                `json:"sessionId,omitempty"`
	OwnerName               string                   `json:"ownerName,omitempty"`
	RunnerScaleSet          *RunnerScaleSet          `json:"runnerScaleSet,omitempty"`
	MessageQueueURL         string                   `json:"messageQueueUrl,omitempty"`
	MessageQueueAccessToken string                   `json:"messageQueueAccessToken,omitempty"`
	Statistics              *RunnerScaleSetStatistic `json:"statistics,omitempty"`
}

type RunnerScaleSetStatistic

type RunnerScaleSetStatistic struct {
	TotalAvailableJobs     int `json:"totalAvailableJobs"`
	TotalAcquiredJobs      int `json:"totalAcquiredJobs"`
	TotalAssignedJobs      int `json:"totalAssignedJobs"`
	TotalRunningJobs       int `json:"totalRunningJobs"`
	TotalRegisteredRunners int `json:"totalRegisteredRunners"`
	TotalBusyRunners       int `json:"totalBusyRunners"`
	TotalIdleRunners       int `json:"totalIdleRunners"`
}

type RunnerSetting

type RunnerSetting struct {
	DisableUpdate bool `json:"disableUpdate,omitempty"`
}

type SystemInfo

type SystemInfo struct {
	// System is the name of the scale set implementation
	System string `json:"system"`
	// Version is the version of the client
	Version string `json:"version"`
	// CommitSHA is the git commit SHA of the client
	CommitSHA string `json:"commit_sha"`
	// ScaleSetID is the ID of the scale set
	ScaleSetID int `json:"scale_set_id"`
	// Subsystem is the subsystem such as listener, controller, etc.
	// Each system may pick its own subsystem name.
	Subsystem string `json:"subsystem"`
}

SystemInfo contains information about the system that uses the scaleset client.

For example, when Actions Runner Controller uses the scaleset API, it will set the following: - System: "actions-runner-controller" - Version: "release-version" - CommitSHA: "sha-of-the-release-commit" - Subsystem: "listener" or "controller"

Directories

Path Synopsis
examples
dockerscaleset command
internal
Package listener provides a listener for GitHub Actions runner scale set messages.
Package listener provides a listener for GitHub Actions runner scale set messages.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL