Skip to content

Development Setup

Ken Tobias edited this page Sep 16, 2026 · 28 revisions

Development Setup

To build and contribute to retch, ensure you have the following prerequisites installed on your system.

Prerequisites

  • Rust (latest stable toolchain)
  • just (command runner for checking format, running tests, and compiling documentation)
  • mandown ≥ 1.1.1 (required for generating the manual page via just mancargo install mandown)
    • The version matters, because just pr compares your regenerated page against the committed one. mandown 1.1.1 stopped emitting .Bl/.El around list items; those are mdoc macros and are undefined in man(7), so groff warned twice on every page that carried them. An older mandown re-adds them, so just man produces a ~164-line diff and the gate fails — which reads as "my change broke the man page" and is not. Run cargo install mandown to update, then regenerate. The rendered output is identical either way; only the warnings differ.
  • hyperfine (optional, required for local CLI execution speed benchmarking)

Nix / NixOS Setup

If you use Nix, all prerequisites are provided automatically via the flake devShell:

git clone https://github.com/l1a/retch.git
cd retch
nix develop

This drops you into a shell with cargo, rustc, rust-analyzer, just, mandown, hyperfine, and python3 available. Skip to Common Just Recipes once inside.

Local Setup

  1. Clone the Repository:
    git clone https://github.com/l1a/retch.git
    cd retch
  2. Run one-time repo setup (installs git hooks):
    just setup
    This installs the post-merge hook that automatically uploads local benchmark results to the dashboard whenever you merge into main. Only needs to be run once per clone.
  3. Build the Project:
    cargo build --release

Common Just Recipes

Recipe Description
just dev Full development cycle: setup → fmt → lint → test → build
just check Strict fmt + clippy checks (same as CI)
just fmt Auto-format code with cargo fmt
just lint Run clippy with -D warnings
just test Run all tests
just build Build release binary
just clean Remove build artifacts
just audit Run cargo audit for known security advisories
just man Regenerate docs/retch.1 from docs/retch.1.md via mandown
just install Install from this checkout: binary + man page + completions
just install-tag VERSION Install a released tag — binary, completions and man page all from that tag
just install-man Install the man page to the XDG man directory
just install-completions Generate and install completions for six shells
just standard-check Verify the vendored install helpers still meet the shared cross-repo standard, and refuse an @ inside a shebang recipe
just install-hooks Install git hooks (included in just setup)
just publish-check Verify packaging and dry-run publish for crates.io
just publish Publish both crates to crates.io (sysinfo first, then CLI); refuses unless HEAD is the tag
just render-check Verify scripts/render_packaging.py still renders and still refuses (offline; run by just check)
just aur-check Verify the AUR PKGBUILD is still a template recording no version (offline; run by just check)
just aur-render VERSION DIR Render the PKGBUILD + generate .SRCINFO for a released tag into DIR (needs podman)
just aur-srcinfo DIR Generate .SRCINFO beside a rendered PKGBUILD in DIR (needs podman)
just aur-publish VERSION Render for a released tag and push to the AUR (asks before publishing)
just aur-local [VERSION] Render and makepkg -si locally, without the AUR (Arch only)
just copr-check Verify the COPR spec is still a template recording no version (offline; run by just check)
just copr-render Print the spec as .copr/Makefile will render it (offline)
just brew-check Verify the Homebrew formula is still a template recording no version (offline; run by just check)
just brew-publish VERSION Render for a released tag and push to the tap at l1a/homebrew-retch (asks first)
just metadata-check Verify every channel's description and licence agrees with packaging/metadata.toml (offline; run by just check)
just text-check Refuse control characters and carriage returns in tracked text (offline; run by just check)
just wip-check Prove update_wip.py round-trips WIP.md's CRLF line endings (offline; run by just check)
just github-metadata [--dry-run] Set the GitHub repo description and topics from packaging/metadata.toml, then read them back (release step 5)
just pr Automated Pre-PR gate — see below
just open-pr Runs just pr, then gh pr create if it passes — the only sanctioned way to open a PR
just merge-pr Squash-merge, switch to main, reset WIP.md — see below

just text-check — control characters and carriage returns

Added in v0.17.10. It reads the bytes of every tracked text file and refuses any C0 control character except tab and newline, plus DEL.

