Channel Promotion and CI Host Groups
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 ./resultHumans 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 checkA 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 -checkCase 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.attrNamesThe 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.nixis a table: job, variant, status. Status changes in git..tested(or aggregate) is what CI pushes to the team cache.checkslist host toplevels by arch, names explicit.- OpenTofu
withPluginsfor the cloud envelope. Colmena for NixOS. - Deprecated ≠ deleted. Unmaintained ≠ still in CI.
Try this
- Add
channels.nixwithdesk-26.05.status = "stable";nix evalit. nix build .#testedwheretestedisapp-01’s toplevel.- Write
ci-hosts.nixwith two names;nix flake check; add a fake Darwin host name and confirm you did not put it on the Linux checks attr. nix develop .#tf --command which tofu— store path, plugins attached.- In a branch, set
desk-26.05.status = "deprecated"and read the PR diff as if you were on-call.