Skip to content

Configure ngit-ci

This is the reference for configuring the ngit-ci coordinator. The microVM sandbox adapter has its own, separately-prefixed configuration.

Most options can be supplied as a CLI flag, an environment variable, or a .env file entry, with precedence CLI > env > .env > defaults. Secret values are the deliberate exception: per-repo secret values and the coordinator identity key are never accepted on argv.

Coordinator options

FlagEnv varRequiredDescription
--index-relaysNGIT_CI_INDEX_RELAYSno (default wss://index.ngit.dev)Comma-separated relays used for kind-30617 repo-announcement discovery.
--nostr-secret-relaysNGIT_CI_NOSTR_SECRET_RELAYSno (default empty/disabled)Comma-separated coordinator inbox relays for encrypted Repository Secret Updates. Configuring at least one enables intake for every repository admitted by --repos.
--reposNGIT_CI_REPOSyesComma-separated repos to watch: npub1... (all repos by that author) or npub1.../identifier (one repo). Hex pubkeys also accepted. Append #ALIAS ([A-Z0-9]+, e.g. npub1.../my-repo#MYREPO) to key per-repo secrets to that entry.
--execution-policyNGIT_CI_EXECUTION_POLICYno (default request-required)request-required waits for an accepted kind-9843 Service Request before ordinary push/PR workflows run; automatic explicitly allows unrequested execution. See Service Request policy.
--additional-requestersNGIT_CI_ADDITIONAL_REQUESTERSno (default empty)Comma-separated npubs or hex pubkeys whose Service Requests may enable work for an admitted repository even when they are not maintainers.
(none — argv is world-readable)NGIT_CI_SECRET_<ALIAS>__<NAME>noOne secret value per variable: injects ${{ secrets.NAME }} into maintainer-triggered jobs of the repo carrying #ALIAS. Also provisionable as systemd credentials. GH_READ_TOKEN additionally enables automatic Nix GitHub authentication. See per-repo secrets.
--work-dirNGIT_CI_WORK_DIRno (default .ngit-ci-work)Local directory for clones and job state.
--runnerNGIT_CI_RUNNERno (default embedded-act)Runner backend: embedded-act (local act against a Docker-compatible daemon) or socket-adapter (submit jobs to a sandbox adapter over the Loom execution-adapter Unix-socket contract; see Sandbox adapters).
--adapter-socketNGIT_CI_ADAPTER_SOCKETwhen --runner socket-adapterPath of the sandbox adapter's Unix domain socket.
--act-pathNGIT_CI_ACT_PATHno (default act)act executable path/name.
--act-platformsNGIT_CI_ACT_PLATFORMSno (default catthehacker medium images)Comma-separated runs-on label → container image mappings passed to act as -P flags. The labels double as this coordinator's capability set: workflows declaring runs-on labels outside it are left unclaimed. See Multi-OS / multi-architecture builds.
--act-cache-enabledNGIT_CI_ACT_CACHE_ENABLEDno (default true)Enable act's local actions/cache-compatible server. Cache writes are isolated per repository perspective and trigger-author trust; the first-party microVM adapter persists the selected scope outside disposable guests. See Cross-run build cache.
--act-cache-max-bytesNGIT_CI_ACT_CACHE_MAX_BYTESno (default 10737418240, 10 GiB)Maximum build-cache bytes retained per repository perspective. Least-recently-used trust scopes are evicted after runs.
--docker-hostNGIT_CI_DOCKER_HOSTnoDocker-compatible daemon socket exported to act as DOCKER_HOST (e.g. rootless Podman, or a dind sidecar).
--act-bind-workspaceNGIT_CI_ACT_BIND_WORKSPACEno (default true)Bind-mount the workspace into job containers (--bind, true) or let act copy the checkout into a Docker volume when actions/checkout runs (false). Bind-mounting is faster and reflects container writes back to the host, but requires act's CWD to be resolvable from the container daemon's mount namespace. Set to false when act runs inside a mount-namespace boundary (e.g. the supplied Compose deployments or a NixOS nspawn container) and ensure repository-dependent steps follow actions/checkout. The copy crosses the namespace through the Docker API and includes .git.
--act-container-daemon-socketNGIT_CI_ACT_CONTAINER_DAEMON_SOCKETno (default -, mount disabled)Daemon socket act bind-mounts into job containers (--container-daemon-socket). The default - disables the mount; set a socket URI (e.g. unix:///var/run/docker.sock) only if jobs need daemon access — the mount gives workflow code control of the container daemon and lets it escape job resource limits.
--act-container-optionsNGIT_CI_ACT_CONTAINER_OPTIONSno (default unset)Docker-run-style options applied to every act job container (passed as --container-options), e.g. --memory=4g --memory-swap=4g --cpus=2 --pids-limit=2048. This is the operator-dictated job container spec; see Job container resource limits.
--act-job-container-policyNGIT_CI_ACT_JOB_CONTAINER_POLICYno (default admin)Who controls job containers. admin: workflows must run on the configured runs-on platform images with the operator's container spec; jobs declaring container: / services: blocks are refused with a published failure result. workflow: trust workflow-declared containers (act's native semantics; workflow options replace the operator spec). See Job container policy.
--max-concurrent-jobsNGIT_CI_MAX_CONCURRENT_JOBSno (default 1)Maximum number of CI jobs that may run at once. Additional planned jobs wait in a FIFO queue.
--max-queued-jobsNGIT_CI_MAX_QUEUED_JOBSno (default 64)Maximum number of planned jobs that may wait in the queue while all job slots are busy. Jobs planned beyond this are dropped and retried on a later relay replay.
--queue-recovery-window-secsNGIT_CI_QUEUE_RECOVERY_WINDOW_SECSno (default 600)Maximum restart gap for restoring jobs frozen in a graceful-shutdown queue checkpoint. The timestamp is written after active jobs finish, so drain time does not consume the window.
--job-timeout-secsNGIT_CI_JOB_TIMEOUT_SECSno (default 1800)Maximum runtime for one job: the embedded act process, or — under socket-adapter — the sandboxed job (enforced by dropping the adapter connection, which kills the job). A coordinator timeout publishes a timed_out diagnostic Job Result with the retained partial log.
--blossom-serversNGIT_CI_BLOSSOM_SERVERSno (default empty)Comma-separated http:// / https:// Blossom server base URLs. Empty disables all Blossom features. When configured, per-job logs are uploaded for both runner backends and embedded-act stages upload-artifact@v4 artifacts, uploading each contained file as its own blob. Servers are tried in order; the first successful upload supplies the event tag URL.
--blossom-max-log-bytesNGIT_CI_BLOSSOM_MAX_LOG_BYTESno (default 4194304)Maximum retained per-job log bytes uploaded to Blossom. Longer logs are tailed with a truncation header before upload; kind-9841 content remains a small inline tail excerpt.
--blossom-max-artifact-bytesNGIT_CI_BLOSSOM_MAX_ARTIFACT_BYTESno (default 67108864)Maximum size per artifact file uploaded to Blossom. Oversized files are skipped, never truncated.
--blossom-max-artifacts-per-jobNGIT_CI_BLOSSOM_MAX_ARTIFACTS_PER_JOBno (default 16)Maximum artifact files (and so artifact tags) attached to each job result.
--blossom-upload-timeout-secsNGIT_CI_BLOSSOM_UPLOAD_TIMEOUT_SECSno (default 60)Per-blob Blossom upload timeout. On timeout/failure ngit-ci logs a warning and publishes results without that tag.
--blossom-upload-budget-secsNGIT_CI_BLOSSOM_UPLOAD_BUDGET_SECSno (default 120)Total post-run Blossom upload budget per workflow run. The job execution slot is released before this phase; publishing proceeds with whatever uploads completed before the budget expires.
--blossom-artifact-server-addrNGIT_CI_BLOSSOM_ARTIFACT_SERVER_ADDRno (default empty)Optional address passed to act's artifact server for embedded-act. Empty leaves act's outbound-IP default in place.
--blossom-public-urlNGIT_CI_BLOSSOM_PUBLIC_URLno (default empty)Public base URL (scheme://host[:port]) for published logs/artifact tags instead of the Blossom upload server URL. Only the scheme/host/port are used; a path is rejected at validation. Useful when the upload endpoint is a loopback or Tailscale address. Empty: use the upload server URL as-is.
(none — argv is world-readable)NGIT_CI_COORDINATOR_NSECnoCoordinator's Nostr secret key. Prefer the systemd credential coordinator_nsec or the generated .coordinator.nsec state file; see Coordinator identity key.
(none — argv is world-readable)NGIT_CI_OPERATOR_BUNKERnoOperator fallback established NIP-46 nbunksec connection: seals Nostr-received secret values for scopes whose maintainers did not bind their own bunker. Only nbunksec is accepted here; a bunker:// pairing URL is rejected because it cannot be replayed after restart. Also provisionable as the systemd credential operator_bunker (which wins). See bunker-sealed secret values.
--processed-ids-pathNGIT_CI_PROCESSED_IDS_PATHno (default .coordinator-processed-ids.json)JSON file recording event ids already processed, to prevent duplicate runs after restart.
--queue-state-pathNGIT_CI_QUEUE_STATE_PATHno (default .coordinator-queued-jobs.json)JSON checkpoint for unstarted jobs preserved across a short graceful restart. Keep this on persistent storage; checkpoint identity and watched-repository scopes must match on recovery.
--event-cache-dirNGIT_CI_EVENT_CACHE_DIRno (default .ngit-ci-cache)Directory for the persistent rust-nostr cache of verified events matching coordinator subscriptions. Filtered startup queries merge cache and relay results to rebuild repository metadata, state baselines, PR context, and Service Request/Stop state. Query results never initiate jobs; only standing-subscription delivery enters the trigger path. Backend filenames and directory contents are implementation details.
--log-levelNGIT_CI_LOG_LEVELno (default info)Tracing filter: a level (debug) or a filter expression (ngit_ci=debug,nostr=info).

Service Request policy

The default --execution-policy request-required separates repository discovery from permission to spend compute. A configured repository is discovered immediately. It enters the acting set only after at least one relay currently announced by its recursive maintainer closure completes the retained history for the coordinator's filtered Service Request/Stop subscription. In request-required mode, the coordinator must also observe an accepted kind-9843 Service Request on either a repository relay or one of its NIP-65 read relays. Only then is the repository advertised as acting and eligible for ordinary push and pull-request workflows. A kind-9840 Manual Trigger is already an explicit one-shot request and bypasses the standing gate without turning the repository into a standing acting repository.

A Request is accepted when it addresses this coordinator, references a repository in maintainership excluding invited maintainers, and is signed by a current maintainer in that same confirmed maintainership. Identities listed in --additional-requesters are also accepted as a local operator policy; this does not make them repository maintainers. A kind-9844 Stop from a confirmed current maintainer closes all earlier Requests for the maintainership. An invited or other non-maintainer author can close only their own earlier Requests, and a later accepted Request makes service eligible again. Controls do not dynamically expand --repos admission.

The coordinator reduces the valid Request and Stop history it observes on repository relays and on its NIP-65 read or unmarked relays; it does not attempt relay-completeness proofs. A write-only NIP-65 relay is not an inbox. Without a kind-10002 relay list, the client uses its configured index relays as the inbox fallback; repository-relay subscriptions remain active independently. Controls are reloaded from the persistent event cache after restart, and maintainer authorization is checked again when each trigger is planned. Set --execution-policy automatic only when unrequested execution is intended. Automatic execution still applies only to repositories selected through --repos, actually discovered from a repository announcement, and synced from at least one currently announced repository relay.

Host requirements

The coordinator host needs on PATH:

  • git
  • act 0.2.86 or later (earlier releases are vulnerable to CVE-2026-34041 / CVE-2026-34042)
  • with the default embedded-act runner: a Docker-compatible container daemon reachable from the coordinator; act runs every job in a container (by default the catthehacker ghcr.io/catthehacker/ubuntu:act-* medium images). Rootless Podman works via --docker-host; act speaks the Docker API over the socket directly, so no podman/docker CLI is needed on PATH.

With --runner socket-adapter the coordinator host needs no container daemon: jobs run inside the sandbox, which brings its own.

The deployment guides (Docker, Proxmox LXC, NixOS) each provide these prerequisites in their platform-native way.

Manual installs from crates.io are supported for operators who want to provide the host services themselves:

bash
cargo install ngit-ci

cargo install installs only the coordinator executable. It does not install act, configure a Docker-compatible daemon, provision persistent state, or set up a sandbox adapter. Treat it as an advanced/custom install path; for normal production deployments, prefer the Docker, Proxmox LXC, or NixOS guides.

Cross-run build cache

The build cache is enabled by default so workflows can use standard actions/cache-compatible actions across runner invocations. Set --act-cache-enabled false to disable it. When it is off, ngit-ci passes --no-cache-server explicitly; this matters because act otherwise starts a cache server against its own shared default path.

For embedded-act, storage lives beneath <work-dir>/act-job-cache. The first-party microVM adapter stores the same partition layout beneath <adapter-state-dir>/act-job-cache and exposes only the selected scope to each guest. Other socket adapters receive a safe job-local cache path unless they independently implement the reserved cache metadata convention; no cross-run persistence is assumed for them. Storage is partitioned in two dimensions:

  • the exact kind-30617 repository perspective, represented on disk by a hash of its coordinate; caches never cross repository coordinates;
  • trigger-author trust. Runs authored by a current confirmed maintainer share the repository's trusted scope. Every other author receives a separate writable scope keyed by their pubkey. An untrusted PR can neither read nor write the trusted scope, nor another PR author's scope.

The maintainer decision uses the current recursive maintainership excluding invited maintainers, matching the repository authority rule used elsewhere by the coordinator. It rebuilds that graph from the persistent event cache for each cache-enabled run and fails into the untrusted partition unless a repository relay is connected and freshly synced. Cache contents grant no additional authority and are never a source of secrets.

--act-cache-max-bytes caps the combined scopes for each repository perspective. After each run, ngit-ci removes whole inactive scopes in least-recently-used order until the cap is met; a scope in use by another concurrent job is never removed. act continues to perform its own expiry of individual cache keys within retained scopes. If one scope alone exceeds the cap, it is removed after the run, so configure a limit larger than the caches a workflow is expected to save.

The first-party microVM adapter also applies an adapter-wide limit across all repository perspectives and trust scopes. Configure it independently with the adapter's --cache-max-total-bytes option.

The microVM adapter never mounts one scope into two VMs simultaneously because act's Bolt metadata database uses mmap. When a job for the same scope is already active, the concurrent job runs normally with an isolated job-local cache; it does not queue and cannot corrupt the persistent scope.

Maintainers can invalidate build inputs without operator access by changing the cache key in the workflow; old entries then age out through act's expiry and the coordinator cap. To reclaim all bytes immediately, an operator can stop the relevant coordinator or microVM adapter and remove its act-job-cache directory.

Coordinator identity key

The coordinator signs workflow results with a Nostr secret key (<COORDINATOR_SECRET_KEY>). That key is a secret value, so it is not accepted as a CLI flag: argv is world-readable via ps / /proc/<pid>/cmdline.

Load order is:

  1. systemd credential named coordinator_nsec ($CREDENTIALS_DIRECTORY/coordinator_nsec)
  2. NGIT_CI_COORDINATOR_NSEC from env / .env
  3. .coordinator.nsec in the working directory, loaded if present or generated on first start

If no key is configured, generating .coordinator.nsec is intentional and is the normal path for fresh deployments — provided the working directory is persistent. The generated file is created with mode 0600 on Unix. If a key is configured but invalid or empty, startup fails rather than silently generating a different identity; otherwise an operator typo could rotate the coordinator's pubkey without notice. That includes NGIT_CI_COORDINATOR_NSEC being set but empty (e.g. a compose ${VAR} substitution expanding to nothing) — unset the variable if you want the generated-key path.

Back the key up and keep it stable: clients' trust in published CI results attaches to the coordinator public key derived from this secret key. Deployment guides either mount a persistent state directory/volume for .coordinator.nsec or expose an explicit runtime secret-file option for admins who want to pin an existing identity.

Multi-OS / multi-architecture builds

The --act-platforms labels are not just image mappings — they declare what this coordinator is able and willing to run. At planning time the coordinator resolves each job's runs-on (including ${{ matrix.os }} expansions) and only claims a workflow when every job's labels are covered by the configured map; anything else is skipped silently, without publishing CI events, so a different coordinator can claim it.

To build on several architectures, run one coordinator per architecture on native hardware, each watching the same repositories under its own coordinator key, with a platform map for the labels its hardware executes:

bash
# x86_64 host
ngit-ci --repos <npub> --act-platforms \
  "ubuntu-latest=ghcr.io/catthehacker/ubuntu:act-latest,ubuntu-24.04=ghcr.io/catthehacker/ubuntu:act-24.04"

# arm64 host
ngit-ci --repos <npub> --act-platforms \
  "ubuntu-24.04-arm=ghcr.io/catthehacker/ubuntu:act-latest"

A workflow with runs-on: ubuntu-latest runs on the first coordinator; one with runs-on: ubuntu-24.04-arm on the second; clients see independently signed results per platform. No coordination between the coordinators is required. The author-side guidance — how to structure workflows so they split cleanly across coordinators — is in act-workflows.md.

Notes:

  • label matching is case-insensitive and mirrors act's behaviour: a runs-on: [self-hosted, linux, ...] label list is satisfied by any one mapped label,
  • a single workflow file whose jobs mix labels for different architectures is only claimed by a coordinator whose map covers all of them — split such workflows into one file per architecture,
  • runs-on values the coordinator cannot resolve statically (expressions other than ${{ matrix.<key> }}, runner groups without labels) are conservatively skipped,
  • macOS and Windows labels cannot be served by the embedded runner: act runs jobs in Linux containers. Leave those workflows to runners on the matching OS,
  • the images must match the host architecture; the catthehacker images are multi-arch, so the same reference can serve an arm-labelled mapping on an arm64 host.

Job container resource limits

Without limits, one hostile or badly-written workflow can consume the whole host. Job containers are created by the container daemon, outside the coordinator's own process tree and cgroup — systemd CPUQuota / MemoryMax (or Docker limits) on the coordinator service would constrain git checkouts and the act process, but not the containers where the actual work runs. Cap jobs at the container level instead:

bash
--act-container-options "--memory=4g --memory-swap=4g --cpus=2 --pids-limit=2048"

The effective host ceiling is --max-concurrent-jobs × these per-container limits (the default of one concurrent job makes the per-container spec the whole story). Any docker-run-style flags act's option parser understands can be used; --memory-swap should accompany --memory so jobs cannot swap past the RAM cap.

Docker counts threads as well as processes against --pids-limit. Parallel compilers and test suites can exceed a few hundred tasks even at a low CPU limit, so 2048 is a safer starting point than 512 for general-purpose CI.

Two related notes:

  • keep the default --act-container-daemon-socket -; a daemon socket in the job container lets workflow code spawn sibling containers that escape all of the above,
  • for an aggregate (all-jobs) cap on top of per-container limits, add --cgroup-parent=<slice> to the container options and configure that systemd slice with MemoryMax / CPUQuota on the host.

Job container policy

The coordinator operator dictates the job container spec; workflow files cannot override it. act itself would let them — a job-level container: block replaces the --container-options spec wholesale (including host mounts and unlimited resources via container.options), and services: containers always use workflow-supplied options.

The default --act-job-container-policy admin therefore refuses workflows that declare container: or services: blocks (or job-level uses: reusable workflows, whose container declarations cannot be verified), publishing a failed workflow result that explains why, so authors get feedback over Nostr. Operators who trust their workflow authors can opt out with --act-job-container-policy workflow.

Allowing workflows to request less than the operator cap (validated per-resource instead of refused outright) is a possible future clamp policy; today the spec is all-or-nothing.

Per-repo secrets

Workflows can read ${{ secrets.NAME }}, but only for secrets the operator has explicitly provisioned for that repo, and only on runs whose trigger event was authored by a maintainer of that repo (see maintainer gating). Nothing is injected by default and there is no GitHub-generated GITHUB_TOKEN. The optional GH_READ_TOKEN secret has one additional, narrowly scoped behaviour for authenticated Nix inputs. Secrets never cross repos: a value provisioned for one repo is invisible to every other watched repo's jobs.

Declaring an alias

Secrets are keyed to a repo by an alias the operator assigns in --repos with a trailing #ALIAS. The alias grammar is [A-Z0-9]+ (no underscore, so __ stays an unambiguous delimiter in the env form):

# specific repo
--repos 'npub1abc.../backend#BACKEND'
# all repos of an author (a wildcard alias)
--repos 'npub1abc...#ALLMINE'

An alias on a bare-pubkey entry is a wildcard: its secrets apply to every repo of that pubkey. When both a wildcard alias and an exact-repo alias define the same NAME for a given repo, the exact one wins.

An alias scopes secrets to the logical repository — its recursive maintainership excluding invited maintainers — not just the listed pubkey's own coordinate. A push by a confirmed co-maintainer of an aliased repo resolves the same secrets as a push by the owner.

Maintainer gating

Secrets are injected only when the trigger event was authored by a confirmed maintainer of the repo. Recursive maintainership including invited maintainers is insufficient for secret access: the trigger author must have a current announcement which completes a reciprocal path into the selected view.

For this security-sensitive decision, MaintainerGraph rebuilds the relevant recursive graphs from the persistent rust-nostr event database on each call; it does not trust the watcher's in-memory announcement view. One acting maintainer authority seed, the trigger author, and each released secret owner must belong to the same recursive maintainership excluding invited maintainers. A signed trigger repository must either belong to that view or be the selected coordinate which safely forwards to it. A former forwarding signer and secrets owned by that signer remain excluded. At least one relay announced by the confirmed maintainership must be connected and have completed repository control-history EOSE since its latest connection so the cached relationships have the required freshness confidence. Secrets from every qualifying scope are merged using the precedence rules below. The exact relationship rule and signed-trigger binding are documented in Secret handling and use authorization.

  • Push (repo-state) triggers are accepted only from maintainership excluding invited maintainers. Secret use additionally requires the author to share that maintainership with the acting maintainer and secret owner.
  • PR / PR-update triggers additionally require at least one effective signed repository reference in that same maintainership. A PR which does not meet the confirmed-maintainership rule still runs its workflows (sandboxed, like any PR), but with ${{ secrets.* }} empty — otherwise any Nostr user could exfiltrate secrets by opening a PR whose workflow reads them. Opt-in secrets for third-party PRs (e.g. behind a maintainer approval step) may come later.

The gate is enforced by SecretBroker. Its value-free authorize operation first re-verifies the signed state, PR, PR-update, or manual event and extracts the signed trigger author and effective repository references. It rebuilds one candidate maintainership per selected coordinate from one cache query and selects exactly one matching repository reference before applying the documented rule to the value-free secret scopes. Under request-required, this is the signed a reference of the newest accepted, unstopped Request within the maintainership excluding invited maintainers; the Request author is not the selected maintainer. Under automatic, it is the first matching configured whitelist reference in stable coordinate order. Both policies require repository-relay control-history readiness. Only authorize_and_retrieve combines authorization with configured values. Invalid or denied requests continue to the runner with an empty secret bundle.

Provisioning secret values

Two ways to supply values, both keyed by alias. Values may contain any character (including = and ,). There is deliberately no CLI flag for secret values: argv is world-readable via ps / /proc/<pid>/cmdline.

  • Env / .env: set NGIT_CI_SECRET_<ALIAS>__<NAME>=value. The .env file is loaded before the scan, so it works there too (lock its permissions down, chmod 600). Note the double underscore between alias and name — a single underscore is ignored with a warning.

    bash
    NGIT_CI_SECRET_BACKEND__API_TOKEN=s3cr3t
    NGIT_CI_SECRET_BACKEND__DEPLOY_KEY=...
  • systemd credentials: when the service runs with LoadCredential=<ALIAS>__<NAME>:/path/to/secret-file entries (systemd exports $CREDENTIALS_DIRECTORY to the process), the coordinator scans that directory at startup: each regular file named <ALIAS>__<NAME> is one secret, the file content the value, with exactly one trailing newline stripped if present (systemd-creds and agenix/sops-nix files commonly end with \n). Files not matching the shape — e.g. other services' credentials — are skipped with a warning. The NixOS module's repoSecrets option wires this up (see deploy-nixos.md).

When both sources define the same (alias, NAME), the credential wins — a per-secret file provision is the more deliberate act.

GH_READ_TOKEN and Nix GitHub inputs

GH_READ_TOKEN is an optional well-known repository secret. Provision it through the same operator environment, systemd credential, or kind-29846 flow as any other secret; its presence is the explicit opt-in, with no separate coordinator flag. When the ordinary maintainer gate releases it to a run, ngit-ci does both of the following:

  • exposes it to the workflow as ${{ secrets.GH_READ_TOKEN }};
  • supplies NIX_CONFIG="access-tokens = github.com=<token>" to every job through a private per-run act environment file.

Consequently, existing workflow steps need no YAML change for Nix's GitHub input fetcher — for example, locked flake inputs whose type is github. A workflow-level, job-level, or step-level NIX_CONFIG assignment can override the generated value and thereby return those Nix commands to anonymous access.

This integration is intentionally not a general GitHub login. It does not create ${{ secrets.GITHUB_TOKEN }} or github.token, and it does not automatically authenticate act's own action downloads, Git, GitHub CLI, curl/wget, npm or pnpm, GitHub Packages, or direct archive downloads from github.com, raw.githubusercontent.com, or codeload.github.com. A workflow which needs one of those paths must explicitly consume ${{ secrets.GH_READ_TOKEN }} and configure that tool correctly. Prefer a registry package, pinned vendored source, or independently hosted archive over adding credentials solely for a public pnpm tarball.

The name communicates the required policy but cannot enforce the token's permissions. Use a dedicated, expiring token with only public/read access; accepted values may contain only ASCII letters, digits, _, and -. An authorized run fails during runner setup if its value contains anything else, rather than silently using anonymous Nix downloads. Every authorized workflow can read the token both from the secrets context and from the generated NIX_CONFIG, so it remains a job secret rather than a credential hidden from workflow code. Unauthorized runs, including third-party PRs, receive neither the token nor the generated Nix configuration.

Receiving secrets over Nostr

Set --nostr-secret-relays to one or more coordinator inbox relays to accept encrypted Repository Secret Updates for every repository in the configured --repos scopes. A bare-pubkey scope automatically includes repositories discovered for that pubkey; no alias or separate per-repository intake allowlist is required. With no inbox relay, intake is disabled, no recipient or new secret update is accepted, and the coordinator omits the secrets-key capability.

The coordinator subscribes to every configured inbox and advertises a rotating recipient plus all inbox relay URLs once at least one inbox subscription is ready. A maintainer submits the same signed ephemeral event to every advertised inbox, so one available relay is sufficient. Repository relays remain the independent source of maintainership freshness: each update is accepted only while its signer belongs to the current recursive maintainership excluding invited maintainers, at least one relay announced by that maintainership is connected, and its control history has reached EOSE. rust-nostr owns relay reconnection and resubscription.

Recipient private keys exist only in coordinator memory. Rotation retains an old key through the expiry of every Advertisement naming it plus a one-minute delivery grace, then wipes it. Restart immediately generates and advertises a new recipient; because Repository Secret Updates are ephemeral relay events, updates addressed to the previous process must be submitted again if their value-free inventory receipt was never published.

Accepted ephemeral updates are reduced into private local state beneath <event-cache-dir>/nostr-secrets; this is deliberately separate from the general rust-nostr event database. The directory layout remains an implementation detail. Locally provisioned env/systemd values and Nostr values are merged into the same name-keyed job environment; an operator value wins a same-name collision. Receiving a value does not authorize its use: every job still passes through the live SecretBroker checks described above.

Treat that directory as single-coordinator, trusted local state. Authenticated encryption detects modification without the storage key, but it cannot detect replacement by an older valid snapshot. Do not share one directory between coordinators, and preserve the encrypted frontier together with its key when backing up or restoring it; restoring them to an earlier point can restore an earlier accepted secret state. The directory contains no recipient private keys. A lost storage key or frontier requires maintainers to submit the current values again.

The public kind-39844 Coordinator Repository Status publishes a value-free inventory. Operator-provisioned entries contain only the name; Nostr entries also contain the source maintainer and update timestamp, plus a sealed marker for bunker-sealed values. This lets maintainers audit what the coordinator has without exposing values. The coordinator attempts to republish this status immediately when an accepted update changes the inventory, so clients can use the matching source and update timestamp as registration confirmation. The exact event and encryption shapes are defined in NIP.md.

Bunker-sealed secret values

By default the coordinator can decrypt every Nostr-received value with keys it stores next to the ciphertext. Binding a repository scope to a NIP-46 bunker removes that ability: values are stored sealed to a per-scope sealing keypair whose private half is held only wrapped (encrypted) to the bunker's user pubkey. Sealing new values is a local operation; opening them takes exactly one bunker nip44_decrypt of the wrapped scope key per run, immediately before runner handoff, with the scope key and plaintext kept only in RAM and wiped after the run.

A maintainer enables sealing by submitting a secret named WORKFLOW_SECRETS_DECRYPTION_BUNKER through the ordinary Nostr secret flow. Its value may be either a fresh bunker:// pairing URL or an established nbunksec. For a pairing URL, ngit-ci generates a dedicated app key and pairs once. For nbunksec, it uses the embedded app key. Only after the bunker answers does ngit-ci accept the update: it removes the one-time pairing secret and persists one canonical nbunksec, so restarts reuse the established connection instead of consuming the original URL again. The public inventory continues to advertise only the stable WORKFLOW_SECRETS_DECRYPTION_BUNKER name, never its submitted or stored form.

The interoperable Applesauce/nsyte nbunksec encoding bundles the remote-signer pubkey, dedicated client/app secret key, and relays into one string. It is Bech32 encoding, not encryption, so handle it like a private key. Create a dedicated connection for this coordinator and, when the remote signer supports per-connection policy, allow only get_public_key and nip44_decrypt; do not hand the coordinator an everyday signing connection. With a current ngit, an already established connection can be exported with ngit account export-keys --json. It is the same portable credential accepted by ngit's --nbunksec and --nbunksec-file options. Anyone holding it can request every NIP-46 method that the remote signer permits for that connection.

The binding is never injected into jobs and stays readable by the coordinator (it must recover the connection details and client key). On acceptance the coordinator uses that dedicated client key to connect once, learn the bunker's user pubkey, cache it, seal all values already held for the scope, and store every later value sealed at intake. The coordinator identity key is never reused as a NIP-46 client key. While the bunker is unreachable at intake time, updates for the scope are rejected rather than stored plaintext — resubmit once the bunker is back.

An operator can instead provide a fallback bunker for scopes whose maintainers did not bind one: set NGIT_CI_OPERATOR_BUNKER (env / .env) or the systemd credential operator_bunker. There is deliberately no CLI flag — the nbunksec embeds a client secret key. Supply the credential as one line, with no separate bunker URL or app-key setting. Local configuration accepts only an established nbunksec, never bunker://. A pairing URL is one-time input and would fail when replayed on restart. If the local value is empty, a pairing URL, or an invalid nbunksec, ngit-ci logs a configuration error and keeps ordinary CI and the Nostr inbox running. It rejects new values for scopes without a valid maintainer binding rather than silently storing them plaintext; maintainers may still submit their own valid binding.

This fallback is an implementation feature of ngit-ci, not part of the published protocol. The reserved NGIT_CI_SECRET_<ALIAS>__WORKFLOW_SECRETS_DECRYPTION_BUNKER local-secret form is ignored and is never included in the public inventory; local operators must use NGIT_CI_OPERATOR_BUNKER or the operator_bunker systemd credential.

Failure semantics. A run authorized to receive sealed secrets never executes without them: if the bunker refuses, is offline, or does not answer within the 60-second unlock budget, the run concludes startup_failure and a diagnostic kind-9841 Job Result carries the reason. Maintainers therefore see on Nostr why CI did not run instead of a silently secretless job. Runs not authorized for secrets (e.g. third-party PRs) never contact the bunker.

What this protects — and what it does not. Sealing removes the at-rest risk: a stolen disk, backup, or snapshot of the coordinator yields only ciphertext, and every later unlock is an online, bunker-visible request the maintainer can refuse or revoke. It does not protect against a coordinator that is already compromised while authorized runs execute — plaintext passes through coordinator memory and the job environment during each run, exactly as with unsealed secrets. Availability also couples to the bunker: if it goes offline, secret-bearing runs fail until it returns. Replacing the binding with a bunker holding a different user key makes previously sealed values undecryptable; resubmit them after such a rotation.

Injection and exposure

Only secret names reach act as -s NAME. Secret values are handed to act through its environment (embedded-act) or the adapter's execute-message env map (socket-adapter), so they never appear on act's argv or as literal values in the generated job script. When GH_READ_TOKEN is present, the derived NIX_CONFIG lives in a private per-run environment file and only that file's path reaches act's argv. Under socket-adapter the values do travel to the adapter over its Unix socket and may be forwarded by that adapter as part of its own process or VM-launch environment. For the microvm adapter they enter the guest — trust the adapter you point ngit-ci at accordingly.

act masks known secret values in step logs, but treat the published kind-9841 log excerpts as untrusted: a workflow can still echo a secret in a way that defeats masking. Only provision secrets for workflows you trust.

Reserved names

A secret NAME must match [A-Z_][A-Z0-9_]* (uppercase letters, digits and underscore; not starting with a digit) and may not be a coordinator/act runtime variable. Uppercase is required because act treats secret names case-insensitively and resolves a -s NAME flag against the uppercased environment variable, so a lowercase name could never receive its value; workflows lose nothing — ${{ secrets.name }} lookups are case-insensitive. Reserved names are rejected at startup, case-insensitively: PATH, HOME, CI, DOCKER_HOST, XDG_CONFIG_HOME, XDG_CACHE_HOME, NIX_CONFIG, and anything beginning GITHUB_, NGIT_CI_, RUNNER_, or ACTIONS_. NIX_CONFIG is coordinator-owned so an authorized GH_READ_TOKEN can derive it without colliding with a separately provisioned secret. A reserved or invalid name, or a duplicate alias in --repos, fails the coordinator at startup rather than silently dropping the secret; an unknown alias or an empty value is skipped with a warning (orchestrators routinely inject empty strings for unset variables, and the environment and credentials directory carry unrelated entries).

Sandbox adapters (socket-adapter)

--runner socket-adapter moves job execution off the coordinator's host and into an external sandbox adapter — a dumb daemon on a Unix domain socket speaking the Loom execution-adapter contract (line-delimited JSON, one job per connection, streamed output; documented in execution-adapter-protocol.md):

bash
ngit-ci --runner socket-adapter --adapter-socket /run/ngit-ci/adapter.sock ...

The coordinator renders each selected workflow into a self-contained job script (repo clone with fallback URLs, event payload, act invocation) and submits it as one execute message. The sandbox must provide bash, git, act, and a Docker-compatible daemon for act's job containers. Compatible adapters are the first-party ngit-ci-adapter-microvm (per-job QEMU/KVM microVMs, in this workspace) and Arjen's Loom adapters (shell, multipass, and the lima QEMU/KVM VM pool).

Division of labour, unchanged from the embedded backend:

  • the coordinator keeps workflow discovery, trigger matching, queueing, and timeout enforcement (--job-timeout-secs is enforced by dropping the adapter connection, which obliges the adapter to kill the job),
  • the adapter keeps no queue: when its capacity is exhausted it rejects the job immediately. The job then fails with a retryable error surfaced in the published kind-9842 result (conclusion startup_failure, no exit code) — the coordinator deliberately does not retry on its own in v1; re-trigger the job (e.g. push a new commit or replay the PR event) once the adapter has free capacity. To avoid rejections in the first place, pair one coordinator with one adapter and size --max-concurrent-jobs at or below the adapter's slot count. Pointing several coordinators at one adapter socket is possible but discouraged: their queues cannot coordinate, so slot races surface as published job failures.

The --act-platforms, --act-container-daemon-socket, and --act-container-options settings apply inside the sandbox exactly as they do locally. --docker-host does not: the sandbox talks to its own container daemon.

Git collaboration, without the platform.