Run every GitHub Actions job in an isolated Novita Sandbox, more affordably and with less operational overhead.
This controller uses GitHub's official
github.com/actions/scaleset client to
receive assigned jobs and desired runner capacity. A long-running controller
creates one fresh Novita runner Sandbox for each job and kills it when the job
finishes.
v0.1 uses the Scale Set API, which GitHub currently labels Public Preview. The client API is stable, but its Go interfaces may evolve. This repository pins
github.com/actions/scaleset v0.5.0.
flowchart LR
G["GitHub Actions<br/>assigned job and JIT config"] --> C["Controller Sandbox<br/>github-actions-controller"]
C --> T["Runner Template<br/>github-actions-runner"]
T --> R["Ephemeral Runner Sandbox<br/>one per job"]
R --> K["Job complete<br/>kill Sandbox"]
The same controller implementation supports two control-plane placements. Novita Hosted is the recommended default because the controller remains online without depending on a user's machine:
- Novita Hosted: run that controller image in a long-running Control Sandbox, which creates separate ephemeral runner Sandboxes.
- Local Docker: run the same controller image on a user's machine with credentials supplied through environment variables.
The runner JIT configuration travels over the adapter's stdin and the Sandbox
filesystem API. The adapter atomically publishes the completed file; the runner
entrypoint reads and unlinks it, then exports it only to the runner child
process as ACTIONS_RUNNER_INPUT_JITCONFIG. It is never included in Sandbox
creation environment values, metadata, process arguments, or controller logs.
Hosted mode uses these public Novita Templates directly; users do not build either Template:
| Role | Public Template |
|---|---|
| Control Sandbox | github-actions-controller |
| Ephemeral CI runner | github-actions-runner |
Prepare the following on the machine where you run the deployment script:
- Bash and Python 3.
- Node.js 20+ and npm if the
novitaCLI is not already installed. - The latest
novitaCLI (version 2.1.0 or newer). TheNOVITA_API_KEYbelow selects the Novita team that will own the Control Sandbox and brokered Secrets; a separate browser login is not required. - A GitHub PAT that can manage self-hosted runners. For a classic PAT, use
repofor a repository target oradmin:orgfor an organization target. For a fine-grained PAT, grant repositoryAdministration: read and writeor organizationAdministration: readplusSelf-hosted runners: read and write, respectively. - The exact repository or organization URL to register, such as
https://github.com/novitalabs/github-actions-runner-controller.
Install and verify the CLI if needed:
npm install --global novita-sandbox-cli@latest
novita --versionExport the three required values in the current shell. Do not put them in the repository:
export GITHUB_CONFIG_URL=https://github.com/OWNER/REPOSITORY
export GITHUB_TOKEN=YOUR_GITHUB_PAT
export NOVITA_API_KEY=YOUR_NOVITA_API_KEYVerify that the CLI sees the intended team before deployment:
novita auth infoGITHUB_CONFIG_URL is mandatory. The script rejects the example
OWNER/REPO and OWNER/REPOSITORY placeholders so it cannot silently deploy
against an unintended target.
Clone this repository and run the Hosted deployment:
git clone https://github.com/novitalabs/github-actions-runner-controller.git
cd github-actions-runner-controller
bash scripts/deploy-hosted.shAfter cd, run the deployment script from the cloned
github-actions-runner-controller/ repository root. The script runs on your
machine and creates the long-running Controller Sandbox in Novita.
Success returns the stable target identity, Control Sandbox ID, and health state:
{"targetId":"novitalabs-github-action-a814d8991e","controllerId":"novita-hosted-novitalabs-github-action-a814d8991e","sandboxId":"...","healthy":true}The default Scale Set label is novita-scale-set. In the target repository,
create or update .github/workflows/ci.yml (or another workflow file under
.github/workflows/) and point each job that should use Novita at this label:
# .github/workflows/ci.yml
jobs:
test:
runs-on: novita-scale-setThe controller creates or reuses the named Scale Set. It intentionally does not delete it on shutdown, allowing safe controller restarts.
The deployment script creates or updates two brokered Secrets and starts a
long-running Control Sandbox with a 60-year timeout and
pause + auto-resume. It uses the public controller Template and creates job
Sandboxes from the public runner Template. The Secrets must remain active for
the full life of the resumable Control Sandbox. Kill that Sandbox before
deleting them.
After Sandbox creation, the script starts the controller through the Sandbox
command API and waits for /healthz; this is required because a process saved
in a template build snapshot does not inherit creation-time environment values.
Run the deployment once for each GitHub repository or organization. By
default, the script derives a stable target ID from the normalized
GITHUB_CONFIG_URL. Each target receives its own controller identity, brokered
Secrets, and long-running Control Sandbox, so deploying another target cannot
reuse the first target's controller or overwrite its GitHub credential.
GITHUB_CONFIG_URL=https://github.com/novitalabs/github-actions-runner-controller \
bash scripts/deploy-hosted.shTo add another project, replace GITHUB_CONFIG_URL with that repository or
organization URL and run the same command again.
Save the JSON output for operations. The resume script accepts either the exact Sandbox ID or the same GitHub target URL. Target-based lookup checks both running and paused Sandboxes and fails on duplicates instead of choosing one arbitrarily. Control Sandboxes created before target metadata was introduced must still be resumed by their Sandbox ID.
When resuming manually, always preserve the intended lifetime:
scripts/resume-hosted.sh SANDBOX_ID
GITHUB_CONFIG_URL=https://github.com/novitalabs/github-actions-runner-controller \
scripts/resume-hosted.sh
# equivalent core command:
novita sandbox resume SANDBOX_ID --timeout 525600hWithout --timeout, Novita resets a resumed Sandbox to its short default
lifetime (about five minutes). The lifecycle remains pause + auto-resume, but
the controller would pause again almost immediately.
Local Docker and Hosted mode use the same controller OCI image. Hosted users
use the two public Template names above; the Template definitions pin an
immutable image version rather than latest.
Use Local mode when the controller should run on a machine you manage. Prepare Docker, the same GitHub PAT and Novita API key described above, then run:
cp .env.example .env
# Edit .env; it is ignored by Git.
docker compose up -d
curl http://127.0.0.1:8080/healthzLocal mode pulls the immutable
novitalabs/github-actions-controller:v0.1.1 image. Credentials are provided
only through GITHUB_TOKEN and NOVITA_API_KEY in .env. Use the same
runs-on: novita-scale-set workflow configuration shown above.
To preinstall tools, build your own runner Template on top of the public
runner image and point the controller at it. See the Novita guide
Build Template
for the full novita template create reference.
Start from templates/runner/novita.Dockerfile. Keep the three conventions
the controller relies on: the user account, the /home/user/actions-runner
directory, and the entrypoint.sh start command. The controller writes each
job's JIT configuration into that directory and the entrypoint consumes it.
# templates/runner/novita.Dockerfile
FROM novitalabs/github-actions-runner:v0.1.1
USER root
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential pkg-config libssl-dev zstd \
&& rm -rf /var/lib/apt/lists/*
USER user
CMD ["/home/user/actions-runner/entrypoint.sh"]Add further apt-get install packages or tool downloads as needed. Install
user-level tools such as rustup or nvm while USER user is active so they land
in /home/user.
Choose a name that does not collide with the public github-actions-runner
Template. Resources default to 2 vCPU and 4 GB; raise them for heavy compiles.
novita template create my-gcc-runner \
--path templates/runner \
--dockerfile novita.Dockerfile \
--cpu-count 4 --memory-mb 8192 \
--cmd /home/user/actions-runner/entrypoint.sh \
--ready-cmd 'test -x /home/user/actions-runner/entrypoint.sh'make runner-template RUNNER_TEMPLATE=my-gcc-runner runs the same command
with the default resource size. Rebuild with the same name after each
Dockerfile change, or use a new name if the platform rejects a duplicate.
Hosted mode reads the Template name at deployment time. Kill the existing Control Sandbox for the target, then redeploy with the override:
novita sandbox kill CONTROL_SANDBOX_ID
NOVITA_RUNNER_TEMPLATE=my-gcc-runner bash scripts/deploy-hosted.shLocal Docker mode reads it from .env:
NOVITA_RUNNER_TEMPLATE=my-gcc-runnerThen docker compose up -d. Workflows do not change; re-run the failed job
and it will land on the new Template. One controller uses exactly one runner
Template. To offer several toolchains, deploy one controller per Template with
distinct SCALE_SET_NAME values and select them with runs-on.
- Desired runners are
min(MAX_RUNNERS, MIN_RUNNERS + TotalAssignedJobs). - Job availability is acquired before provisioning. GitHub can redeliver an unacknowledged message; a stable pending runner name and metadata lookup make create retries idempotent.
- Job completion permanently kills its ephemeral Sandbox.
- A periodic reconcile converges capacity even during quiet message polls.
- Only controller-owned, paused Sandboxes older than
ORPHAN_GRACEare orphan candidates. Unknown running Sandboxes are preserved because they may be busy after a controller restart. /healthzreports process health;/metricsexposes creation, deletion, and error counters in Prometheus text format.
| Variable | Default | Meaning |
|---|---|---|
GITHUB_CONFIG_URL |
required | Repository or organization URL |
GITHUB_TOKEN |
required | GitHub PAT |
NOVITA_API_KEY |
required by SDK | Novita team API key |
NOVITA_RUNNER_TEMPLATE |
required | Runner Template name or ID; see Custom runner Template |
SCALE_SET_NAME |
novita |
Scale Set name |
RUNNER_LABELS |
Scale Set name | Comma-separated labels |
RUNNER_GROUP |
default |
GitHub runner group |
MIN_RUNNERS / MAX_RUNNERS |
0 / 10 |
Capacity bounds |
RUNNER_TIMEOUT |
1h |
Per-job Sandbox hard lifetime |
RECONCILE_INTERVAL |
30s |
Periodic convergence interval |
ORPHAN_GRACE |
15m |
Paused orphan retention |
CONTROLLER_ID |
derived from target URL in Hosted mode | Ownership identity |
METRICS_ADDR |
:8080 |
Health and metrics listen address |
PYTHON |
python3 |
Python executable for the adapter |
NOVITA_ADAPTER |
/app/provisioner/novita/adapter.py |
Adapter path |
Hosted deployment also accepts CONTROLLER_TEMPLATE, GITHUB_SECRET_NAME,
NOVITA_SECRET_NAME, and CONTROL_TIMEOUT. Explicit identity or Secret name
overrides are intended for advanced operation; the per-target
defaults are safer for normal multi-project deployments. CONTROL_TIMEOUT
controls the lifetime used during deployment and by scripts/resume-hosted.sh.
See Design notes for lifecycle ownership and failure boundaries.
Licensed under the MIT License.