- Go 98.6%
- Nix 1.3%
|
All checks were successful
Build Container / Image (amd64) (pull_request) Successful in 9s
Build Go / Nix Build (pull_request) Successful in 4s
Build Go / Go Test (pull_request) Successful in 51s
Build Container / Image (arm64) (pull_request) Successful in 1m43s
Build Container / Image (amd64) (push) Successful in 8s
Build Container / Image (arm64) (push) Successful in 5s
Build Go / Nix Build (push) Successful in 13s
Build Go / Go Test (push) Successful in 49s
Second round on PR #13 (runs 18858/18859): Checkout now ends, Ensure Nix installs Nix 2.33.0 into the fresh fnsc /nix volumes (3 s on arm64), and the jobs got to their real work -- and failed twice more: 1. "Nix Build" / "Image (amd64|arm64)": `error: home directory '/homeless-shelter' exists; please remove it to assure purity of builds without sandboxing`. The runner containers cannot create mount namespaces, so Nix's sandbox-fallback runs every builder unsandboxed with HOME=/homeless-shelter as a real path. `go` creates $HOME/.config/go/telemetry on every invocation (reproduced with golang:1.25: `env -i HOME=/homeless-shelter go version` leaves the tree behind; GOTELEMETRY=off still creates .config/go), so the first buildGoModule build creates /homeless-shelter and Nix refuses to start the next derivation (Image (amd64): 20 s into the dispatcher build; the same runner's following Nix Build job failed on its first derivation). flake.nix: the three buildGoModule outputs share goModuleCommon with `preBuild = export HOME="$TMPDIR"`; buildGoModule forwards preBuild to the go-modules FOD too (`go mod download` has the same hazard), and the FOD's output path does not depend on it, so nix.key.store keeps serving it. The workflows also `rm -rf /homeless-shelter` before Ensure Nix, the key.store convention (images that pre-create it, warm daemon runners). 2. "Go Test": TestMacOSCacheVolumeGuardIsInertWithoutVolumes failed because runMacOSCacheVolumeGuard inherits os.Environ(), and on a cache-volume lane the dispatcher exports KEYSTORE_NSC_CACHE_VOLUME_ACTIVE=1 for the job itself -- the test saw ACTIVE=1 it never set. The helper now scrubs HOME/KEYSTORE_NSC_CACHE_VOLUME_*/KEYSTORE_BAZEL_STORAGE_CACHE/ NSC_CACHE_PATH from the inherited environment (verified on the forge with those variables exported: ok). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|---|---|---|
| .forgejo/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 it drains ahead of the Namespace TTL (see below) 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 on the key.store forge: go fmt/vet/test, nix builds, container image builds
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 runner exits or reaches its TTL. This keeps TTL as the hard cap while committing cache volumes promptly and avoiding unbounded idle Linux runners.- The stop monitor polls
nsc describe --output jsonevery 10s, starting 10s after launch. Right afternsc run --waitreturns, describe reports the resource with no container list for several seconds; that is treated asstarting, never as stopped. Only a tombstone or an explicitlystopped/terminated container ends the wait, and a "not found"/"destroyed" describe error is trusted only once a container has been observed or the 2m startup grace has passed. Transient describe errors are retried until the TTL+5m deadline instead of destroying the instance. The first check logs the raw describe JSON (runner first stop check) and later phase changes are logged asrunner instance phase changed. - Daemon-mode runners drain before the TTL instead of accepting jobs the
instance cannot finish. The dispatcher passes
FORGEJO_RUNNER_TTL_DEADLINE_EPOCH(Unix seconds, computed beforensc run) andFORGEJO_RUNNER_DRAIN_SECONDS(default: TTL/3 clamped to 5m..30m and at most TTL/2, e.g. 30m for a 90m TTL, 5m for 15m); the bootstrap runsforgejo-runner daemonin the background, writesrunner.shutdown_timeout = drain - 60s(min 30s) intorunner.yaml, and sends SIGTERM atdeadline - drain. forgejo-runner then stops polling, lets an in-flight job finish withinshutdown_timeout, and exits, which stops the container and lets the monitor destroy the instance. SetFORGEJO_RUNNER_DRAIN_SECONDSin a target'senvto override the margin.
- The stop monitor polls
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_backend– how macOS instances are created:nsc(nsc create --bare ..., the path every production launch has used),compute(Namespace Compute APICreateInstanceRequestover Connect, authenticated with the sameNSC_TOKEN_FILEbearer token the CLI uses; the dispatcher pollsDescribeInstanceuntil the instance isRUNNINGand itssshservice, when listed, isREADY, logging the phase and the application/container/service status, and destroys an instance that errors or never becomes ready), orauto/empty (the default:computewhenmacos_application_imageis set,nscotherwise). Both backends then run the same Compute-SSH bootstrap, TTL cut andnsc destroy. The two backends are not the same wire API: the CLI talks to the private InstanceService (selectors/volumes/base image inexperimental,--bare= featureEXP_DISABLE_KUBERNETES), thecomputebackend to the public v1betaComputeService(Shape.Selectors,Volumes,Experimental.MacosBaseImageId, no Kubernetes feature). The v1beta create has not been exercised for this tenant since 2026-05 (a 2026-02 note said Compute rejected the macOS support-disk selectors then), so flip one target tomacos_create_backend: computewithout an application first and watch themacos compute instance phaselogs before enabling the application.namespace.macos_application_image– optional Namespace macOS Application: an nscr.io image built withnsc macos package <dir> --name <repo>whose command Namespace runs on the instance at VM start, forwarded asApplications: [{Name, ImageRef, Command, Args}]on the create request.nsc create(0.0.557) has no flag for applications, so setting this selects thecomputebackend (an explicitmacos_create_backend: nscis rejected). key.store uses it to hydrate/nixfrom a zstd snapshot of the aarch64-darwin closures, since macOS instances cannot put/nixon a cache volume; pin the image by digest and refresh it from the same pipeline asmacos_custom_image_id. Companions:macos_application_name(default: the image's last path component),macos_application_command(default./entrypoint-- Namespace unpacks the image at/opt/<application name>and resolves a bare word via$PATH, exit 127; runs as userrunnerwithPACKAGE_ROOTset; imageENTRYPOINT/CMDare ignored by Namespace),macos_application_args, andmacos_application_ready_file– a path the application creates when it is done. The dispatcher exportsKEYSTORE_NSC_MACOS_APPLICATION_NAME/_IMAGE/_READY_FILEto the bootstrap, which waits for the ready file (bounded byKEYSTORE_NSC_MACOS_APPLICATION_READY_TIMEOUT, default 900 s; an optionalKEYSTORE_NSC_MACOS_APPLICATION_FAILED_FILEends the wait as soon as the application reports failure; both can be set per autoscaler target viaenv) before touching/nix, so a snapshot import and the Nix installer never race. On timeout/failure it logs, exportsKEYSTORE_NSC_MACOS_APPLICATION_READY=0and installs Nix itself as before;KEYSTORE_NSC_MACOS_APPLICATION_WAIT=job(autoscaler target env) instead registers the runner without waiting: while the application is still running the bootstrap exportsKEYSTORE_NSC_MACOS_APPLICATION_PENDING=1, skips its own Nix install, and the job-side reader (key.storeScripts/nix-snapshot-state.shviaScripts/ensure-nix.sh, driven byKEYSTORE_NSC_MACOS_APPLICATION_READY_FILEwith_READYunset = "snapshot expected") waits for the outcome at the job's first Nix use -- including the window before the entrypoint's first marker (start grace) -- and only installs Nix itself when the application failed or never appeared; a Nix the application installed is put onPATHfirst so the installer is never run twice. Application contract: Namespace applications areJOBworkloads and the instance terminates when the application's command exits (v1betaApplicationRequest.workload_type), so the command must import, create the ready file, and then keep running (sleep loop, exit onSIGTERM) until the dispatcher destroys the instance; an entrypoint that exits after the import kills the runner. The command is resolved by Namespace as an absolute path, a path relative to the image root, or via the base image's default$PATH; the image is a plain tar of the packaged directory, so ship an executable file (nsc macos packagekeeps modes). Whoever can push the referenced nscr.io repository runs code as the instance's application user on every macOS runner: pin@sha256:digests (the dispatcher warns at startup for tag references).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 (and the container image)
nix build .#packages.x86_64-linux.container-amd64produces a deterministic tarball containing the service, thenscbinary, BusyBox, andforgejo-runner.- The key.store forge does not run the container: it builds
forgejo-nsc-dispatcher,forgejo-nsc-autoscalerandforgejo-nsc-macos-codexfrom this flake through itsforgejo_nsc_srcinput and runs them as systemd services. - CI (
.forgejo/workflows/, runs on the forge's Namespace runners; the labels must be targets of the forge autoscaler'sforgejo-nscinstance in key-store/key.storenixos/hosts/keystore-forge/common.nix):Build Goruns gofmt/vet/test plus a darwin/arm64 cross-build and builds the three Go packages with Nix;Build Containerbuilds theamd64andarm64images with Nix as a build check. Nothing is pushed to a registry (the former ghcr.io publish needed a GitHub token the forge does not have).
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. On top of that the autoscaler keeps a per-target circuit breaker:
every consecutive failed dispatch (the dispatcher answered non-2xx, e.g. a
Windows bootstrap that never came up) doubles the effective interval
(2m -> 4m -> 8m ...) up to max_dispatch_backoff (default 30m); the next
successful dispatch resets it. Without it a job whose instances never
bootstrap re-created an 8x16 Windows instance every interval until Forgejo's
stale-job reaper cancelled the job (2026-08-17).
Windows instances
Windows guests are reached through the Namespace ssh service, not through
nsc instance port-forward/proxy: the dispatcher calls the Compute API
GetSSHConfig for the instance (ssh.<region>.namespace.so, user = instance
id, issued key), opens a direct-tcpip forward to 127.0.0.1:5985 on the
guest and speaks WinRM/NTLM over it with the RDP credentials from
nsc instance proxy -s rdp. Live evidence (2026-08-17, ord4): the gate tunnels
behind port-forward/proxy fail with websocket: bad handshake for the whole
instance lifetime (5985, 22, even the READY rdp service on 3389; with and
without instance:dial_host in the token), while the ssh service authenticates
(it cannot exec on Windows -- /bin/bash is not in %PATH% -- but forwards
fine) and WinRM answers HTTP/1.1 401 ... Microsoft-HTTPAPI/2.0 about 20 s
after nsc create returns. port-forward stays as a fallback transport.
Before any bootstrap attempt the dispatcher probes the tunnel for a real HTTP
status line from /wsman (a bare TCP connect says nothing: the forwarder
accepts locally and resets later) with a windows_ready_timeout budget
(default 10m, env KEYSTORE_NSC_WINDOWS_READY_TIMEOUT), logs
windows guest ready ... after=<duration>, then retries the bootstrap for
windows_bootstrap_retry (default 3m, KEYSTORE_NSC_WINDOWS_BOOTSTRAP_RETRY).
NSC_WINDOWS_E2E=1 go test ./internal/nsc -run TestWindowsWinRMScriptRoundTrip -v
(with nsc on PATH, NSC_TOKEN_FILE, NSC_COMPUTE_BASE_URL) proves the whole
path against a fresh 4x8 instance in ~25 s.
Bootstrap failures are classified by what the pywinrm driver reports:
a Python traceback / connection reset is a transport failure and is retried
within windows_bootstrap_retry, then handed to the next transport; a
winrm_stage_exit=<n> / winrm_exit=<n> marker means the PowerShell ran on
the guest and exited non-zero, which ends the launch on that transport (the
transport works, replaying the same script through another tunnel cannot
help): a script exit (winrm_exit) ends it immediately, a staging exit
(winrm_stage_exit: writing the script file, idempotent, nothing registered)
is retried for 60 s first so a guest still settling after boot or Defender
holding the half-written file gets a second chance. Before this rule "The
command line is too long." and the runner-zip 404 were each replayed for the
whole 3-minute window and then handed to port-forward, which probed a guest
that was already up for the rest of the 10-minute ready budget, up to
6m50s / 40 resets per launch on 2026-08-17. Once one transport has reached
the guest a fallback transport is probed for 45 s only. Every successful launch logs windows bootstrap finished (with
the transcript size; the transcript tail itself is a debug line, since it is
job output).
The guest runs forgejo-runner one-job in a bounded loop: forgejo-runner v12
fetches once and exits non-zero with "no task received" when nothing is
assigned yet, so the bootstrap keeps asking for
FORGEJO_RUNNER_ONE_JOB_WAIT_TIMEOUT seconds (default 300, the same knob as
the Linux/macOS bootstraps; set it in the target's env) and then exits 75,
which the dispatcher logs as windows runner received no job and reports as
a failed launch (the instance is destroyed at once instead of the runner being
re-registered for 3 minutes).
The bootstrap needs a forgejo-runner.exe: Forgejo publishes no Windows
runner binaries (code.forgejo.org releases carry linux amd64/arm64 only; the
old forgejo-runner-<v>-windows-amd64.zip URL answers 404 and
Expand-Archive then fails with "End of Central Directory record could not be
found"). Set namespace.windows_runner_url (env
KEYSTORE_NSC_WINDOWS_RUNNER_URL) to a build you host -- key.store publishes
its Nix build to https://blob.key.store/key-store-mirrors/forgejo-runner-windows/<version>/forgejo-runner.exe
with a .sha256 sidecar, which the guest verifies. Runners register
--ephemeral and run one-job.
Cache/persistent volumes are not supported on Windows compute instances:
the Compute API rejects nsc create --volume on a Windows machine type with
InvalidArgument ("persistent volumes are not supported on Windows
instances"; cache volumes fail identically), any mount format, on nsc 0.0.516
and 0.0.557 (the same Linux create succeeds). The dispatcher therefore never
attaches volumes on Windows, so a Windows job re-provisions its toolchain each
run; persisting state needs Namespace enabling Windows compute volumes or a
custom Windows base image. The nsc CLI is only half of it -- its --volume
flag splits on : and cannot even express a drive-letter mount point -- so
TestLiveWindowsVolumeProbe (KEYSTORE_LIVE_WINDOWS_VOLUME_PROBE=1,
KEYSTORE_LIVE_PROBE_WINDOWS_VOLUME=cache:<mount>|pv:<mount>) sends the
CreateInstanceRequest directly and inspects the guest over WinRM. Measured
2026-08-19 05:22-05:33Z (ord4, windows/amd64:4x8): a CACHE volume at
C:\cache\probe, K:\ or D:\cache\probe is accepted (instance id
returned) but the instance never leaves PENDING and Namespace destroys it
itself after 40-52 s with no shutdown reason; /cache/probe, K: and an
empty mount point are InvalidArgument; experimental.persistent_volumes
with mount_point: K:\ is accepted and the instance boots (RUNNING in
22-24 s) but the guest has no such disk (Get-Disk/Get-Volume/mountvol
show only C: 200 GB, D: 100 GB and Namespace's own read-only image disks
mounted under C:\namespace\{nsc,github-runner,powertoys}). If a mounted
volume ever appears, windowsCacheVolumeEnv already exports
KEYSTORE_WINDOWS_CACHE_ROOT (key.store's git-mirror + swiftc build/module
cache root) from the nscloud mount, next to NSC_CACHE_PATH and
KEYSTORE_BAZEL_STORAGE_CACHE.
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.