Skip to content

ci: run zig build headless, then smoke-test what it installed - #72

Merged
Parth merged 11 commits into
masterfrom
claude/dotfiles-workflow-simplify-tttwk0
Aug 24, 2026
Merged

ci: run zig build headless, then smoke-test what it installed#72
Parth merged 11 commits into
masterfrom
claude/dotfiles-workflow-simplify-tttwk0

Conversation

@Parth

@Parth Parth commented Aug 24, 2026

Copy link
Copy Markdown
Owner

The workflows named packages and asserted on paths, so every new package needed a matrix row and every moved file needed a workflow edit. They now do what a person does on a new machine — bootstrap, zig build headless, check the result — and the checking lives in zig/smoke.zig, which imports the package lists instead of repeating them.

67 + 27 jobs → 5 + 6, and every remaining square has to be green.

The bug that was hiding everything else

login_shell.zig called std.c.getpwuid from build.zig. The build runner is not linked against libc on Linux, so this was a compile error for the entire build graph:

std/c.zig:10326:12: error: dependency on libc must be explicitly specified in the build command
pub extern "c" fn getpwuid(uid: uid_t) ?*passwd;
referenced by: currentShell: zig/packages/login_shell.zig:41:21

Every zig build <anything> on every Linux runner and all 27 containers died there before doing any work — zig build ripgrep included, since build.zig imports every package at comptime. macOS links libc unconditionally, which is why it was the only platform where anything passed, and why the matrix read as "macOS green, Linux red" rather than "one function is wrong". $SHELL answers the same question without libc.

login-shell stayed in headless, and now runs unattended

chsh asks PAM for a password only when run as an ordinary user. It reaches chsh as root either way now — via sudo, which was already required to append to /etc/shells, or directly in containers where there is nothing to elevate and often no sudo binary. That choice is made at run time by id -u. The /etc/shells check moved to run time too and then disappeared: duplicates are harmless, and the append is not a precondition for anything, since root chsh ignores the file entirely.

zig/smoke.zig

zig build smoke runs eight tests against $DOTFILES_PREFIX, importing config.links, nvim_plugins.plugins and treesitter.grammars from the packages, so adding a grammar or plugin extends the tests for free:

  • every installed binary runsnvim, rg, zls, fish, cargo, lua-language-server — and rust-analyzer is in the toolchain
  • every config link is a symlink and resolves — a dangling link is what a test -d misses
  • every plugin is unpacked, including libfzf.so (telescope silently degrades without it)
  • every grammar is compiled and has queries installed
  • neovim starts on the real config with nothing matching E\d+: in :messages
  • neovim can actually vim.treesitter.language.add each grammar — compiled and loadable differ
  • fish sources the shipped conf.d

Each command has a 60s timeout, since a language server given arguments it dislikes waits on stdin forever.

It is deliberately not a dependency of headless, so it can re-check an existing install without rebuilding.

Platforms dropped

Both were red for upstream reasons, and both are gone rather than carried as permanently-failing squares:

  • ubuntu-24.04-arm — neovim's nlua0 build helper dies on aarch64 because lua_newstate() returns null for the LuaJIT it is built against. Filed upstream as neovim/neovim#40149, open since June with no maintainer response. Worth knowing: the same build succeeds when cross-compiled, because build.zig:97 (host_use_luajit = if (cross_compiling) false else use_luajit) then backs nlua0 with PUC Lua instead. So -Dhost=native is a plausible untested workaround if this ever matters.
  • alpine:3.22 headless — everything builds and 7 of 8 smoke tests pass; lua-language-server is the only dynamically linked binary we install and upstream ships no musl build, so it cannot spawn. alpine still appears under no-system-compiler, which passes.

Smaller findings

  • Every commit built the matrix twiceon: push and on: pull_request were both unfiltered. push is now limited to master.
  • Failures were unreadable. --summary all prints a 90-line green tree while the failing step's output streams inline thousands of lines earlier; zig keeps building independent steps after a failure, so the end-of-run summary is the only unambiguous place. Switched to --summary failures, which is how both platform bugs above were finally identified. smoke keeps --summary all because it is four lines and turns a silent pass into a test tally.
  • Four bare squares were red by design — debian/ubuntu ship no curl and no xz, rocky no xz. One green job now runs all seven images in ~47s and writes the answer to the run summary. Its old error-extraction had a latent bug: under bash -e -o pipefail an unrecognised failure killed the step instead of reaching the see logs fallback.
  • libuv needs linux/errqueue.h, which alpine does not ship — that was the alpine build failure, not musl incompatibility, and linux-headers fixed it.
  • timeout-minutes: 60, so a hung step fails instead of running to GitHub's six-hour default.