Two defect classes, both of which this repo had already shipped:

  • A lone control byte from a collapsed backslash. A double backslash in a command handed to an agent's shell arrives as a single one, so usr\bin becomes usr + 0x08. templates/ justfile-common.just carried exactly that, and crates/sysinfo/src/win_setupapi.rs carried a worse one: the Win32 device path \\?\acpi#… had collapsed twice on the same line, into \? + BEL + cpi#…. That one sits in a /// comment on a pub fn, so it reached the published rustdoc.

  • A carriage return. .gitattributes pins * text=auto eol=lf, and with that attribute set git normalises a CRLF worktree copy out of its own view rather than reporting it. Measured on git 2.55.0:

    asked answer
    git status --short M <file>
    git diff nothing — no hunk, no name, only a stderr warning
    git add <file>, then git status clean, with every CR still on disk

    So the drift shows exactly once, as an M with no diff behind it, and the first git add erases the only signal while leaving every byte in place. git ls-files --eol is the unambiguous oracle (look for i/lf w/crlf).

    v0.17.10 stated this as "git status structurally cannot report it", which is wrong; the real mechanism is narrower and worse. Corrected in v0.17.12.

WIP.md is gitignored, so the guard never sees it — which is correct, because its CRLF is deliberate and recorded in its own START HERE block. just wip-check is what protects it instead, and it exists because just merge-pr was destroying it — see below.

It counts bytes rather than shelling out to grep: grep -c $'\r' from an agent shell returns the file's line count, not its carriage-return count.

@ inside a shebang recipe — refused since template v5

In a plain recipe a leading @ tells just not to echo the line, and just strips it. In a shebang recipe just strips nothing: the body goes to the interpreter verbatim, so the @ reaches the shell, which looks for a command literally named @/usr/bin/python3 and exits 127.

merge-pr: line 847: @/usr/bin/python3: No such file or directory
error: recipe `merge-pr` failed with exit code 127

scripts/gate_conformance.py refuses that shape in any recipe, not just the pr/open-pr/ merge-pr triad — it is a fact about a recipe's shape, not about the gate's behaviour, so it can land anywhere.

It exists because the trap was paid for twice: rusticprofile 0.2.2, and retch v0.17.13 in a session where that write-up had already been read. Pointed at those two historical commits the guard names all four sites — rusticprofile's pr, merge-pr and aur-publish, and retch's merge-pr — and is clean on both repos' fixed commits.

Heredocs are skipped. A cat <<'MSG' block is data, so a line of it starting with @ is a literal @ and not a command. Without that, the check fires on correct code — and a guard that fires on correct code gets deleted, taking the real rule with it. The self-test pins all four cases: fires on the defect, silent on a plain recipe, silent inside a heredoc, and still fires on a defect after a heredoc.

