key.store Forgejo Namespace runner dispatcher and autoscaler
  • Go 97.6%
  • Nix 2.2%
  • Shell 0.2%
Find a file
gpt-5.5-codex fb30e3898a
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
Recover Linux Namespace Flatpak runners
2026-06-06 13:32:38 -07:00
.github/workflows forgejo nsc runner wiring 2026-01-26 00:17:59 -08:00
cmd Use recovery access naming 2026-06-06 12:18:07 -07:00
deploy Protect Namespace autoscaler polling from stale jobs 2026-05-26 12:33:22 -07:00
internal Recover Linux Namespace Flatpak runners 2026-06-06 13:32:38 -07:00
.gitignore forgejo nsc runner wiring 2026-01-26 00:17:59 -08:00
autoscaler.example.yaml Protect Namespace autoscaler polling from stale jobs 2026-05-26 12:33:22 -07:00
config.example.yaml Add key.store Namespace runner base fallback 2026-05-25 17:12:09 -07:00
flake.lock forgejo nsc runner wiring 2026-01-26 00:17:59 -08:00
flake.nix Restore ios chat resident harness 2026-05-22 21:42:09 -07:00
go.mod key.store: fix macos runner bootstrap under nsc token 2026-02-15 16:05:04 -05:00
go.sum key.store: fix macos runner bootstrap under nsc token 2026-02-15 16:05:04 -05:00
README.md Recover Linux Namespace Flatpak runners 2026-06-06 13:32:38 -07:00

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:

  1. Forgejo Actions the service requests a scoped registration token for the repository/organization/instance where you want to run jobs.
  2. Namespace (nsc) the dispatcher shells out to the nsc CLI to create a short-lived environment, runs the forgejo-runner container inside it, and defaults Linux runners to forgejo-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. Set FORGEJO_RUNNER_MODE=one-job on a target only after that runner image and Forgejo version have live proof for one-job bootstrap.
  3. 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 with actions:runner scope is required in forgejo.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 like https://git.key.store). On the forge host it commonly differs from base_url (which may be http://127.0.0.1:3000).
  • forgejo.default_scope where new runners register (instance, organization, or repository).
  • forgejo.default_labels labels applied to every spawned runner. GateForge workflows via runs-on: ["namespace-profile-linux-medium"] (or other namespace-profile-linux-* labels).
  • namespace.nsc_binary path to the nsc binary (the Nix container ships one compiled from namespacelabs/foundation so /app/bin/nsc works out of the box).
  • namespace.image OCI image used for Linux runners. key.store uses nscr.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.io refs so this path cannot drift to Docker Hub. The dispatcher bootstraps forgejo-runner when the image does not already contain the configured version, defaulting to 12.7.2.
  • Per-target image overrides let heavyweight platform lanes use specialized images. key.store uses namespace-profile-android-large with the android-runner image 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-party nscr.io images here; key.store's fallback is the Namespace-hosted public runner base built by Scripts/namespace-build-runner-public-base.sh. The dispatcher derives the allowed fallback repository from namespace.image; for nscr.io/osibqsl33bahe/key-store/devbox-nix:runner, only nscr.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.io Linux 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 --wait returns 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_INTERVAL tune the bounded retry used only when FORGEJO_RUNNER_MODE=one-job and Forgejo has not assigned a task immediately after registration. Defaults are 300 seconds and 5 seconds.
  • namespace.macos_selectors macOS support-disk selectors, for example macos.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 to nsc create through the Compute experimental macos_base_image_id field. 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_VOLUMES contract as Linux. The dispatcher attaches cache:* volume specs to macOS Compute and exports NSC_CACHE_PATH=/cache/nscloud plus KEYSTORE_BAZEL_STORAGE_CACHE=/cache/bazel when those mount points are present.
  • namespace.macos_create_wait_timeout / namespace.macos_create_timeout macOS runner creation timeouts. The first value is passed to nsc create --wait_timeout; the second is the dispatcher-side command guard and must be longer. KEYSTORE_NSC_MACOS_CREATE_WAIT_TIMEOUT and KEYSTORE_NSC_MACOS_CREATE_TIMEOUT override generated configs, which is useful for host-specific Nix deployments when Namespace macOS capacity is slow.
  • namespace.cache_volumes optional default nsc run --volume specs attached before the runner bootstrap starts. Prefer leaving this empty and assigning cache volumes per autoscaler target with env.NSC_CACHE_VOLUMES; a shared default cache can be overwritten by unrelated concurrent Linux jobs. When a /cache/gradle volume is present, the dispatcher exports GRADLE_USER_HOME=/cache/gradle for the job. When a /cache/bazel volume is present, the cache helper appends Bazel disk/repository cache settings to the generated Namespace bazelrc and Scripts/ensure-nix.sh configures /cache/bazel/nix-binary-cache?trusted=true as the first Nix substituter via NIX_CONFIG. Android/F-Droid jobs hydrate that local file cache from nix.key.store dev-shell profiles, and Bazel Android actions inherit the cache environment so nested nix run .#android-gradle calls 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 /nix cache-volume reuse is only an opportunistic optimization. Android Bazel lanes also hydrate .#android-gradle as a package output because the nested genrule runs it outside the .#android dev-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-amd64 produces a deterministic tarball containing the service, the nsc binary, BusyBox, and forgejo-runner.
  • The included Build Container workflow builds both amd64 and arm64 images on Namespace runners and pushes them to ghcr.io/<owner>/<repo>. No Fly.io manifests are emitted the multiarch manifest points only at GHCR.

How this fits behind Caddy (last-mile networking)

The dispatcher is just an HTTP server. You can:

  1. Run it anywhere that can reach Forgejo and Namespace: bare metal, Namespace cluster, Kubernetes, Fly, etc.

  2. 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 doesnt assume Caddy, nor does it manipulate HTTP clients directly it simply waits for POST requests. As long as the dispatcher can reach Forgejos REST API and run the nsc binary, you can drop it anywhere.

Autoscaling (webhook + poller)

If you dont want to call /api/v1/dispatch manually, theres a companion autoscaler (cmd/forgejo-nsc-autoscaler) that watches Forgejo job queues and triggers the dispatcher for you. It operates in two modes simultaneously:

  1. Polling every instance can poll GET /api/v1/.../actions/runners to keep a minimum number of idle Namespace runners per label. Because Forgejo's runner-job API does not include timestamps, production should set ignore_existing_jobs_on_start: true; the autoscaler records a startup high-watermark per target and only dispatches for newer queued jobs.
  2. Webhooks once Forgejo reaches the autoscaler via the /webhook/{name} endpoint, the autoscaler reacts to workflow_job events 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.