Everything needed to ship hi through a package manager. Nothing here publishes
on its own — the publishing job waits on a manual approval, and the AUR and the
Homebrew tap are copies you make by hand. The one-time setup each channel
needs first (the release approval gate, branch protection, the apk and
minisign keypairs, the AUR deploy key, the tap token) is a checklist with exact
commands in ROADMAP.md’s GitHub repo settings, Secrets & keys
and Release channels sections. Until those exist, a pushed v* tag publishes
unattended and the release ships unsigned sums.
Every workflow’s runs-on: reads a repo/org Actions variable first —
vars.RUNNER_LABEL, or vars.MACOS_RUNNER_LABEL / vars.WINDOWS_RUNNER_LABEL
for the two OS-locked e2e jobs — falling back to the GitHub-hosted label when
unset, so nothing changes until you set one. ci.yml reads them one job
earlier: its runner job resolves the pair once and the six substitutable jobs
take needs.runner.outputs.* from it. That job is also where the one exception
lives — a pull request from a fork gets the GitHub-hosted label whatever the
variable says, so a stranger’s branch never runs on your machine.
Jobs that install apt packages or touch the Docker socket (ci.yml’s test,
bench, packaging-smoke, e2e, e2e-backends, and coverage.yml) need a
self-hosted runner providing those; macos-e2e.yml and windows-e2e.yml need
a same-OS one if substituted. The three lint jobs — actionlint, zizmor,
markdownlint — are the other side of that list, and are pinned to
ubuntu-latest outright rather than reading the variable: they install nothing
and open no socket, so pointing them at your own machine buys nothing and only
adds jobs contending for its workspace.
Those five ci.yml jobs run in a chain rather than in parallel — test →
bench → packaging-smoke → e2e → e2e-backends — so at most one of them
occupies the runner at a time. Their needs: are ordering, not data
dependencies, which is why each link is guarded with !cancelled(): a red job
still lets the next one run and report its own verdict.
Know the limit of that guarantee, though: it holds within this workflow only.
coverage.yml, pages.yml, link-check.yml, tool-versions.yml and
scorecard.yml read RUNNER_LABEL too, and nothing in a workflow file can
order one workflow against another. The runner is the only thing that can: one
runner process takes one job at a time, so registering exactly one on the box
is what actually makes “never two at once” true.
Every one of those jobs also shares one directory on the box —
_work/<repo>/<repo>, which the runner keeps between jobs rather than
recreating. actions/checkout cleans it itself, but it does that as the runner
user, and the container suites here can leave a file behind that the runner
user cannot delete: root-owned from a docker step, or subuid-owned from
rootless podman. That delete then throws, and the throw is swallowed — every
checkout in this repo sets persist-credentials: false, so a Removing auth
teardown runs in finally, fails its own git config --local against the
now-.git-less directory, and replaces the real error with
fatal: --local can only be used inside a git repository
The process '/usr/bin/git' failed with exit code 128
Read that message as “something in the workspace could not be deleted”, not as
a git problem. It does not clear on a retry either: .git is gone, so every
later run takes the same delete path and dies identically. The Reclaim the
workspace step ahead of each checkout is the guard — a sudo chown -R back to
the runner user, skipped on hosted runners via runner.environment. If a box
has already wedged, look at what survived in _work/<repo>/<repo> (find . !
-user "$(id -un)", plus mount for a stale mount point) before clearing it;
that residue is the only evidence of which step left it there.
Do the two repo settings under ROADMAP.md’s “GitHub repo
settings” — the fork-PR approval and the manual-dispatch environment —
before pointing any of these variables at a self-hosted runner. Neither can
be done from a workflow file, and the environment: declarations in
release.yml and scorecard.yml are inert until the second one exists. The
two e2e workflows deliberately carry none: ci.yml calls them on every push to
main, where a required reviewer would stall the run rather than gate it — the
push-only condition is what keeps a fork’s pull request out of them.
hi.sh locates itself - it walks $0 through any symlinks and takes the tree from where it lands, so
/usr/bin/hi pointing into a package prefix resolves correctly on its own (GLOSSARY: HI.33). Everything
then resolves against $_HI_ROOT="$_HI_HOME/hi.d". What a channel still owes is the layout and the
handoff: put the tree in a directory literally named hi.d, and make sure _HI_HOME names that
directory’s parent in the environment, because a new process with no tree to derive from - a login
shell, tmux’s update-environment, another machine’s hi probing this one - has nothing else to read.
| channel | tree | how _HI_HOME gets set |
|---|---|---|
| AUR, deb, rpm, apk | /usr/share/hi.d |
/etc/profile.d/hi.d.sh, written by install_tree |
| Homebrew | <keg>/libexec/hi.d |
the bin/hi wrapper, plus the rc line install.sh writes |
scripts/install.sh --prefix /usr/share (with $DESTDIR) does all of this and is the single decider of
what a packaged install contains — _HI_PACKAGE_CONTENTS and install_tree() in that file. Both AUR
PKGBUILDs and mkpkg.sh call it. Only the Homebrew formula repeats the list, because a formula cannot
call it: install_tree hardcodes /usr/bin and /etc/profile.d, neither of which exists in a brew
prefix. tests/packaging/packaging_test.sh fails if that copy drifts.
| path | what it is |
|---|---|
mkpkg.sh |
stages the tree, stamps it, then builds deb/rpm/apk with nfpm |
stamp.sh |
writes the version into a built tree’s hi.sh and man page; every channel calls it |
bump.sh |
writes the version + real checksums into every manifest; --check verifies |
aur/hi.d/ |
the versioned AUR package (PKGBUILD, .SRCINFO) |
aur/hi.d-git/ |
the same package built from main |
homebrew/hi.d.rb |
the tap formula |
nfpm/nfpm.yaml |
deb/rpm/apk, built from the staged tree |
The version stamp. packaging/stamp.sh writes _HI_RELEASE= into the hi.sh a channel installs and
the version into the man page’s .TH line. All four call it — mkpkg.sh for deb/rpm/apk, both
PKGBUILDs’ package(), the formula’s install — so there is one implementation rather than four seds.
It cannot live in git: bump.sh runs only after the tag exists (its checksums need the tarball), so a
committed stamp would always be one release stale in the very tarball Homebrew and the AUR build from. A
checkout answers hi --version with git describe instead, so the committed line stays empty. The
formula passes --date <version>, having no SOURCE_DATE_EPOCH, and stamp.sh refuses to guess one.
tests/packaging/packaging_test.sh guards all of it.
git tag v1.0.0 && git push origin v1.0.0
That is the whole local ceremony. The tag never moves: bump.sh checksums the GitHub tarball, which only
exists once the tag is pushed, so the workflow runs the bump itself against that tarball rather than
requiring a pre-tag bump and a force-retag to reconcile the two.
git tag v1.0.0 && git push origin v1.0.0 — the tarball now exists and the workflow starts.build job runs the fast suites, then bump.sh 1.0.0 (fetches the tarball, writes pkgver,
b2sums, the formula url/sha256, and the derivable .SRCINFO lines), verifies with
bump.sh --check, runs the packaging drift guards against the fresh manifests, and builds the
deb/rpm/apk plus a SHA256SUMS over them. Nothing has published yet.publish job in the Actions UI — this is your review point, over the exact artifacts the
build produced. Packages, SHA256SUMS, and manifests land on the release, and the regenerated
manifests are committed back to main (they are consumed from the AUR/tap repos, not from inside the
tarball, so they don’t need to be in the tagged tree).HOMEBREW_TAP_TOKEN), the
AUR gets a push (AUR_SSH_KEY). Until then, copy the manifests from the release (or from main) by hand,
per the sections below.bump.sh 1.0.0 still works by hand if CI is ever unavailable (--tarball <file> skips the
download), and bump.sh --check 1.0.0 stays useful locally to confirm the manifests match a cut release.
Release notes are the PR titles. The publish job’s gh release create --generate-notes drafts the
notes from the PR titles merged since the last tag — there is no separate notes file to write. The
discipline that makes this good enough: title PRs the way you’d want them read in release notes, and skim
gh pr list --state merged before tagging to retitle anything that wouldn’t. Revisit git-cliff only if
the generated notes start needing curation.
Every channel below is gated on the manual approval in release.yml, and two of them (the AUR and the tap)
are pushed by CI once their secrets exist — the checks each section describes are still yours to run first.
Not done yet — no account, no submission. When you do, run the gate below for each package:
aur/hi.d-git today, aur/hi.d once v1.0.0 exists. namcap is the hard step, not a suggestion — push
nothing while either its PKGBUILD or its built-package run has complaints.
cd packaging/aur/hi.d-git # then again in packaging/aur/hi.d
makepkg -f # builds it
namcap PKGBUILD # lints the recipe itself
namcap ./*.pkg.tar.zst # catches hardcoded paths and bad permissions
pacman -Qlp ./*.pkg.tar.zst # /usr/share/hi.d/..., /usr/bin/hi, /etc/profile.d/hi.d.sh
What a clean run looks like. namcap PKGBUILD is silent. namcap on the built package prints exactly
three warnings, all of them namcap being unable to read shell scripts, all correct to keep:
W: Dependency fish detected but optional (programs ['fish'] ...) # optdepend on purpose - hi works without it
W: Dependency zsh detected but optional (programs ['zsh'] ...) # same
W: Dependency included, but may not be needed ('openssh') # hi runs ssh; no shebang says so
Anything else is a real finding. (coreutils is deliberately not in depends — it is in base, which
packaging guidelines say to assume.)
The end-to-end check, which is what caught the hi.d-git package shipping no version stamp:
docker run --rm -v "$PWD:/pkgs:ro" archlinux:base bash -c '
pacman -Sy --noconfirm openssh && pacman -U --noconfirm /pkgs/*.pkg.tar.zst
bash -lc "echo \$_HI_HOME; command -v hi; hi --version"'
Both packages have been through all of this against a local clone (the only substitution being source=,
the repo not being published yet): built, linted, installed into a clean Arch container, exercised, and
removed with nothing left behind.
Then push PKGBUILD + .SRCINFO — only those two — to ssh://aur@aur.archlinux.org/hi.d-git.git,
hi.d-git first since it needs no tag. That first push is the manual one, because it is where namcap
gates. After it, release.yml’s aur job pushes the versioned hi.d on every release, given the
AUR_SSH_KEY secret; hi.d-git has no version to bump and CI never touches it.
Never submit the versioned package with b2sums=('SKIP') — SKIP is correct only on hi.d-git, whose
source is a git ref.
A tap is just a GitHub repo named homebrew-tap with a Formula/ directory. Copy
packaging/homebrew/hi.d.rb to Formula/hi.d.rb there and brew install ivy/tap/hi.d works — no review,
no approval, which is exactly why brew audit --strict is a hard gate here.
The copy is automated, the checks are not. release.yml’s tap job (behind the same approval as
publish) opens a PR against <owner>/homebrew-tap with the regenerated formula and the three commands
below as its checklist. It needs a HOMEBREW_TAP_TOKEN repo secret — a fine-grained PAT scoped to that
repo with contents + pull-requests write — and without it the job says so and does nothing, which is the
state until the tap repo exists. Merging the PR is yours, as is running these first:
brew install --build-from-source ./packaging/homebrew/hi.d.rb
brew test hi.d
brew audit --strict --new hi.d
brew audit needs a named formula, so it wants one in a tap: brew tap-new ivy/tap, copy the file into
its Formula/, then brew audit --strict --new ivy/tap/hi.d. Passing a path is refused outright.
What a clean run looks like — this has been run in the homebrew/brew container against a local
tarball, the only substitution being url/sha256: install and test exit 0, and audit reports only these
two, which are the unpublished repo and nothing else:
* The homepage URL https://github.com/ivylikethevine/hi.d is not reachable (HTTP status code 404)
* HEAD: The URL https://github.com/ivylikethevine/hi.d.git is not a valid Git URL
Two real findings came out of that run and are fixed: the description had to start with a capital, and
uses_from_macos "openssh" was rejected — that macro is for formulae macOS provides to Homebrew, and
openssh is not one. The formula now declares no dependencies at all, which is correct: ssh and base64
ship with macOS and with any Linux that would install this.
A mac is still worth using before the first publish, since the container exercises Linuxbrew’s paths
rather than a keg under /opt/homebrew — but nothing about the formula itself is unverified now.
Built by mkpkg.sh and attached to the GitHub Release. Users install the file:
sudo apt install ./hi.d_1.0.0_all.deb
The apk is signed (once the APK_SIGNING_KEY secret exists — see the ROADMAP checklist) with a key apk
verifies against /etc/apk/keys/, so Alpine users install the public key once and never pass
--allow-untrusted:
wget -O /etc/apk/keys/hi.d.rsa.pub \
https://raw.githubusercontent.com/ivylikethevine/hi.d/main/packaging/apk/hi.d.rsa.pub
apk add ./hi.d_1.0.0_noarch.apk
A quirk worth knowing: the apk enumerates its contents per _HI_PACKAGE_CONTENTS member in nfpm.yaml
rather than riding the type: tree entry deb/rpm use, because nfpm 2.47.0’s tree walker writes directory
modes apk-tools rejects outright. The packaging suite keeps that copy honest, and CI’s packaging-smoke
installs the signed apk on Alpine every PR so the channel can’t silently regress.
No apt upgrade — the trade for not maintaining a repository. Revisit
OBS only if people ask for a repo to
subscribe to.
tests/test_runner.sh packaging install header # the offline drift guards
packaging/mkpkg.sh --stage-only # inspect exactly what ships
find dist/staging \( -type f -o -type l \)
packaging/mkpkg.sh # needs nfpm on PATH
dpkg-deb -c dist/hi.d_*_all.deb
The same commit builds byte-identical deb/rpm/apk: mkpkg.sh exports SOURCE_DATE_EPOCH (HEAD’s commit
time, respecting a value you set per the
reproducible-builds.org convention), clamps the
staged tree’s mtimes to it, and nfpm stamps everything else it controls from the same variable. CI’s
packaging-smoke job enforces it with a double build on every PR. Locally, run them sequentially — nfpm.yaml
hardcodes ./dist/staging, so --outdir cannot run two side by side:
packaging/mkpkg.sh && mv dist dist.first
packaging/mkpkg.sh && diff dist.first/SHA256SUMS dist/SHA256SUMS
One caveat: CI pins nfpm 2.47.0 (.github/actions/setup-tool/tools.txt) while mkpkg.sh takes whatever
nfpm is on PATH — a different local nfpm can produce different (still internally reproducible) bytes.
The honest end-to-end check for the /etc/profile.d snippet, which is the part no unit test can prove:
docker run --rm -it -v "$PWD/dist:/dist" debian:stable \
bash -lc 'apt-get update -qq && apt-get install -y /dist/hi.d_*_all.deb && echo "$_HI_HOME" && hi'
The tree is root-owned and holds nobody’s settings. Each user runs, once:
/usr/share/hi.d/scripts/install.sh --no-link
--no-link skips the /usr/bin/hi symlink the package already owns. Answers go to ~/.config/hi.d/,
never into the tree, which is what lets a root-owned checkout work at all. hi --update correctly refuses to
git pull and points at the package manager instead.