-
Notifications
You must be signed in to change notification settings - Fork 5
Workflows
Generated from
docs/internals/in the main repository. Edits made here are overwritten on the next push tomain.
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 runSource of truth is
docs/internals/in the main repository. The wiki copy is generated by.github/workflows/wiki.yml.
| 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.
Trigger: every push to main and every pull request.
Jobs: 7, most gated behind the first.
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? |
yes — build.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.
-
Check formatting —
nix fmt -- --ci -
statix — repeated keys, useless parens; things
nix fmtis happy with - deadnix — unused bindings
-
The workflow files are valid GitHub Actions —
actionlint
Run all four locally before pushing; statix in particular has caught the
same class of mistake repeatedly.
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 |
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.
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 start → Install onto a blank disk, and into free space beside a neighbour → Push the proof, so nobody has to prove it twice → Report 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).
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.
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-evalproves 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 anundefined variable— the cheapest class of failure there is — and nothing looked for months. -
A cheap check must say what it does not prove.
stable-evalprints 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.
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.
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.
One job, bump, and it is the most elaborate workflow here because a bump can
break anything.
- Find the newest Omarchy release — stops if it is the one we vendor
- Bump the input and build against the new Omarchy
- What packages this release adds and drops — diffs upstream's package lists
- What upstream says this release changed — their release notes
- What this release changes in the tree seeded into
~/.config - Which files this port patches upstream touched — the dangerous one: a patch whose target moved
- Check nothing became unmapped or unwired — menu mapping and the bin ledger against the candidate release
- Turn what it found into a decision list
- Boot a session and check the desktop renders
-
Apply the README's derived numbers — runs
readme-counts.sh --fix, so a release moves the numbers instead of failing on them - 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.
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.
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, locallyWhat the apps are on today → Move the pin → What the apps would be on →
What actually moved → Both 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.
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-2Steps, in order, and the first three are refusals:
- The tag is not behind main
- The clone is not shallow, or the notes cannot be derived
-
The tag names the Omarchy the flake vendors —
v4.0.3-2must vendor 4.0.3 - Build the ISO and Build the network ISO
- Split it into pieces GitHub will accept — the offline image exceeds the 2 GiB asset cap
-
Write the release notes — derived, via
nix run .#release-notes - Publish
-
The release attached everything it promised — compares what was built against what
SHA256SUMSnames -
Move the release branch to this tag — this is what
nixarchy updatefollows, so cutting a tag is what delivers updates - Remove the split copies
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.
Installs the agent-bus MCP server for Copilot agents and proves it starts and serves the tools its allowlist names.
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
cancelledrun. 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.
.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.
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.
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.
gh run view <id> --log-failed
gh api repos/{owner}/{repo}/actions/jobs/<job-id>/logs --allow-escape-sequences > log.txtThe second works while other jobs in the run are still going, when
gh run view --log refuses.
- The board — public
- Milestones — one per feature
- README's Roadmap — the shape, CI-enforced
AGENTS.md §12 has the filing rules.
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:
- open the pull request that adds the Roadmap row
- let it merge
- 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.
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:
- open the pull request that moves the row into Recently finished prose
- close the epic — closing an issue pushes nothing, so nothing evaluates
- 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.
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).