Not done, on purpose

  • Windows is dropped. bootstrap.sh builds a .tar.xz URL unconditionally and zig ships .zip for Windows; the old workflow carried two permanently-failing rows for it.
  • no-system-compiler still names a package (zig build treesitter). The one deliberate exception to "don't reach in": it asserts no cc/gcc/clang/make/pkg-config exists and compiles C anyway, which is the load-bearing claim of the repo.
  • neovim and fish are non-lazy dependencies, so zig build ripgrep still clones all of neovim first. Making them lazy would fix it, but it interacts with the memoisation in fish.zig and is not a CI problem.

Parth and others added 7 commits August 24, 2026 16:11
The workflows named packages and asserted on paths, which meant every
new package needed a matrix row and every moved file needed a workflow
edit. They now do what a person does on a new machine -- bootstrap,
`zig build headless`, check the result -- and the checking moved into
zig/smoke.zig, where it can import the package lists instead of
repeating them.

The build had to change to make that honest:

  - login_shell.zig called std.c.getpwuid from build.zig. On linux the
    build runner is not linked against libc, so this was a compile
    error for the entire build graph: every `zig build <anything>` on
    every linux runner and every container failed identically before
    doing any work. macOS links libc unconditionally, which is why it
    was the only platform where anything passed. $SHELL answers the
    same question without libc.

  - login-shell is no longer part of headless. chsh wants a PAM
    password and appends to /etc/shells, so a group that claims to
    install unattended cannot contain it. It is its own `interactive`
    group; `zig build` still runs everything.

zig/smoke.zig asserts against the lists in config.zig,
nvim_plugins.zig and treesitter.zig, so adding a grammar or a plugin
extends the tests for free. It checks that the binaries run, that
every config link resolves, that neovim starts on the real config with
nothing in :messages, that every grammar actually loads, and that fish
sources the shipped conf.d.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U73fQ6rCbAPPf1YRHga95Q
Four of the seven images do not ship curl or xz, so four of the seven
squares were red by design. A matrix that is expected to be part red is
a matrix nobody reads. One job now runs all seven and writes the answer
to the step summary, always green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U73fQ6rCbAPPf1YRHga95Q
A passing `zig build` prints nothing, so a green smoke step was
indistinguishable from one that did no work; --summary all makes the
test count visible. timeout-minutes stops a hung step from running
until GitHub's six hour default.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U73fQ6rCbAPPf1YRHga95Q
--summary all prints a ninety line green tree and the failing step's
own output is interleaved thousands of lines above it, so finding out
what broke meant scrolling past every parser that compiled fine.
--summary failures prints only the subtree that failed. The 'what
landed' find dump went with it: it added sixty lines of
lua-language-server paths between the reader and the error, and the
smoke tests report what is missing far more precisely.

Also corrects the note on the alpine row. zls and lua-ls install fine
on musl -- they are tarball extractions. 58 of 59 steps pass there and
neovim's own build is the one that does not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U73fQ6rCbAPPf1YRHga95Q
Both were failing in nvim and nowhere else, but for unrelated reasons,
and only one of them is ours:

  alpine: libuv wants linux/errqueue.h and alpine ships no kernel
  headers by default. linux-headers joins the setup line -- that is
  the documented floor growing, which is what the job is for. The row
  stays experimental because zls and lua-language-server are
  glibc-only, so the smoke tests should now be the next thing to fail.

  ubuntu-24.04-arm: nlua0, the bootstrap lua neovim builds to generate
  its own headers, segfaults on aarch64 linux. Every invocation dies
  with SIGSEGV in gen_declarations.lua. Nothing in this repo is on
  that stack, so the row is experimental rather than fixed, and it is
  kept rather than deleted so it goes green on its own.

Both were invisible before: with --summary all the error was
interleaved thousands of lines above the summary.

Also limits the push trigger to master. With push and pull_request
both unfiltered, every commit on a branch with a PR open built the
entire matrix twice.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U73fQ6rCbAPPf1YRHga95Q
alpine passing 8/8 after the linux-headers fix made the gap obvious:
every other binary here is a static zig or rust build, so "the file
exists" and "the file runs on this libc" are the same claim for them
and different claims for lua-language-server, which is the one
dynamically linked thing we install. It was the only check that stopped
at access(), which is precisely where it mattered least.

