Skip to content

Workflows

github-actions[bot] edited this page Sep 13, 2026 · 6 revisions

Generated from docs/internals/ in the main repository. Edits made here are overwritten on the next push to main.

The workflows

Ten of them. Two run on your pull request, six run on a clock, one runs on a tag, one publishes this page. Knowing which is which settles the most common confusion here — "CI is green, so why did the nightly fail?" — because they check different things on different machines.

Every one of them accepts manual dispatch:

gh workflow run <name>.yml            # on main
gh workflow run <name>.yml --ref <branch>
gh run watch                          # follow the newest run

Source of truth is docs/internals/ in the main repository. The wiki copy is generated by .github/workflows/wiki.yml.


At a glance

workflow trigger runner for
build.yml push, PR hosted + self-hosted the bulk of the checks
install-check.yml push, PR self-hosted installs into a real VM
nightly.yml 03:00 UTC self-hosted the expensive VM checks
omarchy.yml 04:00 UTC hosted is there a new Omarchy?
update.yml 05:00 UTC hosted are the vendored tools behind?
review.yml 06:00 UTC hosted write the state into the review issue
flake-update.yml 07:00 UTC hosted bump the flake inputs
release.yml a v* tag self-hosted build and publish the images
wiki.yml docs/internals/** hosted mirror these pages
copilot-setup-steps.yml Copilot hosted agent environment
copilot-handoff.yml build fails on update/ hosted hand a red bump to Copilot

The clock is staggered on purpose: each one reports on the state the previous one left.


build.yml — the bulk of the checks

Trigger: every push to main and every pull request. Jobs: 7, most gated behind the first.

gateDecide whether this change can affect a build

Computes whether the diff can possibly change a build output. A documentation-only change skips the expensive jobs, and each skipped job still reports success so required checks are satisfied.

It is a denylist, never an allowlist, and .github/scripts/pr-touches-build.sh states why: Nix closures are computed by evaluation, not by directory, so an allowlist silently stops covering the next directory somebody adds. A denylist can only be wrong about the paths it names.

It answers two questions, not one. build.yml asks "can this change anything Nix builds?" — a new tests/foo.nix is a new derivation, so yes. install-check.yml asks the narrower "can this change an INSTALL?", and --install adds a second, tighter list for it.

README.md is the case worth knowing, because it sits on opposite sides of those two questions:

README.md
can it change a build? yesbuild.yml derives the app and command counts from data/ and greps README for them, and that guard caught a real mismatch
can it change an install? no — nothing an install builds reads it

So a one-line edit to the README's roadmap table runs the count guard and skips the install. Before that split it did both, twice in one day, queued behind every other pull request on the single slot. tests/install-gate.nix asserts both halves, because one without the other is the bug.

lint — four linters, because they catch different things

  1. Check formattingnix fmt -- --ci
  2. statix — repeated keys, useless parens; things nix fmt is happy with
  3. deadnix — unused bindings
  4. The workflow files are valid GitHub Actionsactionlint

Run all four locally before pushing; statix in particular has caught the same class of mistake repeatedly.

omarchy — the vendored tree and the guards over it

Builds .#omarchy, pushes it to cachix, then runs the checks that stop prose and data from drifting:

step what fails
Verify the CLI sees its subcommands a shipped command with no # omarchy:summary=
Every check is run by some workflow a check that exists and nothing runs
The README's roadmap still matches the open epics an epic issue with no Roadmap row
Every derived number in the README a hand-edited count that drifted
Verify shebangs were patched a #!/bin/bash on line 1
Every command this port changes, classified a nix-bin/ file with no data/bin-ledger.nix row
Verify the browser lookup ignores $BROWSER a script that trusts xdg-settings get
Verify the agent skills are NixOS-aware a pacman or /usr/share/omarchy line in a skill's code block
Verify every app's attr resolves a catalogue row naming a package that does not exist
Verify every Install row is mapped an upstream menu row nobody mapped, or a mapping for a row that is gone
Verify every desktop entry's command resolves a launcher entry that does nothing

devenv-presets, apps, system, box

system is the big one: it builds the VM and installed-machine closures, pushes them to cachix, and runs the session, option, integration, coexist, installer-screen and plugin checks. box needs /dev/kvm and fails loudly at its first step if the runner does not have it.


install-check.yml — a real install, on real hardware

Trigger: every push and pull request. Runner: chosen at runtime — runs-on: ${{ fromJSON(needs.gate.outputs.runner) }} — self-hosted when the change can affect an install, hosted otherwise.

Steps: gate → This host can finish what it is about to startInstall onto a blank disk, and into free space beside a neighbourPush the proof, so nobody has to prove it twiceReport what it cost.

The first step refuses a host with under 40 GB free under the store (#672), before checkout. A full disk does not fail an install cleanly: the guest starves and udevadm settle times out eighty minutes in, on whoever's pull request was running — the same symptom as the concurrency limit below. The three VM images are only 14.3 GB; the floor is generous because each install also fetches and builds into the store, and a refusal in ten seconds costs a re-run where a starved job costs an afternoon. It measures the store path, not /, and prints the figure either way. The cause it cannot fix is the host's own garbage-collection floor, which is outside this repository (#663).

Why it is slow, and why that is deliberate

It boots a VM and installs nixarchy into it, capped so two installs run at a time (#587).

That cap is not caution. The in-guest install has a 30-minute timeout, and a guest-side timeout is a hidden concurrency limit: host-side timeouts scale with the machine, guest-side ones do not. Four at once on one box turned "slower" into "failed", and the failure named the test rather than the contention. Two concurrent jobs is what demonstrably passes, and two is therefore the cap: cksum(GITHUB_REF) % 2 puts every relevant run in one of nixarchy-install-vm-1 or -2. The hash is stable, so a pull request's re-runs queue behind themselves instead of migrating between slots.

A queued install check may be the cap working — or a job about to be evicted. The two look identical from the outside (#548 measured it). GitHub retains only one pending job per concurrency group, so when a second run queues into a slot, the next arrival cancels whoever was waiting. Note what this does and does not mean: a running job blocks nothing — main building while one PR waits is fine — and eviction begins at the second pending job. Three supervisor designs were written here on the opposite assumption and none of them worked. A cancelled install renders as a failure, and a cancelled required check silently disables auto-merge — the pull request then sits there with nothing visibly wrong. #550 gave runs the gate rules irrelevant their own install-noop-<ref> group, so a docs-only PR no longer touches the shared slots at all, and #587 split the one shared group into two. The eviction is halved, not repealed: the one-pending rule still applies within each slot, so three relevant pull requests hashing to the same slot still evict each other. That residue is #555.

What that cap costs, and what is done instead

Two slots for the whole repository means every minute spent there is a minute other pull requests wait. Two rules follow from it, and both are visible in the checks:

  • A check that does not need a VM must not take one. stable-eval proves this flake still evaluates against stable nixpkgs in about twenty seconds, by forcing the system's derivation to be computed rather than built. It exists because stable stopped evaluating on an undefined variable — the cheapest class of failure there is — and nothing looked for months.
  • A cheap check must say what it does not prove. stable-eval prints it in its own output: "NOT PROVEN: that it boots, or that the desktop comes up. Nothing here starts a VM." A green tick that gets read as a guarantee it never made is worse than no check.

nightly.yml — 03:00 UTC, the expensive checks

Runner: self-hosted. Jobs: 6 plus a reporter.

job steps
install install onto a blank disk and boot the result; check the harness builds; report the duration
install-encrypted install with encryption, boot through the LUKS prompt
microvm boot a MicroVM on the -tcg runner
install-iso build the ISO, report its size, check both images are inside their budgets, install from it with no network device present, then install from the network ISO
cache does cachix hold what main last built? main only — the omarchy derivation depends on the flake's own rev, so on a branch it probes a path nobody pushed and 404s by construction
canary resolve every input the way a user would tomorrow, and check their machine still evaluates
report open or update an issue when something failed

install-iso is the one that proves the offline image: it installs with no network device at all and asserts not that the install succeeded but that nothing was built and nothing was fetched.


Pinned inputs on pull requests, moving ones only here

nixpkgs-stable is an input in flake.lock, not a ref resolved fresh. A moving ref would turn every open pull request red the moment upstream moved, for a reason none of their authors could fix — the same failure that took the roadmap check off pull requests (see omarchy above).

Upstream drift is this workflow's canary job instead: it runs a bare nix flake update and resolves every input the repository leaves mobile. Its failure has its own identity — a canary failure means UPSTREAM moved under us — and it files an issue saying so.

Two questions, two places, neither pretending to be the other. If you are adding a check that depends on something outside the lock, this is the split to follow.


omarchy.yml — 04:00 UTC, the upstream bump

One job, bump, and it is the most elaborate workflow here because a bump can break anything.

  1. Find the newest Omarchy release — stops if it is the one we vendor
  2. Bump the input and build against the new Omarchy
  3. What packages this release adds and drops — diffs upstream's package lists
  4. What upstream says this release changed — their release notes
  5. What this release changes in the tree seeded into ~/.config
  6. Which files this port patches upstream touched — the dangerous one: a patch whose target moved
  7. Check nothing became unmapped or unwired — menu mapping and the bin ledger against the candidate release
  8. Turn what it found into a decision list
  9. Boot a session and check the desktop renders
  10. Apply the README's derived numbers — runs readme-counts.sh --fix, so a release moves the numbers instead of failing on them
  11. Open a pull request, arm auto-merge, and report what needs a human

Auto-merge is gated on the required checks, so a bump that breaks something stays open.

Both halves use BUMP_TOKEN, a person's token, not GITHUB_TOKEN (the same applies to flake-update.yml). Anything GITHUB_TOKEN does starts no workflows. A pull request it opens gets no checks, so auto-merge waits forever; and GitHub performs an auto-merge as whoever armed it, so a merge it armed lands on main with no build and no install check. #671 did exactly that (#676). Without the secret both steps fall back to GITHUB_TOKEN, and the merge step fails saying so rather than arming a merge that can never fire.

A bump that goes red is handed to Copilot — see copilot-handoff.yml below.


update.yml — 05:00 UTC, the vendored tools

Bumps the small pinned packages (hey-cli, ttfx, once, omacalc, omacut, omawrite…), builds every one it moved, and opens a PR. Nightly rather than weekly because these publish often.

review.yml — 06:00 UTC, the one to read first

One step, Review, and say so: runs pkgs/review.sh, which reads every pin, every vendored version, every workflow's last result, and the board's hygiene — then edits the issue titled Nightly review: what needs updating and fixing in place with the table, and closes it automatically when everything is green. Once closed, the next red night opens a new one, so the number changes (#195, then #670); find it by title.

So an open review issue means something needs attention; no open one means nothing does. It files with the Keeping the lights on milestone, because its own board row flags any open issue without one — itself included (#670).

nix run .#review        # the same table, locally

flake-update.yml — 07:00 UTC, the inputs

What the apps are on todayMove the pinWhat the apps would be onWhat actually movedBoth toplevels still evaluate → PR with auto-merge armed, both through BUMP_TOKEN for the reason given under omarchy.yml. It says so explicitly when there was nothing to do.


release.yml — on a v* tag

Runner: self-hosted. Nothing pushes tags; cutting a release is the one step that is deliberately a person.

git tag v4.0.3-2 && git push origin v4.0.3-2

Steps, in order, and the first three are refusals:

  1. The tag is not behind main
  2. The clone is not shallow, or the notes cannot be derived
  3. The tag names the Omarchy the flake vendorsv4.0.3-2 must vendor 4.0.3
  4. Build the ISO and Build the network ISO
  5. Split it into pieces GitHub will accept — the offline image exceeds the 2 GiB asset cap
  6. Write the release notes — derived, via nix run .#release-notes
  7. Publish
  8. The release attached everything it promised — compares what was built against what SHA256SUMS names
  9. Move the release branch to this tag — this is what nixarchy update follows, so cutting a tag is what delivers updates
  10. Remove the split copies

wiki.yml — publishes these pages

Triggers on any change to docs/internals/**. Copies them into the wiki, writes _Sidebar.md, and prefixes each page with a banner saying edits made in the wiki are overwritten.

The wiki is generated. Do not edit it there — the next push to main discards it.

copilot-setup-steps.yml

Installs the agent-bus MCP server for Copilot agents and proves it starts and serves the tools its allowlist names.

copilot-handoff.yml — a red bump, handed to Copilot

Trigger: workflow_run on build completing. Runs only when the conclusion is failure, the branch starts update/ (all three bump bots use that prefix), and the branch is in this repository.

It runs .github/scripts/copilot-handoff.sh, which posts one @copilot comment on that branch's open pull request: the failed job names, the error lines from the log (post-job cleanup and nix trace frames cut, colour stripped in both spellings), and three constraints — fix the cause rather than revert the bump, show it failing then passing (§1), and stop and say so if the failing job needs KVM or a self-hosted runner. Copilot pushes its fix to that same branch, and build runs again.

It holds COPILOT_AGENT_TOKEN, a broad PAT, because Copilot answers only comments from someone with write access and GITHUB_TOKEN is not that. The shape around it:

guard what it stops
workflow_run, checking out main pull request code never runs while the secret is in scope
same-repository test a fork naming its branch update/anything
permissions: {} nothing else rides on the job token
values passed through env: expression injection into run:

What it will not do:

  • Hand off a cancelled run. An eviction or a timeout has no failed job (§6); an eviction needs nothing, and a timeout is a CI-gate change for a human.
  • Hand off the same commit twice. Each comment carries a hidden copilot-handoff sha=<sha> marker, so a re-run of that commit is skipped.
  • Loop. At most two handoffs per pull request; after that the step summary says it is left for a human.
  • Watch install check. Those jobs need the self-hosted runners, which Copilot cannot reach.

DRY_RUN=1 prints the comment instead of posting it.


The composite action

.github/actions/setup-nix is used by nearly every job. It installs nix via DeterminateSystems/nix-installer-action and — this is the load-bearing part — writes the caches into the system nix.conf with extra-conf, not through the flake's own nixConfig.

The difference matters: accept-flake-config promotes a flake's nixConfig to client-specified settings, and the daemon discards those for anyone not in trusted-users. When that happened, every build printed

warning: ignoring the client-specified setting 'trusted-public-keys',
because it is a restricted setting and you are not a trusted user

and then compiled Hyprland from source.

It also sets stalled-download-timeout = 60 in the same file (#676). nix's default is 300 seconds, and when several downloads from cache.nixos.org stalled at once they cost one omarchy run about fifteen minutes and then its timeout. Not lower than 60, because a cache miss on a large file can take tens of seconds to start sending, which is slow rather than stalled.

On self-hosted runners nix is already installed, so the action skips and writes nothing — those hosts need the keys, and the timeout, in their own NixOS config.


Reading a failure

cancelled means three different things

Distinguishable only by timings:

what you see how to tell what it is
ran 75–92 min long duration a timeout-minutes kill — GitHub does not say timed_out
started, then died has a runner_name the per-ref cancel; on a PR this fires when main moves
~90 s, no runner runner_name empty concurrency-group eviction — re-run it

A cancelled required check silently disables auto-merge: it never turns green and never will, so the PR sits open with no failure to investigate.

A green branch is a claim about the main it was tested against

Two PRs, each green against a main that lacked the other, broke main (#137 + #141) — one moved a file, the other read it from the old place. No per-PR check can catch that. Rebase before merging.

Getting the log

gh run view <id> --log-failed
gh api repos/{owner}/{repo}/actions/jobs/<job-id>/logs --allow-escape-sequences > log.txt

The second works while other jobs in the run are still going, when gh run view --log refuses.


Where the work is tracked

  • The board — public
  • Milestones — one per feature
  • README's Roadmap — the shape, CI-enforced

AGENTS.md §12 has the filing rules.

File the Roadmap row before the epic, not after

The roadmap check reads the live issue list, not the diff. So an epic that exists without a Roadmap row turns main red on whatever commit pushes next — someone else's merge, with nothing in it about your epic and nothing its author can do.

It does not run on pull requests, deliberately: closing an epic correctly once turned every open pull request red at the same time, none of which had touched the README. So it runs on pushes to main and weekly, and the red always lands somewhere other than the change that caused it.

The order that avoids it:

  1. open the pull request that adds the Roadmap row
  2. let it merge
  3. then file the epic

Doing it the other way round costs a red main and a re-run. Getting the row merged first is not always possible — if another pull request is already armed with auto-merge it will land ahead of yours — in which case the red is expected, harmless, and clears on a re-run once the row exists. No new commit is needed, because the check reads live state.

Retiring a finished epic: close the issue before the row leaves

The mirror of the above, and the direction that comes up once an epic's children have all shipped. The guard fails both ways, so there is no state where a half-done retirement passes:

epic Roadmap row guard
open present pass
open absent fails — "open and not in the README's Roadmap"
closed present fails — "closed but the Roadmap still lists it as planned"
closed absent pass

Every single-step transition crosses a failing state. The order that avoids the red:

  1. open the pull request that moves the row into Recently finished prose
  2. close the epic — closing an issue pushes nothing, so nothing evaluates
  3. merge

The row must not leave main while the epic is open, so do not merge before closing. And as above, a red here is recoverable: the check reads live state, so once the epic is closed and the row is gone, a re-run is green without a new commit.

The prose under Recently finished is invisible to the guard — it matches only table rows (^| [#). That is deliberate, and the comment in build.yml records why: reading every link in the section flagged the finished one as a lie the first time it ran.

A milestone whose work is done is closed

The same argument one level out. Milestones answer "how far has each feature got", and a milestone whose issues are all closed answers it wrongly. Nobody had ever closed one — six features were complete and still open, some for weeks — because nothing asked. The check names each one rather than counting them, and exempts Keeping the lights on, which sits at zero open issues whenever the queue is briefly clear and has no end by design (AGENTS.md §12).