Two version numbers, deliberately. The block marker (# >>> COMMON (template v5)) and each helper's own TEMPLATE_VERSION move independently, because a helper can change without the block changing.

just wip-check — why merge-pr was corrupting WIP.md

scripts/update_wip.py rewrites WIP.md on every just merge-pr. Until v0.17.12 it read with read_text() and wrote with write_text(), both of which apply universal newlines — so \r\n became \n in memory and was written back as whatever the platform prefers.

The bug was platform-dependent, which is why it survived. On Windows the round trip is invisible, because write_text re-translates on the way out; this repo's merges were historically cut from a Windows host. The first merge run from Linux converted the entire file: 0 CRLF, 4824 lone LF, measured straight afterwards.

The fix reads bytes, picks the terminator by majority, normalises to \n for the substitutions and re-applies it on write. A naive "just read bytes" fix would still have corrupted two lines: both substitutions use .*, and in Python's re a dot matches \r — it excludes only \n — so on un-normalised CRLF text the replacement line comes back LF-terminated.

just wip-check runs the script's --self-test, which was watched failing against the exact pre-fix code. It is a self-test and nothing more, deliberately: WIP.md is per-machine and untracked, so the guard has to be about the code that rewrites it, not the file's current state.

The AUR recipes, and why packaging/aur is a template

packaging/aur/PKGBUILD is a template, not a copy of the published package and not a publishable file. Its pkgver is @VERSION@ and its sha256sums is ('@SHA256@'); scripts/render_packaging.py fills both in at publish time, from the tag being released and the sha256 of the tarball it downloads. .SRCINFO is not tracked at all — it is generated from the rendered PKGBUILD.

That shape is the answer to two separate failures.

First, drift. The file used to be a "reference copy" that nothing rendered, published or checked, and it reached eleven releases of lag (0.6.12 in-repo against 0.6.23 published). Because the copy was inert, the live AUR PKGBUILD kept two man-page defects long after they were fixed in the repo — every Arch install got a page footed $DATE / retch $pkgver. v0.7.1 made it a real source with a guard; v0.9.10 and v0.17.2 did the same for the COPR spec and the Homebrew formula. Three guards, each comparing four recordings of one fact.

Second, and this is what v0.17.5 fixed: recording the fact forced a version bump on every release. A checksum cannot be computed before its tag exists, so the bump had to be a post-tag commit; that commit had to be a reviewed PR; and just pr refuses a Cargo.toml equal to the last tag, so the PR had to open the next version too. main was therefore always naming a version that had never been released — which is how just publish on main came one command from putting an unreleased retch-cli 0.17.4 on crates.io.

Writing the fact down once, at publish time removes both: there is no copy to drift, and no commit to bump.

Releasing to the AUR

The tag must already exist, because the checksum comes from the release tarball:

just aur-publish 0.17.5     # downloads the tarball, computes its sha256, renders the
                            # PKGBUILD, generates .SRCINFO, then pushes (asks first)

Nothing is committed afterwards. just aur-publish refuses before touching the AUR if the rendered pair disagrees, if a sentinel survived the render, or if the AUR is in a maintenance window. It answers its confirmation from AUR_CONFIRM, an interactive terminal, or piped input — so AUR_CONFIRM=n just aur-publish 0.17.5 is a safe way to exercise every preflight check without publishing anything.

just brew-publish confirms the same three ways, from BREW_CONFIRM. Since v0.17.10 all three confirm variables — PR_CONFIRM, AUR_CONFIRM and BREW_CONFIRM — accept y. Until then BREW_CONFIRM alone required the literal yes, and BREW_CONFIRM=y aborted the publish; in the sibling repo etr that happened after crates.io and the AUR had already published, leaving a partially-released version. If you ever meet that state, re-run the single leg (just brew-publish <version>) rather than the whole sequence, which would start again from crates.io.

Installing locally without the AUR

makepkg refuses @ in a pkgver, so the template cannot be handed to it directly. Render first, which one recipe does:

just aur-local              # last released tag; or: just aur-local 0.17.5

Never hand-edit .SRCINFO

It is generated by a real makepkg --printsrcinfo running in archlinux:base-devel under podman, because no development host here runs Arch. The AUR reads .SRCINFO for metadata while building from the PKGBUILD, so a pair that disagrees does not fail loudly — the AUR advertises one version and builds another, and the first person to find out is a user whose install broke. That comparison now happens inside just aur-publish, on the bytes about to be pushed, rather than against a committed copy of them.

The four packaging guards

packaging/aur/PKGBUILD, packaging/copr/retch.spec and packaging/homebrew/retch.rb are templates that record no released version, and just check asserts exactly that, via aur_check.py, copr_check.py and brew_check.py. render-check is the fourth: it runs render_packaging.py's self-test, because the renderer is the single implementation behind all three and the piece that has to refuse — a substitution that matched nothing, a sentinel that survived, a checksum that is not a real digest.

That is a smaller claim than the guards used to make, about a stronger property. They used to compare four recordings of one fact against each other and treat "trailing Cargo.toml" as normal, "ahead" as a defect. Now there is one recording, made at publish time, so the drift they hunted is unrepresentable rather than detected.

Neither check that mattered was dropped, they moved to where they can act:

  • the AUR pair comparison runs inside just aur-publish (aur_check.py --dir), on the rendered files being pushed;
  • newest %changelog entry matches Version: moved into the renderer, which writes both in one pass, so they cannot disagree.

The expensive halves run in CI: the aur job renders for the last released tag, re-derives that tarball's sha256 and builds with real makepkg; copr builds the SRPM twice (once under mock's environment); brew renders, installs from source on a macOS runner, runs brew audit --strict, and inspects the payload. Rendering is what CI now verifies — a committed checksum could only ever be wrong invisibly until someone installed from the AUR, whereas a renderer that produces a wrong one fails in CI.

What the brew job does NOT prove: it stages the formula in a brew tap-new local tap, which bypasses Homebrew 6.0's trust gate for third-party taps. So it verifies the formula builds, installs and works — not that a user can brew tap l1a/retch without first running brew trust. That requirement is a documentation matter, covered in Getting Started. A local tap is still the right thing to test, because it exercises the formula in the pull request rather than whatever is already published.

What each layer actually proves

layer proves needs
just render-check the renderer still substitutes, and still refuses nothing (pure Python)
just aur-check the PKGBUILD still records no version and no checksum nothing (pure Python)
just aur-publish the computed checksum matches the real tarball, and the rendered pair agrees network + podman
CI aur job the render is correct for the last released tag, the PKGBUILD builds, and the packaged man page is right GitHub Actions