Running language servers means something can wait on stdin forever if
it does not like its arguments, so expectRun now carries a 60s timeout.
Verified it fires: a 60s sleep comes back as error.Timeout in 2.6s
rather than hanging until the runner gives up.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U73fQ6rCbAPPf1YRHga95Q
7 of 8 smoke tests pass on alpine. Everything builds and everything
runs except lua-language-server, which fails to spawn:

  could not run .../bin/lua-language-server: FileNotFound

A glibc ELF on musl -- execve cannot find its interpreter. It is the
only dynamically linked thing we install, and it is the check that
until this morning stopped at access(), which is why nobody had
noticed either way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U73fQ6rCbAPPf1YRHga95Q
@Parth
Parth marked this pull request as ready for review August 24, 2026 17:01
claude added 4 commits August 24, 2026 18:14
Reverts the split into an 'interactive' group. login-shell belongs in
headless; what did not belong was a step that could only run with a
human at the keyboard.

chsh as an ordinary user asks PAM for a password, which needs a
terminal. Under sudo it does not ask, and sudo was already required
here to append to /etc/shells, so this asks for nothing it was not
already asking for. As root -- which is every container -- there is
nothing to elevate and often no sudo to elevate with, so the choice is
made at run time by id -u rather than baked in.

The /etc/shells check moved from configure time to run time along with
it, which also drops isRegistered: grep -qxF answers the same question
at the moment it matters instead of one build phase earlier.

Verified the script against stubbed chsh/sudo: as root it skips sudo,
as a normal user it uses it, a second run does not duplicate the
/etc/shells line, and a shell that does not execute fails the step
instead of chsh-ing you into a broken login.

Containers need chsh to exist for this, which is the documented floor
growing again: passwd on debian, util-linux-user on fedora and rocky,
shadow on alpine.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U73fQ6rCbAPPf1YRHga95Q
Drops the grep. Checked what the guard was buying and it was buying
nothing:

  - getusershell(), which is what every consumer of /etc/shells goes
    through, just enumerates the file. Five duplicate lines iterate as
    five entries with no error, and chsh exits 0 against that file.
    Callers ask 'is this shell in here', which a repeated line answers
    the same way a single one does.

  - the append is not what makes the next line work anyway. chsh only
    consults /etc/shells for non-root callers, and this one is always
    root: setting a shell that appears nowhere in the file succeeds.
    It is there so the file stays honest about which shells are login
    shells, not as a precondition.

So the worst case is a couple of duplicate lines during the window
before $SHELL flips and the step stops running at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U73fQ6rCbAPPf1YRHga95Q
Same job, more lines, no cleverness. The two moving parts are now named
functions -- bootstrap_in and missing_commands -- the image list is one
per line, and the markdown table is assembled once at the end instead of
being appended to from inside the loop.

Fixes a latent bug while it is in there. The old form was

  note="$(printf ... | grep ... | sort -u | paste ...)"
  note="needs: ${note:-see logs}"

and that fallback could never fire: GitHub runs the step under
bash -e -o pipefail, so an image that failed for a reason the grep has
no pattern for made the assignment exit 1 and took the whole step with
it. Verified both halves -- the old form exits 1 without printing the
fallback, the new one reports 'unclear, read the log' and carries on.

The output is also plainer, because the command names are now cut out
of the matches rather than shown raw:

  before   needs: wget: not found xz: Cannot exec
  after    missing: wget xz

Tested against fixtures reproducing each real image's output, plus an
unrecognised failure.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U73fQ6rCbAPPf1YRHga95Q
ubuntu-24.04-arm and the alpine headless row were both red on purpose
and both blocked on upstream bugs rather than anything here:

  aarch64: neovim's nlua0 build helper dies because lua_newstate()
  returns null for the LuaJIT it is built against. Reported upstream as
  neovim/neovim#40149, open since June with no response.

  alpine: lua-language-server is the one dynamically linked binary we
  install and upstream publishes no musl build, so it cannot spawn.

Removing them takes continue-on-error and the experimental flag with
them, so every remaining square is one that has to be green. alpine
still appears under no-system-compiler, which passes.

Also cuts the comments back to the ones that answer 'why is this
written this way' rather than restating the code.
@Parth
Parth merged commit 1dd8e17 into master Aug 24, 2026
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants