No description
  • Rust 85.7%
  • Nix 11.2%
  • Go 3.1%
Find a file
2026-09-02 11:56:20 +00:00
docs fix: retry unacknowledged Forgejo job outputs 2026-09-02 11:56:20 +00:00
examples/forgejo feat: run Forgejo CI in Firecracker guests 2026-08-17 20:30:31 +00:00
forgejo-engine refactor: move CDK guest ownership downstream 2026-09-02 08:43:44 +00:00
nix feat: stream Firecracker guest traffic over vsock 2026-08-29 12:27:30 +00:00
proto/runner/v1 plan 2026-06-23 14:13:38 +01:00
src fix: retry unacknowledged Forgejo job outputs 2026-09-02 11:56:20 +00:00
templates/custom-guest refactor: move CDK guest ownership downstream 2026-09-02 08:43:44 +00:00
tests fix: retry unacknowledged Forgejo job outputs 2026-09-02 11:56:20 +00:00
.gitignore plan 2026-06-23 14:13:38 +01:00
build.rs plan 2026-06-23 14:13:38 +01:00
Cargo.lock feat: expose Prometheus job and startup metrics 2026-08-28 15:19:01 +00:00
Cargo.toml feat: expose Prometheus job and startup metrics 2026-08-28 15:19:01 +00:00
flake.lock refactor: move CDK guest ownership downstream 2026-09-02 08:43:44 +00:00
flake.nix refactor: move CDK guest ownership downstream 2026-09-02 08:43:44 +00:00
README.md refactor: move CDK guest ownership downstream 2026-09-02 08:43:44 +00:00
unified-ci-compute-platform.md plan 2026-06-23 14:13:38 +01:00

unified-ci

unified-ci is a Forgejo-first CI runner service with a source-neutral scheduler and execution path. The current implementation can register as a Forgejo runner, poll for jobs, execute supported workflows through a pinned Forgejo v13 engine, stream logs, report final status, and record usage.

The project is still a scaffold/MVP, not a production-hardened runner. It is useful today for basic Forgejo Actions workflows and for validating the Firecracker guest execution model.

What Works Today

  • Forgejo runner registration or loading existing runner credentials.
  • Forgejo label declaration and polling.
  • Internal development job submission over HTTP.
  • Source-neutral scheduling with global and per-template capacity.
  • JSON state for jobs, results, templates, Forgejo account balances, and usage.
  • guest_process execution for local validation without KVM.
  • Firecracker execution with one VM per job on Linux hosts with KVM.
  • Packaged NixOS Firecracker guest images containing the Forgejo engine, Node.js, Git, shell tools, Nix with flakes enabled, and the guest agent.
  • A Docker-enabled Firecracker guest variant for workflows that need Docker actions, service containers, or Docker CLI access inside the VM.
  • Shell, JavaScript, and composite action steps with vars, masked secrets, needs, matrices, and job outputs.
  • Optional Firecracker egress_only networking for public internet egress with host/private/job-to-job isolation.

Not Supported Yet

  • Full forgejo-runner compatibility.
  • Full Docker action parity.
  • Caches and artifacts.
  • Production-grade state storage and admin tooling.
  • Nostr/Cashu ingress and settlement.

See examples/forgejo for workflows matching the currently tested subset.

Quick Checks

Run the normal local checks:

cargo fmt -- --check
cargo test --offline
nix flake check

Validate the live Forgejo protocol path without Firecracker:

nix run .#forgejo-live-smoke

Validate Firecracker execution on a Linux host with readable/writable /dev/kvm:

nix run .#firecracker-smoke

Validate Forgejo plus Firecracker together:

nix run .#forgejo-firecracker-smoke

Validate Firecracker public egress with host/private/job isolation. This needs root or equivalent CAP_NET_ADMIN privileges in addition to KVM:

sudo nix run .#firecracker-egress-smoke

Smoke apps exit with code 77 and a SKIP message when the host does not have the required KVM or network privileges.

Build A Custom Guest Downstream

Rith exports lib.<system>.mkFirecrackerGuest so projects can build and cache their own guest image without adding project-specific inputs to Rith itself. The custom-guest template demonstrates embedding a development-shell closure, enabling Docker, configuring public binary caches, exporting the image as a package, and wiring it into the generic Firecracker runner module with explicit CPU, memory, writable storage, and concurrency:

