- Go 97.6%
- Nix 2.2%
- Shell 0.2%
|
Some checks are pending
Build Container / Publish Manifest (push) Blocked by required conditions
Build Container / Image (amd64) (push) Waiting to run
Build Container / Image (arm64) (push) Waiting to run
Build Go / Go Test (namespace-profile-linux-large) (push) Waiting to run
Build Go / Go Test (namespace-profile-macos-large) (push) Waiting to run
Build Go / Nix Build (push) Waiting to run
|
||
|---|---|---|
| .github/workflows | ||
| cmd | ||
| deploy | ||
| internal | ||
| .gitignore | ||
| autoscaler.example.yaml | ||
| config.example.yaml | ||
| flake.lock | ||
| flake.nix | ||
| go.mod | ||
| go.sum | ||
| README.md | ||
forgejo-nsc-dispatcher
This service exposes a simple HTTP API that tells Namespace Cloud to start ephemeral Forgejo Actions runners on demand. It glues together three pieces:
- Forgejo Actions – the service requests a scoped registration token for the repository/organization/instance where you want to run jobs.
- Namespace (
nsc) – the dispatcher shells out to thenscCLI to create a short-lived environment, runs theforgejo-runnercontainer inside it, and defaults Linux runners toforgejo-runner daemon. The autoscaler launches one Namespace instance for one queued job, and the runner stays alive until the Namespace TTL expires or the container exits. SetFORGEJO_RUNNER_MODE=one-jobon a target only after that runner image and Forgejo version have live proof for one-job bootstrap. - Your automation – you call the service via HTTP (directly, through Caddy, via Forgejo webhooks, etc.) whenever a new runner is needed.
Directory layout
.
├── cmd/forgejo-nsc-dispatcher # main entry point
├── internal/ # service packages (config, forgejo client, nsc dispatcher, HTTP server)
├── config.example.yaml # starter config referenced by README
├── flake.nix / flake.lock # reproducible builds (Go binary + container image)
└── .forgejo/workflows # CI that runs go test/build and publishes manifests
Configuration
Copy config.example.yaml and update it for your Forgejo instance and Namespace
profile. The important knobs are:
forgejo.base_url– HTTPS endpoint of your Forgejo server. A PAT withactions:runnerscope is required inforgejo.token.forgejo.instance_url– URL that spawned runners use to register back to Forgejo. This must be reachable from the runner (typically the public URL likehttps://git.key.store). On the forge host it commonly differs frombase_url(which may behttp://127.0.0.1:3000).forgejo.default_scope– where new runners register (instance,organization, orrepository).forgejo.default_labels– labels applied to every spawned runner. GateForge workflows viaruns-on: ["namespace-profile-linux-medium"](or othernamespace-profile-linux-*labels).namespace.nsc_binary– path to thenscbinary (the Nix container ships one compiled fromnamespacelabs/foundationso/app/bin/nscworks out of the box).namespace.image– OCI image used for Linux runners. key.store usesnscr.io/osibqsl33bahe/key-store/devbox-nix:runner, rebuilt by the Namespace image workflow when important flake/runner inputs change. Prefer a glibc Node image with Bash from Namespace's registry; startup rejects non-nscr.iorefs so this path cannot drift to Docker Hub. The dispatcher bootstrapsforgejo-runnerwhen the image does not already contain the configured version, defaulting to12.7.2.- Per-target
imageoverrides let heavyweight platform lanes use specialized images. key.store usesnamespace-profile-android-largewith theandroid-runnerimage so Android SDK, Gradle, and Bazel bootstrap work is present before the job starts. namespace.image_fallbacks– optional Namespace public-base fallback images tried only when Namespace cannot resolve the primary image tag. This is a positive allow-list for the same Namespace public runner-base path as the primary image, not a generic external-registry fallback. Do not use Docker Hub tags or arbitrary third-partynscr.ioimages here; key.store's fallback is the Namespace-hosted public runner base built byScripts/namespace-build-runner-public-base.sh. The dispatcher derives the allowed fallback repository fromnamespace.image; fornscr.io/osibqsl33bahe/key-store/devbox-nix:runner, onlynscr.io/osibqsl33bahe/key-store/runner-public-base:*is valid. It also validates fallbacks against the selected image override's Namespace account/path at dispatch time and rejects non-nscr.ioLinux image overrides, so this cannot silently drift back to Docker Hub or cross to a different Namespace account.namespace.machine_type/namespace.duration– shape + TTL for the ephemeral Namespace environment.nsc run --waitreturns when the container starts, so the dispatcher monitors the runner container and destroys the Namespace instance after the one-job runner exits or reaches its TTL. This keeps TTL as the hard cap while committing cache volumes promptly and avoiding unbounded idle Linux runners.FORGEJO_RUNNER_ONE_JOB_WAIT_TIMEOUT/FORGEJO_RUNNER_ONE_JOB_RETRY_INTERVALtune the bounded retry used only whenFORGEJO_RUNNER_MODE=one-joband Forgejo has not assigned a task immediately after registration. Defaults are 300 seconds and 5 seconds.namespace.macos_selectors– macOS support-disk selectors, for examplemacos.version=26.x,image.with=xcode-26. This is independent of a custom image ID so key.store can keep pinning the OS/Xcode lane while rolling a custom Namespace macOS image.namespace.macos_custom_image_id– optional explicit Namespace custom macOS image/base ID. When set, the dispatcher passes it tonsc createthrough the Compute experimentalmacos_base_image_idfield. Leave it empty until the image exists in the Namespace workspace; selectors remain the safe default.- macOS cache volumes use the same
namespace.cache_volumes/NSC_CACHE_VOLUMEScontract as Linux. The dispatcher attachescache:*volume specs to macOS Compute and exportsNSC_CACHE_PATH=/cache/nscloudplusKEYSTORE_BAZEL_STORAGE_CACHE=/cache/bazelwhen those mount points are present. namespace.macos_create_wait_timeout/namespace.macos_create_timeout– macOS runner creation timeouts. The first value is passed tonsc create --wait_timeout; the second is the dispatcher-side command guard and must be longer.KEYSTORE_NSC_MACOS_CREATE_WAIT_TIMEOUTandKEYSTORE_NSC_MACOS_CREATE_TIMEOUToverride generated configs, which is useful for host-specific Nix deployments when Namespace macOS capacity is slow.namespace.cache_volumes– optional defaultnsc run --volumespecs attached before the runner bootstrap starts. Prefer leaving this empty and assigning cache volumes per autoscaler target withenv.NSC_CACHE_VOLUMES; a shared default cache can be overwritten by unrelated concurrent Linux jobs. When a/cache/gradlevolume is present, the dispatcher exportsGRADLE_USER_HOME=/cache/gradlefor the job. When a/cache/bazelvolume is present, the cache helper appends Bazel disk/repository cache settings to the generated Namespace bazelrc andScripts/ensure-nix.shconfigures/cache/bazel/nix-binary-cache?trusted=trueas the first Nix substituter viaNIX_CONFIG. Android/F-Droid jobs hydrate that local file cache fromnix.key.storedev-shell profiles, and Bazel Android actions inherit the cache environment so nestednix run .#android-gradlecalls use the same local cache. When a previous profile snapshot exists, hydration imports that profile from the local binary cache before evaluating the dev shell. Direct/nixcache-volume reuse is only an opportunistic optimization. Android Bazel lanes also hydrate.#android-gradleas a package output because the nested genrule runs it outside the.#androiddev-shell profile closure.
Running locally
# Ensure nsc is available (e.g. `go build ./foundation/cmd/nsc`)
cp config.example.yaml config.yaml
nix develop # optional dev shell with Go toolchain
go run ./cmd/forgejo-nsc-dispatcher --config config.yaml
API example:
curl -X POST http://localhost:8080/api/v1/dispatch \
-H 'Content-Type: application/json' \
-d '{
"count": 1,
"ttl": "20m",
"labels": ["namespace-profile-linux-medium"],
"scope": {"level": "repository", "owner": "example", "name": "app"}
}'
Deploying with Nix + GHCR
nix build .#packages.x86_64-linux.container-amd64produces a deterministic tarball containing the service, thenscbinary, BusyBox, andforgejo-runner.- The included
Build Containerworkflow builds bothamd64andarm64images on Namespace runners and pushes them toghcr.io/<owner>/<repo>. No Fly.io manifests are emitted – the multi‑arch manifest points only at GHCR.
How this fits behind Caddy (last-mile networking)
The dispatcher is just an HTTP server. You can:
-
Run it anywhere that can reach Forgejo and Namespace: bare metal, Namespace cluster, Kubernetes, Fly, etc.
-
Put Caddy (or any reverse proxy) in front to terminate TLS, do auth, or rewrite URLs. For example:
forgejo-dispatcher.example.com { reverse_proxy 127.0.0.1:8080 basicauth /api/* { user JDJhJDE... } }
The service doesn’t assume Caddy, nor does it manipulate HTTP clients
directly – it simply waits for POST requests. As long as the dispatcher can
reach Forgejo’s REST API and run the nsc binary, you can drop it anywhere.
Autoscaling (webhook + poller)
If you don’t want to call /api/v1/dispatch manually, there’s a companion
autoscaler (cmd/forgejo-nsc-autoscaler) that watches Forgejo job queues and
triggers the dispatcher for you. It operates in two modes simultaneously:
- Polling – every instance can poll
GET /api/v1/.../actions/runnersto keep a minimum number of idle Namespace runners per label. Because Forgejo's runner-job API does not include timestamps, production should setignore_existing_jobs_on_start: true; the autoscaler records a startup high-watermark per target and only dispatches for newer queued jobs. - Webhooks – once Forgejo reaches the autoscaler via the
/webhook/{name}endpoint, the autoscaler reacts toworkflow_jobevents in real time. Each payload is mapped to a target label set and results in a dispatch call.
You can manage multiple Forgejo instances by listing them under instances in
autoscaler.example.yaml:
listen: ":8090"
dispatcher:
url: "http://dispatcher:8080"
instances:
- name: key-store
forgejo:
base_url: "https://git.key.store"
token: "PENDING-FORGEJO-PAT"
scope:
level: "repository"
owner: "key-store"
name: "key.store"
disable_polling: false
poll_after_webhook: true
ignore_existing_jobs_on_start: true
poll_interval: "2m"
webhook_secret_file: "/run/secrets/forgejo-nsc-webhook-secret"
webhook:
url: "https://nsc-autoscaler.key.store/webhook/key-store"
content_type: "json"
events: ["workflow_job"]
active: true
targets:
- labels: ["namespace-profile-linux-medium"]
min_idle: 0 # set to 0 to scale-to-zero between jobs
max_dispatch_per_poll: 2
min_dispatch_interval: "2m"
ttl: "20m"
- labels: ["namespace-profile-windows-large"]
min_idle: 0
max_dispatch_per_poll: 1
min_dispatch_interval: "5m"
ttl: "45m"
machine_type: "windows/amd64:8x16"
Set max_dispatch_per_poll on any polled targets that can accumulate stale
queued jobs. A zero or missing value means "no cap". key.store production uses
signed webhooks plus high-watermark polling so stale queued jobs that existed
before autoscaler startup cannot spend the Namespace tenant quota.
Set min_dispatch_interval when startup failures can briefly register and then
drop a runner before it takes a job; this keeps polling from relaunching the same
target every interval while the underlying image or capacity issue is being
debugged.
For key.store, the live forge should prefer managed runtime tokens under
/var/lib/key-store/forgejo-runtime-tokens/ plus repo-generated config. The
local Scripts/provision-forgejo-nsc.sh path only updates the older encrypted
dispatcher/autoscaler YAML secrets and should be treated as a legacy recovery
bridge, not the default operating model.
Run it next to the dispatcher:
go run ./cmd/forgejo-nsc-autoscaler --config autoscaler.yaml
# or build the binary/container via `nix build .#forgejo-nsc-autoscaler`
For production, prefer signed webhooks, keep min_idle: 0 for scale-to-zero,
and enable ignore_existing_jobs_on_start whenever polling is enabled. Set
poll_after_webhook: true when Forgejo can leave dependency-unlocked jobs
waiting without sending a fresh webhook. The autoscaler auto-creates/updates the
webhook using the PAT.
In Forgejo add a webhook pointing to https://nsc-autoscaler.key.store/webhook/key-store
with the shared secret (or let the autoscaler create it by specifying
webhook.url in config). If polling is enabled only for bootstrap, leave
poll_after_webhook false so the autoscaler stops polling after the first valid
webhook.