-
Notifications
You must be signed in to change notification settings - Fork 0
Development Setup
To build and contribute to retch, ensure you have the following prerequisites installed on your system.
- 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 man—cargo install mandown)-
The version matters, because
just prcompares your regenerated page against the committed one. mandown 1.1.1 stopped emitting.Bl/.Elaround list items; those are mdoc macros and are undefined inman(7), so groff warned twice on every page that carried them. An older mandown re-adds them, sojust manproduces a ~164-line diff and the gate fails — which reads as "my change broke the man page" and is not. Runcargo install mandownto update, then regenerate. The rendered output is identical either way; only the warnings differ.
-
The version matters, because
- hyperfine (optional, required for local CLI execution speed benchmarking)
If you use Nix, all prerequisites are provided automatically via the flake devShell:
git clone https://github.com/l1a/retch.git
cd retch
nix developThis drops you into a shell with cargo, rustc, rust-analyzer, just, mandown, hyperfine, and python3 available. Skip to Common Just Recipes once inside.
-
Clone the Repository:
git clone https://github.com/l1a/retch.git cd retch -
Run one-time repo setup (installs git hooks):
This installs the
just setup
post-mergehook that automatically uploads local benchmark results to the dashboard whenever you merge intomain. Only needs to be run once per clone. -
Build the Project:
cargo build --release
| 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 |
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\binbecomesusr+ 0x08.templates/ justfile-common.justcarried exactly that, andcrates/sysinfo/src/win_setupapi.rscarried 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 apub fn, so it reached the published rustdoc. -
A carriage return.
.gitattributespins* 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 --shortM <file>git diffnothing — no hunk, no name, only a stderr warning git add <file>, thengit statusclean, with every CR still on disk So the drift shows exactly once, as an
Mwith no diff behind it, and the firstgit adderases the only signal while leaving every byte in place.git ls-files --eolis the unambiguous oracle (look fori/lf w/crlf).v0.17.10 stated this as "
git statusstructurally 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.
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.
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.
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.
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.
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.5It 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.
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
%changelogentry matchesVersion: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.
| 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.
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.tomlEvery 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'sSummary/%description, the tap'sdesc, the Nix descriptions — ships with that channel's own publish step.just check(metadata-check, also apackaging.ymljob) fails when any of those copies drifts frommetadata.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.ymlnow pushes the COPR page on every tag, andjust github-metadatasets 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 becausejust prhard-fails onceCargo.tomlequals 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-bumpandbrew-bumpno longer exist.
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:
-
--lockedis load-bearing. With network access and no vendoring it is the only thing pinning what gets resolved to what was actually tested.Cargo.lockis 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
Source1andrusqlite'sbundledfeature 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.
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.
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%filesentry or an assertion naming the uncompressedretch.1reports it missing exactly when it is present. The spec usesretch.1*. This is thezipmantrap from theaurCI job in RPM form. -
The
fedoracontainer image setstsflags=nodocs, sodnf installof the built package silently skips the man page and a check against the installed path reports it missing. Read the payload withrpm2cpioinstead, or install with--setopt=tsflags=.
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.
.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.tomlequals the tag, so a rebuild whilemainis ahead of the last release cannot publish an unreleased NEVRA; and -
buildscm --commit <tag>builds that exact committish, rather thanbuild-package's storedmain. 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.
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.
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
%_topdiralone, leavingHOME=/root. Under those conditions the pre-fix Makefile passes:rpmdev-setuptreesimply builds its tree wherever_topdirpoints, and the copy into$(HOME)/rpmbuild/SPECSstill succeeds. Reproducing the real failure needsHOME=/builddiras well, because/builddir/rpmbuild/SPECSis the path the error message actually names. It also needs a fresh container: reusing one lets the happy-path run leave/root/rpmbuildbehind for the second run to find, which hides the failure a second way.Verified by reconstructing the pre-fix Makefile from commit
a606bbeand 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.
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:
-
Version:is exactly@VERSION@— an equality test, not "contains a sentinel": a spec carryingVersion: 0.17.3and a@VERSION@in a comment would pass the weaker one. - No other preamble tag records a version number. Prose in
%descriptionis ignored, which is the negative case that keeps the check usable. -
Source0is the local%{name}-%{version}.tar.gz. A URL would put the dependency on a published tag back. -
cargo buildstill 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. -
%filesstill carries%license LICENSE NOTICE.%doccan be stripped;%licensecannot, and NOTICE holds the MIT attribution for the adapted Fastfetch logos. - The newest
%changelogentry 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.
| 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.
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 suffix — git, 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.
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.
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.
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, whateverXDG_CONFIG_HOMEsays. 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 underDocuments\PowerShelland can be moved by OneDrive folder redirection. The helper still writes the XDG path but now reports it asNOT ACTIVEthere 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.
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.
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-prThis 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.
-
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
hyperfineandgh(GitHub CLI) to be authenticated. It is also run automatically after merging intomainif you have runjust 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 showed1 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 asgithub-action-benchmarkdoes. 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-writtendata.jsand comparing sha256; three of the four plausible combinations do not reproduce it.