Channel Promotion and CI Host Groups

Updated

September 12, 2026

Channel Promotion and CI Host Groups

NixOS/infra’s channels.nix is a table: channel name → Hydra job, variant (primary / small / darwin), status (rolling / stable / unmaintained). Their checks/ flake groups host toplevels by architecture so CI can nix-fast-build one arch without evaluating every Darwin Mac. The boring default is: the same two ideas at desk scale — a promotion table in git, and checks that list host closures by arch — not a wiki “we shipped 26.05.”

You will not run Hydra for nixpkgs. You will have a tested job that may update a Cachix/Attic alias.

Mental model

nix flake check          →  hosts eval + tests
nix build .#tested       →  the closure you are willing to name
cachix push / attic push →  cache
channels.nix status      →  stable | staging | unmaintained

NixOS maps nixos-26.05 to nixos/release-26.05/tested on hydra.nixos.org. Desk maps desk-stable to .#nixosConfigurations.app-01.config.system.build.toplevel after @apps canary. The table is data. The job is CI.

25.11 in their file is status = "unmaintained". That is how you tell humans to stop following a pin — not a Slack message.

CI groups (their flake.ciSystems.ofborg-x86_64-linux = [ core01 build01 … ]) exist so one runner does not eval aarch64-darwin. List names explicitly. Do not mapAttrs every nixosConfigurations if that forces Mac eval on a Linux runner.

Worked examples

Case 1: A desk channel table

Save as channels.nix:

# channels.nix
{
  channels = {
    desk-unstable = {
      job = "flake.checks.x86_64-linux.default";
      variant = "primary";
      status = "rolling";
    };
    desk-26.05 = {
      job = "nixosConfigurations.app-01.toplevel";
      variant = "primary";
      status = "stable";
    };
    desk-26.05-small = {
      job = "nixosConfigurations.app-01.toplevel";
      variant = "small";
      status = "stable";
    };
  };
}
nix eval --file channels.nix 'channels.desk-26.05.status'
"stable"

When 26.11 exists, set desk-26.05.status = "deprecated" in the same PR that bumps the flake input. The table is the announcement.

Case 2: tested as an explicit package

Save as flake.nix fragment:

# flake.nix fragment
{
  packages.x86_64-linux.tested = pkgs.releaseTools.aggregate {
    name = "desk-tested";
    constituents = [
      self.nixosConfigurations.app-01.config.system.build.toplevel
      self.nixosConfigurations.app-02.config.system.build.toplevel
      self.checks.x86_64-linux.fmt
    ];
  };
}

If releaseTools.aggregate feels heavy, a simpler pin:

# flake.nix fragment
{
  packages.x86_64-linux.tested =
    self.nixosConfigurations.app-01.config.system.build.toplevel;
}

CI:

nix build .#tested
cachix push desk-cache ./result

Humans nixos-rebuild switch --flake .#app-01 only after .tested is green. That is a channel. Hydra’s UI is optional.

Case 3: Group host toplevels by arch (no surprise Darwin eval)

Save as ci-hosts.nix:

# ci-hosts.nix
{ self, lib }:

let
  nixos = names:
    lib.genAttrs names (n: self.nixosConfigurations.${n}.config.system.build.toplevel);
in
{
  desk-x86_64-linux = nixos [ "gw-01" "app-01" "app-02" "db-01" "ops-01" ];
  desk-aarch64-linux = nixos [ "builder-arm" ];
}

In outputs:

# flake.nix fragment
{
  checks.x86_64-linux = (import ./ci-hosts.nix { inherit self; lib = nixpkgs.lib; }).desk-x86_64-linux;
}
nix flake check

A Linux GHA runner evals five x86_64 toplevels. It does not eval builder-arm unless you add a matrix job for aarch64-linux. NixOS/infra’s comment: list hosts explicitly so CI does not force every configuration just to learn system.

Case 4: OpenTofu envelope, NixOS OS — two shells

Their terraform/flake-module.nix is a mkShellNoCC with opentofu.withPlugins (AWS, Fastly, Netlify). DNS and IAM live there. Machines live in Colmena.

# flake.nix fragment
{
  devShells.x86_64-linux.tf = pkgs.mkShell {
    packages = [
      pkgs.awscli2
      (pkgs.opentofu.withPlugins (p: [ p.aws p.random ]))
    ];
  };
}
# dns.tf
resource "aws_route53_record" "api" {
  zone_id = "ZXXXXXXXX"
  name    = "api.desk.internal"
  type    = "A"
  ttl     = 60
  records = ["203.0.113.10"]
}

The IP is the Colmena targetHost. Terraform does not install NixOS (Disko / nixos-anywhere does). Two tools, two state files, one README that says which.

nix develop .#tf --command tofu fmt -check

Case 5: Promote, then deprecate

# channels.nix
{
  channels = {
    desk-26.05 = {
      job = "nixosConfigurations.app-01.toplevel";
      variant = "primary";
      status = "deprecated";
    };
    desk-26.11 = {
      job = "nixosConfigurations.app-01.toplevel";
      variant = "primary";
      status = "stable";
    };
    desk-25.11 = {
      job = "nixosConfigurations.app-01.toplevel";
      variant = "primary";
      status = "unmaintained";
    };
  };
}

Unmaintained means: delete the GHA job, leave the table row so git log explains why flake.lock must not point there. NixOS/infra keeps 25.11 rows as unmaintained instead of pretending the files never existed.

nix eval --file channels.nix 'channels' --apply builtins.attrNames

The trap

The trap is cachix push from every PR and calling that a channel. A channel is a named, reviewed closure (tested) plus a status in git. PR pushes belong behind skipPush.

The other trap is checks = mapAttrs every nixosConfigurations on a laptop flake that also has aarch64-darwin. The Linux runner then tries to eval Darwin and fails (or downloads a world). List names.

A third: Terraform remote-exec of nixos-rebuild so “we only have one tool.” That is how state and generations diverge. OpenTofu for DNS/IAM; Colmena for the OS.

The boring rule

  • channels.nix is a table: job, variant, status. Status changes in git.
  • .tested (or aggregate) is what CI pushes to the team cache.
  • checks list host toplevels by arch, names explicit.
  • OpenTofu withPlugins for the cloud envelope. Colmena for NixOS.
  • Deprecated ≠ deleted. Unmaintained ≠ still in CI.

Try this

  1. Add channels.nix with desk-26.05.status = "stable"; nix eval it.
  2. nix build .#tested where tested is app-01’s toplevel.
  3. Write ci-hosts.nix with two names; nix flake check; add a fake Darwin host name and confirm you did not put it on the Linux checks attr.
  4. nix develop .#tf --command which tofu — store path, plugins attached.
  5. In a branch, set desk-26.05.status = "deprecated" and read the PR diff as if you were on-call.