nix flake init \
  -t 'git+https://git.cashu.dev/thesimplekid/rith.git#custom-guest'
nix build .#custom-guest

See templates/custom-guest/README.md for cache publication and deployment-flake examples. Customization happens while building the immutable image, not when each job starts.

NixOS Module

The flake exposes a NixOS module as nixosModules.default. It creates the unified-ci user/group, writes /etc/unified-ci/config.toml, and installs a systemd service. By default it runs unified-ci --check-config before service start.

Guest-Process Setup

This is the easiest way to verify registration, polling, scheduling, log streaming, and status reporting before enabling Firecracker.

{
  inputs.unified-ci.url = "path:/path/to/unified_ci";

  outputs = { self, nixpkgs, unified-ci, ... }: {
    nixosConfigurations.runner = nixpkgs.lib.nixosSystem {
      system = "x86_64-linux";
      modules = [
        unified-ci.nixosModules.default
        ({ pkgs, ... }: {
          services.unified-ci = {
            enable = true;
            executorBackend = "guest_process";
            executorPackages = [
              unified-ci.packages.x86_64-linux.forgejo-engine
              pkgs.git
            ];

            commandExecutor = {
              command = "${unified-ci.packages.x86_64-linux.default}/bin/unified-ci-guest-agent";
              args = [
                "--forgejo-command" "${unified-ci.packages.x86_64-linux.forgejo-engine}/bin/rith-forgejo-engine"
                "--forgejo"
              ];
              workingDir = "/var/lib/unified-ci/work";
            };

            forgejo = {
              enable = true;
              instanceUrl = "https://forgejo.example.com";
              runnerName = "unified-ci";
              runnerLabels = [ "ubuntu-latest" ];
              registrationTokenPath = "/run/secrets/forgejo-runner-registration-token";
              accountId = "forgejo-default";
            };

            billing.forgejoAccounts.forgejo-default.balance_seconds = 3600;
          };
        })
      ];
    };
  };
}

After the service starts, credentials are written to /var/lib/unified-ci/forgejo-runner.json. Future starts can use that credentials file without the registration token.

Firecracker Setup

Build one of the packaged guest images:

nix build .#firecracker-guest-minimal
nix build .#firecracker-guest-docker
nix build .#firecracker-guest

firecracker-guest currently aliases firecracker-guest-docker. Use the minimal image for shell/composite workflows with a smaller rootfs. Both guest variants include the Forgejo engine, Node.js, nix, nix-build, nix-shell, and flakes support. Use the Docker image when workflows need Docker actions or Docker CLI access inside the VM. The packaged images stay compact in the Nix store and record their recommended writable headroom as metadata. At service startup the NixOS module copies each image into commandExecutor.rootfsImageDir and grows it once; rootfs_extra_size_mib overrides the recommendation per template. Every job then receives a strict copy-on-write clone with no resize step. Then configure the Firecracker executor with one or more templates. The scheduler selects a template by matching job labels.