The CI job runs behind a paths filter, so "CI was green" is not the same as "CI checked the packaging." It looks at a PR only when that PR touches something that can affect packaging: packaging/**, any *.rs, Cargo.toml/Cargo.lock, the flake, the workflow itself, or — since v0.9.6 — the Justfile, where the aur-* recipes live. That last one was a real hole rather than a theoretical one: a fix to the aur-srcinfo recipe that regenerated a byte-identical .SRCINFO (#203) touched only the Justfile, matched no filter in any workflow, and reported green having never been looked at. scripts/render_packaging.py is in the filter for the same reason, and a stronger one: scripts/*_check.py does not match it, and it writes the files that actually get published.

Note what the job does and does not do. It renders the PKGBUILD for the last released tag, re-derives that tarball's sha256, reads the rendered file back, runs makepkg -s, and inspects the packaged man page. It does not run just, aur-srcinfo or aur-check — there is no just and no podman in that container, so .SRCINFO generation is not exercised there. It proves the render and the build; the pair check belongs to aur-publish.

There is deliberately no local aur-verify recipe (rusticprofile has one) because retch's CI runs a real makepkg and asserts the packaged man page whenever a PR can affect packaging.

Releasing: no packaging commit, and no version bump

Nothing in the repository records a released version, so a release edits no files:

git tag -a vX -m '...' --cleanup=verbatim && git push origin vX
                             # CI builds the GitHub Release; copr.yml rebuilds COPR and
                             # pushes the COPR project page text
just publish                 # crates.io; refuses unless HEAD is the tag for Cargo.toml
just aur-publish X           # renders + pushes to the AUR (needs podman)
just brew-publish X          # renders + pushes to the Homebrew tap
just github-metadata         # the repo's About box, from packaging/metadata.toml

Channel text is part of each channel's publish

Every channel shows users a description and a licence. packaging/metadata.toml is the one place they are written: the one-line summary, the Homebrew desc, the SPDX licence, the GitHub description and topics, and the COPR project page's description and instructions (packaging/copr/project-*.md).

  • Text an artifact carries — the crates.io description and README, the AUR pkgdesc, the RPM's Summary/%description, the tap's desc, the Nix descriptions — ships with that channel's own publish step. just check (metadata-check, also a packaging.yml job) fails when any of those copies drifts from metadata.toml.
  • Text that lives on the service — the COPR project page and GitHub's About box — was never touched by any release step, which is how both went stale (the COPR page quoted one machine's benchmark numbers from an old release; GitHub and the flake named two of the four output modes). copr.yml now pushes the COPR page on every tag, and just github-metadata sets the About box and reads it back.

The guard proves the copies agree, not that they are true. Before tagging, read the READMEs and these files for claims that are no longer right: the v0.17.9 review found a README flag that does not exist (--ascii-only), a retch-sysinfo example on crates.io that did not compile (it is now a doctest), and a Nix derivation declaring gpl3Only for a GPL-3.0-or-later project.

main then sits at the released version until the next feature PR bumps it — which is what makes just publish on main correct, and its HEAD-is-the-tag guard is what enforces that rather than trusting it. PUBLISH_ANY_REF=1 overrides the guard deliberately.

How this used to work, because old commits refer to it. Releases up to v0.9.10 committed the packaging bump straight to main, on the documented belief that a PR was impossible because just pr hard-fails once Cargo.toml equals the last tag. That belief was wrong and survived five releases: step 2 of the gate is an equality test against the last tag, not "did this PR bump anything". v0.9.12 turned the bump into a normal gated PR (just post-release), and v0.17.5 removed the bump entirely by making the packaging record no version at all. just post-release, aur-bump, copr-bump and brew-bump no longer exist.

The COPR spec, and the compromises it names

packaging/copr/retch.spec is the third packaging target, beside packaging/aur and packaging/nixpkgs. It is written for a COPR project with "Enable internet access during builds" ON, so cargo build resolves crates.io at build time and there is no vendor tarball. Two consequences follow, and both are commented in the spec itself:

  • --locked is load-bearing. With network access and no vendoring it is the only thing pinning what gets resolved to what was actually tested. Cargo.lock is format 4 with 247 packages. Never drop it.
  • This will not build in koji. Fedora proper requires offline builds, so shipping there would need a vendor tarball as Source1 and rusqlite's bundled feature replaced with system sqlite. COPR is the endpoint; those are known, named compromises.

Version: is @VERSION@, and Source0 is the local %{name}-%{version}.tar.gz. .copr/Makefile renders the version from Cargo.toml and builds that tarball from the checkout COPR cloned, so the spec needs neither a published tag nor a network fetch for its source. It used to pin the last released tag, for the same reason the PKGBUILD's pkgver did — and that is what forced a post-tag commit, and therefore a version bump, on every release (see Releasing, above).

Building the source from the checkout has a second effect worth more than the bump it removed: a pull request can build its own SRPM. While Source0 pinned a released tag, the copr CI job could only ever build the previous release's source.

Building it locally without rpmbuild

No development host here has rpmbuild or mock (copr-cli is now installed on irulan, as of 2026-09-01), so the spec is exercised in a container, the same way .SRCINFO is generated:

podman run --rm -v "$PWD/packaging/copr:/spec:ro,z" fedora:latest bash -c '
  dnf -y install rpm-build rpmdevtools dnf-plugins-core
  rpmdev-setuptree && cp /spec/retch.spec ~/rpmbuild/SPECS/
  spectool -g -R ~/rpmbuild/SPECS/retch.spec
  dnf -y builddep ~/rpmbuild/SPECS/retch.spec
  rpmbuild -ba ~/rpmbuild/SPECS/retch.spec'

Note the mount is :z, never :Z — uppercase assigns a fresh private MCS category pair that would permanently relabel the directory, and this repo lives under a Syncthing folder whose own container then could not scan it.

Two traps that are specific to inspecting an RPM

Both cost a debugging cycle here, and both are checkers failing while the package was correct.

  • rpm compresses man pages, so the packaged path is usr/share/man/man1/retch.1.gz. A %files entry or an assertion naming the uncompressed retch.1 reports it missing exactly when it is present. The spec uses retch.1*. This is the zipman trap from the aur CI job in RPM form.
  • The fedora container image sets tsflags=nodocs, so dnf install of the built package silently skips the man page and a check against the installed path reports it missing. Read the payload with rpm2cpio instead, or install with --setopt=tsflags=.

Build flags: use Fedora's, and let rpm produce debuginfo

The spec exports RUSTFLAGS="%{build_rustflags}" — Fedora's own flags, from rust-srpm-macros:

-Copt-level=3 -Cdebuginfo=2 -Ccodegen-units=1 -Cstrip=none -Cforce-frame-pointers=yes --cap-lints=warn

That buys distro hardening and codegen policy the spec would otherwise ignore, and the -Cdebuginfo=2 -Cstrip=none half is what makes rpm's debuginfo extraction work at all. A stock Rust release build emits no DWARF, so rpm finds nothing and the build dies on an empty debuginfo package — which is why an earlier version of this spec used %global debug_package %{nil} plus a hand-written strip. Both are gone.

This does not ship a debug build, which is the natural worry and the wrong one. -Copt-level=3 is in the same flag set; rpm moves the symbols out into retch-debuginfo and retch-debugsource and strips the binary in the main package. Verified after the change: file reports the shipped /usr/bin/retch as stripped, with zero .debug_info sections, and the main package is smaller than the hand-stripped one was.

rust-packaging's %cargo_prep / %cargo_build are deliberately not used. They assume Fedora's offline, vendored-dependency workflow and write a cargo config with offline = true, which fights the network-enabled build this spec is designed around. They are also not installed on the development hosts here — only rust-srpm-macros is, so %{cargo_prep} expands to nothing and would fail silently. Take the flags, not the machinery.

The two rpmlint errors that remain are spelling false-positives on fastfetch and neofetch, which are the actual project names.

Rebuilding COPR: the trigger is the tag (and why it used to be a commit)

.github/workflows/copr.yml rebuilds the COPR package when a v* tag is pushed, and pins the build to that tag with copr-cli buildscm --commit <tag>.

A tag trigger used to be wrong, and the reasoning is worth keeping because it explains what changed. The spec pinned Version: to the last released tag, so it could only be bumped after that tag existed:

step what the spec said
tag vX pushed the previous version
release workflow runs, release published the previous version
packaging bump committed to main vX

A tag-triggered rebuild fired on the first two rows and rebuilt the version COPR already had, so the only event that meant "there is something new" was the commit on the third row.

Row three no longer exists. .copr/Makefile renders the version from Cargo.toml, so at the moment vX is pushed the tree is what vX names — the tag is the event. Two guards keep that honest:

  • the workflow refuses to build unless Cargo.toml equals the tag, so a rebuild while main is ahead of the last release cannot publish an unreleased NEVRA; and
  • buildscm --commit <tag> builds that exact committish, rather than build-package's stored main. That also closes a race the old trigger had: a merge landing in the minute after a tag push would otherwise make COPR clone a tree ahead of the release.

The paths filter is gone with the packaging commit. A push to main touching packaging/copr/** now means someone edited the template, which the PR-triggered copr job in packaging.yml already verifies by building an SRPM.

One-off setup, and why an uploaded SRPM cannot auto-rebuild

A package first built from an uploaded .src.rpm has source_type='upload' and auto_rebuild: False. There is no source for COPR to re-fetch, so no webhook and no build-package can act on it. Convert it once:

copr-cli edit-package-scm kentobias/retch --name retch \
  --clone-url https://github.com/l1a/retch.git \
  --commit main --method make_srpm --webhook-rebuild off

Two flags are easy to get wrong here, and both were wrong in the first version of this page. It is --method make_srpm, not --type--type selects the versioning tool and only accepts git or svn. And no --subdir: COPR runs .copr/Makefile from the repository root, and that Makefile refers to packaging/copr/retch.spec relative to the root, so pointing COPR at a subdirectory breaks it. Check copr-cli edit-package-scm --help rather than trusting a remembered invocation.

make_srpm runs .copr/Makefile, which fetches Source0 and builds the SRPM explicitly, rather than relying on rpkg's handling of remote sources. --webhook-rebuild off is deliberate: the rebuild is driven by GitHub Actions on a path filter, and leaving COPR's own webhook on as well would double-trigger and rebuild on every unrelated push.

COPR runs the Makefile inside MOCK, and a container does not reproduce that

This cost a failed build. .copr/Makefile originally ran rpmdev-setuptree and copied the spec into $(HOME)/rpmbuild/SPECS. That is correct in a plain container — and wrong on COPR, because mock redefines rpm's %{_topdir} to /builddir/build, so the directory never existed and the build died with cp: cannot create regular file '/builddir/rpmbuild/SPECS/': No such file or directory.

The local test had been described as "exactly what COPR runs". It was exactly the command and not the environment, and the environment held the only difference that mattered.

The Makefile now depends on neither %{_topdir} nor $(HOME) — it passes _sourcedir and _srcrpmdir explicitly with --define, so nothing can relocate them.

Test it both ways. The second run is the one with diagnostic value:

# happy path -- catches a Makefile that hardcodes /builddir/build, which would work on
# COPR and break every development box
podman run --rm -v "$PWD:/src:ro,z" fedora:latest bash -c '
  dnf -y install make rpm-build rpmdevtools
  cp -r /src /work && cd /work && mkdir -p /tmp/out
  make -f .copr/Makefile srpm outdir=/tmp/out'

# mock's environment -- BOTH variables, in a FRESH container
podman run --rm -v "$PWD:/src:ro,z" fedora:latest bash -c '
  dnf -y install make rpm-build rpmdevtools
  cp -r /src /work && cd /work && rm -rf .copr-sources
  mkdir -p /builddir /tmp/out
  export HOME=/builddir
  echo "%_topdir /builddir/build" > /builddir/.rpmmacros
  make -f .copr/Makefile srpm outdir=/tmp/out'

Corrected 2026-09-01 (v0.9.10) — the version of this recipe published here until now could not fail. It set %_topdir alone, leaving HOME=/root. Under those conditions the pre-fix Makefile passes: rpmdev-setuptree simply builds its tree wherever _topdir points, and the copy into $(HOME)/rpmbuild/SPECS still succeeds. Reproducing the real failure needs HOME=/builddir as well, because /builddir/rpmbuild/SPECS is the path the error message actually names. It also needs a fresh container: reusing one lets the happy-path run leave /root/rpmbuild behind for the second run to find, which hides the failure a second way.

Verified by reconstructing the pre-fix Makefile from commit a606bbe and watching it die with the exact production message, then confirming the current one passes under the identical condition. A regression test nobody has watched fail is not yet a regression test.

Since v0.9.10 this runs in CI, so it is no longer something to remember to do by hand — see the layers table below.

The template guard: just copr-check and the copr CI job

Version: used to be bumped by a human, at release time, in a separate commit, and until v0.9.10 nothing checked the result — the exact construct, and the exact missing guard, that let packaging/aur/PKGBUILD sit eleven releases stale while CI stayed green. Since v0.17.5 there is no recorded version to check, so scripts/copr_check.py asserts the property that makes drift impossible instead. All offline:

  1. Version: is exactly @VERSION@ — an equality test, not "contains a sentinel": a spec carrying Version: 0.17.3 and a @VERSION@ in a comment would pass the weaker one.
  2. No other preamble tag records a version number. Prose in %description is ignored, which is the negative case that keeps the check usable.
  3. Source0 is the local %{name}-%{version}.tar.gz. A URL would put the dependency on a published tag back.
  4. cargo build still passes --locked. With internet-enabled COPR builds and no vendor tarball it is the only thing pinning resolution; before this it was a comment saying "Never drop it", which is not a guard.
  5. %files still carries %license LICENSE NOTICE. %doc can be stripped; %license cannot, and NOTICE holds the MIT attribution for the adapted Fastfetch logos.
  6. The newest %changelog entry is real history, not a sentinel — the renderer prepends the entry for the version it renders, so a sentinel here would yield two entries for one version.

The old "newest %changelog entry matches Version:-Release:" check was not dropped: it moved into render_packaging.py, which writes both in one pass, so they cannot disagree.

It parses the spec rather than calling rpmspec, for two reasons: rpmspec does not exist on Windows or macOS, where just check is expected to run, and it expands macros — which would make a hardcoded Source0 and a %{version} one indistinguishable.

What each layer actually proves (COPR)

layer proves needs
just copr-check the spec still records no version and builds from the checkout nothing (pure Python)
just copr-render what the rendered spec actually says nothing (pure Python)
CI copr job the SRPM builds from the PR's own source, in a plain environment and under mock's GitHub Actions
COPR itself the RPM builds, %check passes, on 4 chroots a v* tag

The CI job deliberately builds the SRPM, not the RPM: COPR already does the full ~6-minute build on every release, so what CI adds is a signal before merge, which is the thing COPR cannot give. Since Source0 became a local archive it builds the pull request's own source rather than the previous release's.

.copr/** is in packaging.yml's pull_request paths filter as of v0.9.10, and its absence was a real hole. copr.yml watches .copr/**, but only on push to main — so a PR touching only .copr/Makefile was verified by nothing at all. That is how the v0.9.9 bug described above reached main and failed on COPR rather than in CI. It is the same hole v0.9.6 closed for the Justfile, one directory over.

Install paths: use the macros, and check the convention against the population

The spec installs to %{bash_completions_dir}, %{zsh_completions_dir} and %{fish_completions_dir} rather than hardcoded paths, so a future Fedora relocation is followed automatically. All three directories are owned by filesystem.

Naming is where a small sample misleads. ripgrep, fd-find and bat all install <cmd>.bash, which makes a bare retch look wrong. Across the whole directory it is the reverse: 1382 completion files, only 10 use a .bash suffixgit, dnf, systemctl, ssh, flatpak and chezmoi all use the bare command name, and bash-completion's loader accepts either form. All 69 files in zsh/site-functions are _-prefixed, and fish uses <cmd>.fish. Check the population, not three packages.

Optional runtime dependencies

Every external binary retch probes is optional — a missing one makes the field absent, never an error — so none is a hard Requires. They are split between Recommends (light: chafa, curl, iproute, dmidecode) and Suggests (the desktop stack), because dnf installs Recommends by default and dragging NetworkManager, bluez and xrandr onto a server install would be obnoxious. Establish these with dnf repoquery, not from memory: xrandr is its own package rather than part of xorg-x11-server-utils, wireless-tools no longer exists in Fedora 44 (so iwgetid is not listed), and zpool is not packaged in Fedora at all.

The install recipes, and the shared standard

just install installs three things, and the dependency chain is the point: cargo install on its own replaces only the binary, leaving the man page and completions at whatever version last ran their recipe. A machine in this project's fleet was found running a current binary beside a man page eleven releases old, with nothing reporting it.

Use just install-tag 0.6.20 to install a release. It makes all three artefacts agree, which a hand-typed cargo install --git --tag does not:

artefact source
binary cargo install --git --tag — never --path
completions the installed binary, so they cannot disagree with its CLI
man page the tag itself (git show v<version>:docs/retch.1), not your working tree

It deliberately does not depend on install-man/install-completions, which work from the checkout — reusing them would pair a tag's binary with the worktree's man page.

Where completions actually go, and why two of these are non-obvious

scripts/install_completions.py writes six shells' completions to XDG locations, with two exceptions that are measured rather than assumed:

  • nushell on Windows reads only %APPDATA%\nushell\autoload -- one entry -- and never ~/.config/nushell/autoload, whatever XDG_CONFIG_HOME says. Until v0.6.20 the helper wrote to the XDG path, printed the path it had written, and delivered nothing.
  • PowerShell on Windows loads $PROFILE, which lives under Documents\PowerShell and can be moved by OneDrive folder redirection. The helper still writes the XDG path but now reports it as NOT ACTIVE there rather than implying it will be read.

zsh is checked, not claimed. zsh reads completion functions only from directories on fpath, and ~/.local/share/zsh/site-functions is not on it by default on any distribution — so the helper asks an interactive zsh and, if the directory is absent, prints the fpath+=(…) line to add. A non-interactive zsh sources neither .zshrc nor anything it includes, so asking one returns the built-in default and gives the wrong answer just as confidently.

It takes a command NAME, never a path (v0.9.4). Passing a path made the helper write the completion script over the binary it was asked to read. The output path is directory / pattern.format(bin=binary), and Path("/dest") / "/abs/path" discards the left operand -- so an absolute argument relocated every write onto the path itself. Worse, --from-path runs [binary], so a path worked for the read and only broke the write: generation succeeded and then destroyed its own input, exit 0, nothing printed. It is now refused before any file is written. Use just install-completions, or python3 scripts/install_completions.py retch --from-path.

The cross-repo standard

scripts/install_completions.py, scripts/install_man.py and templates/justfile-common.just are meant to be vendored byte-identically in retch, rusticprofile and etr. The Justfile carries the common recipes between # >>> COMMON (template v5) and # <<< COMMON, with project facts (BINS, MAN_PAGES) in a PROJECT header above.

Python rather than shell recipes is this repo's contribution to the standard (v0.6.16): it needs no sh, no cygpath, no coreutils and nothing from Git's usr\bin on Windows.

"Byte-identically" is the goal, and v4 exists because it was not true. All three repos declared template v3 while standard-check's body differed — retch ran four plain @"{{PY}}" lines, the other two ran a #!/usr/bin/env bash recipe with an explicit PYTHON-NOT-FOUND guard. Each repo agreed with itself, so nothing looked wrong from inside any of them, and the version marker could not tell them apart.

v4 settled it in retch's favour, and the majority lost. What decides it is which recipes each repo's check actually depends on:

repo check dependencies that are shell-free
retch 7 of 7
etr 2 of 6
rusticprofile 2 of 4

retch is the only one where just check still runs on a default Windows PATH, which is exactly what v0.6.16 bought. So standard-check is four plain lines and has no shebang, and the guard is not missed: the sentinel is the literal string PYTHON-NOT-FOUND, so the unguarded failure reads PYTHON-NOT-FOUND: command not found and already names the problem.

The rule is about the gate, not the whole block: install-tag inside the markers is a bash shebang recipe and stays one, because nothing runs it from check.

just standard-check runs the helpers' own --self-test, and just check depends on it. It is not a text diff — three separate repositories cannot diff each other's files, and a diff would pass happily on a repo that never adopted the standard. The self-tests assert the invariants directly, including the Windows nushell path that was wrong here for months.

Editing inside the markers means changing the standard: edit the template and the helpers, bump their versions, and propagate to the sibling repos in their own PRs.

Opening a Pull Request

Never call gh pr create directly. Always use:

just open-pr

(any args are forwarded to gh pr create). This runs the automated Pre-PR gate (just pr) first and only calls gh pr create if it passes. gh has no hook mechanism of its own, so this Justfile recipe is the one enforcement point that works regardless of which tool — human shell, Claude Code, or any other agent — is driving.

just pr (run on its own before each subsequent push to an open PR, not just once) checks you're on a feature branch (not main), that Cargo.toml's version has been bumped past the last tag, that NOTES.md's "Current State" header matches, regenerates and diffs the man page, verifies Cargo.lock is committed, runs just check and cargo test, then prints a manual checklist (README, NOTES.md release log, wiki, tldr page) that you confirm before it reports the gate passed. See AGENTS.md in the main repo for the full checklist this automates.

After a PR is merged, run:

just merge-pr

This squash-merges via gh, switches to main, pulls (which also fires the post-merge benchmark-upload hook — see Running Benchmarks below), deletes the local feature branch, and resets WIP.md for the next session.

Running Benchmarks

  • Run Criterion micro-benchmarks:

    just bench
  • Benchmark the CLI execution speed:

    just bench-cli
  • Compare retch against other fetchers (e.g. fastfetch, neofetch):

    just bench-compare
  • Upload local benchmark results to the gh-pages performance dashboard:

    just bench-upload

    This requires hyperfine and gh (GitHub CLI) to be authenticated. It is also run automatically after merging into main if you have run just setup. On Windows, run from Git Bash; native cmd/PowerShell are not supported for this recipe.

    A local upload used to rewrite the whole of dev/bench/data.js — the gh-pages commit showed 1 insertion / ~22k deletions, and the next CI run showed the inverse. That was cosmetic (the dashboard parses either form) but it buried each upload's one real change in a whole-file diff. Fixed in v0.9.11: the script now serialises the file exactly as github-action-benchmark does. If you ever touch that serialisation, all three details matter — indent=2, ensure_ascii=False (JavaScript does not escape non-ASCII and Python's default does), and no trailing newline. Verify by round-tripping a CI-written data.js and comparing sha256; three of the four plausible combinations do not reproduce it.

Clone this wiki locally