{ pkgs, unified-ci, ... }:
{
  services.unified-ci = {
    enable = true;
    executorBackend = "firecracker";

    commandExecutor = {
      command = "${unified-ci.packages.x86_64-linux.default}/bin/unified-ci-firecracker-launcher";
      args = [
        "--firecracker" "${pkgs.firecracker}/bin/firecracker"
      ];
      runtimeDir = "/var/lib/unified-ci/firecracker";
      # Optional; defaults to an images directory below runtimeDir. Both paths
      # must be on the same Btrfs or reflink-enabled XFS filesystem.
      rootfsImageDir = "/var/lib/unified-ci/firecracker/images";

      # Required only for templates using network = "egress_only".
      networkHelper = "${unified-ci.packages.x86_64-linux.default}/bin/unified-ci-firecracker-netns-helper";
      # Optional. If unset, the helper uses the host default route interface.
      # networkEgressInterface = "eth0";
      # Optional exact RFC1918 IPv4/TCP exceptions. Policy-mode helpers install
      # these before the general private-range deny rules.
      # allowedPrivateEgress = [
      #   {
      #     address = "10.0.0.20";
      #     port = 8080;
      #   }
      # ];
    };

    vmTemplates.minimal = {
      name = "nixos-act-minimal";
      labels = [ "self-hosted" "ubuntu-latest" "nix" "x86_64-linux" ];
      default = true;
      kernel = "/nix/store/...-unified-ci-firecracker-guest-minimal/vmlinux";
      initrd = "/nix/store/...-unified-ci-firecracker-guest-minimal/initrd";
      rootfs = "/nix/store/...-unified-ci-firecracker-guest-minimal/rootfs.ext4";
      rootfs_extra_size_mib = 16384;
      vcpu_count = 2;
      memory_mib = 4096;
      max_concurrent_jobs = 1;
      network = "egress_only";
    };

    vmTemplates.docker = {
      name = "nixos-act-docker";
      labels = [ "self-hosted" "ubuntu-latest" "nix" "x86_64-linux" "docker" ];
      kernel = "/nix/store/...-unified-ci-firecracker-guest-docker/vmlinux";
      initrd = "/nix/store/...-unified-ci-firecracker-guest-docker/initrd";
      rootfs = "/nix/store/...-unified-ci-firecracker-guest-docker/rootfs.ext4";
      rootfs_extra_size_mib = 32768;
      vcpu_count = 2;
      memory_mib = 6144;
      max_concurrent_jobs = 1;
      network = "egress_only";
    };

    forgejo = {
      enable = true;
      instanceUrl = "https://forgejo.example.com";
      runnerName = "unified-ci-firecracker";
      runnerLabels = [ "self-hosted" "ubuntu-latest" "nix" "x86_64-linux" "docker" ];
      registrationTokenPath = "/run/secrets/forgejo-runner-registration-token";
      accountId = "forgejo-default";
    };

    billing.forgejoAccounts.forgejo-default.balance_seconds = 3600;
  };
}

The main unified-ci service runs as the unified-ci user. Firecracker itself needs usable /dev/kvm; for Firecracker configurations the module creates the kvm group, adds the service user to it, and conditions startup on that device being present. The egress_only network helper needs root or equivalent CAP_NET_ADMIN rights to create network namespaces, TAP/veth devices, nftables rules, and NAT. The firecracker-runner module grants that privilege only through a fixed Nix-store wrapper and drops back to the service UID/GID before Firecracker is executed. The service deliberately fails its startup probe when the staged image and runtime directory cannot perform cp --reflink=always; ext4 and separate filesystems are rejected instead of silently falling back to a multi-gigabyte physical copy. Mount /var/lib/unified-ci/firecracker on Btrfs or reflink-enabled XFS (or choose equivalent paths with the options above). Multiple VM templates can be configured at the same time; use distinct labels and per-template capacity to control which jobs land on the minimal or Docker-enabled image. forgejo.runnerLabels is what the runner advertises to Forgejo; vmTemplates.<name>.labels is what unified-ci uses to choose a VM template after a task is fetched. A Forgejo workflow with runs-on: docker therefore requires docker in both forgejo.runnerLabels and the Docker template's label list. When a task requests multiple labels, one VM template must contain all of them; Rith rejects ambiguous or unmatched combinations.

Prometheus Scraping

The NixOS module can add Rith's loopback /metrics endpoint to an existing host Prometheus configuration:

services.unified-ci.prometheus.enable = true;

This only contributes a job_name = "unified-ci" entry to services.prometheus.scrapeConfigs; it deliberately does not enable Prometheus, configure agent mode or remote write, manage credentials, or open the firewall. Those are deployment responsibilities. By default the scrape target is derived from services.unified-ci.listenAddr. Wildcard IPv4 and IPv6 binds are mapped to loopback for same-host scraping. Override unusual network or proxy layouts explicitly:

services.unified-ci.prometheus.target = "rith.internal:18080";

See docs/10-configuration-and-operations.md for an agent-mode remote-write example and Grafana queries.

Operational Commands

Validate a rendered config without contacting Forgejo or booting a VM:

unified-ci --config /etc/unified-ci/config.toml --check-config

Run one Forgejo poll cycle and exit:

unified-ci --config /etc/unified-ci/config.toml --forgejo-once

Start the daemon:

unified-ci --config /etc/unified-ci/config.toml --serve

Status endpoints:

  • GET /healthz
  • GET /status
  • GET /jobs/<job-id>
  • GET /usage

More Docs