Everything needed to ship hi through a package manager, plus the two things
that hang off a release once it exists: checking a download you did not
build, and regenerating the demo
GIFs the release pipeline publishes to the site.
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. Both
signing keys are in place, so a release signs its sums and its apk; what is
still one-time setup is the AUR deploy key and the tap token, a checklist with
exact commands in ROADMAP.md’s Release channels section.
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 four lint jobs — actionlint, zizmor,
markdownlint, hadolint — 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.
Two repo settings have to be in place before you point any of these variables
at a self-hosted runner: the fork-PR approval, and the environments
release.yml names — manual-dispatch on its rehearsal gate and release on
publish/tap/aur. Neither can be done from a workflow file, and an
environment: naming one that does not exist gates nothing. 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/say-hi". What a channel still owes is the layout and the
handoff: put the tree in a directory literally named say-hi, 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/say-hi |
/etc/profile.d/say-hi.sh, written by install_tree |
| Homebrew | <keg>/libexec/say-hi |
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/say-hi/ |
the versioned AUR package (PKGBUILD, .SRCINFO) |
aur/say-hi-git/ |
the same package built from main |
homebrew/say-hi.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.
nix is the one looked at and answered so far, and the answer for now is no. The
reasoning - the derivation shape, why it would ship as a flake before a nixpkgs
submission, the _HI_PACKAGE_CONTENTS drift guard that has to grow a case
first, and what /etc/profile.d has no store-path equivalent for - lives with
every other decision against something, in UNSUPPORTED.md’s packaging
channels section.
git tag v1.0.0 && git push origin v1.0.0
That is the whole local ceremony. The tag never moves: the manifests carry checksums of a tarball that cannot exist before the tag does, so the workflow does the bump itself rather than requiring a pre-tag bump and a force-retag to reconcile the two.
The tarball is one the release builds, not GitHub’s auto-generated
/archive/ one. That matters because the archive is the only artifact a
release could ship with nothing signed over it: no entry in SHA256SUMS and no
build provenance, in a chain where every other file has both.
packaging/srctar.sh (over lib.sh’s src_tarball) writes a
git archive --prefix say-hi-<version>/ of the tag — the same shape, down to
the directory the AUR package’s prepare() symlinks — and that file is what
bump.sh checksums, what mkpkg.sh lists in SHA256SUMS/ARTIFACTS, and
what the release attaches. One set of bytes, summed once.
git tag v1.0.0 && git push origin v1.0.0 — the workflow starts.build job builds say-hi-1.0.0.tar.gz from the tag, runs the fast
suites, then bump.sh --tarball <that file> 1.0.0 (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 — mkpkg.sh --source-tarball
puts the tarball beside them and writes one SHA256SUMS over the lot.
Nothing has published yet.publish job in the Actions UI — this is your review point, over
the exact artifacts the build produced. Packages, the source tarball,
SHA256SUMS, and manifests land on the release, and the regenerated
manifests come back to main as a manifests-v1.0.0 pull request (they
are consumed from the AUR/tap repos, not from inside the tarball, so they
don’t need to be in the tagged tree). main requires a pull request and
refuses a direct push, so the job opens one rather than being granted an
exception to the rule.HOMEBREW_TAP_TOKEN), the AUR gets a push (AUR_SSH_KEY). Neither waits
on the manifest PR — both read the manifests out of the packages
artifact, so merging it is bookkeeping for the next release to diff against.
Until those secrets exist, copy the manifests from the release by hand, per
the sections below.Because the tarball is in dist/ARTIFACTS, it reaches both the attestation and
the release upload without either step naming a .tar.gz: those two read that
file rather than a format list spelled out in YAML.
bump.sh 1.0.0 still works by hand if CI is ever unavailable — with the tag in
your checkout it builds the identical tarball itself, and --tarball <file>
takes one you already have. It falls back to downloading the published asset
only when neither is available, which is why the download can no longer be the
first thing it tries: during a release the asset does not exist yet.
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 asks GitHub’s
releases/generate-notes endpoint for the notes and puts them at the top of the
release body — the PR titles merged since the last tag, with 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.
Below the notes the same step appends the
verification checklist, so the two questions a
release has to answer — what changed and how do I check this download — are
both in the body. It is composed rather than passed as
--generate-notes --notes, because gh appends the generated notes after
--notes, which would bury the notes under the checklist.
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, and not currently doable: AUR registration is closed to new accounts
because of spam, so there is no account to push from. Everything below is ready
for the day it reopens. Run the gate for each package — aur/say-hi-git
today, aur/say-hi 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/say-hi-git # then again in packaging/aur/say-hi
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/say-hi/..., /usr/bin/hi, /etc/profile.d/say-hi.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 say-hi-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/say-hi-git.git, say-hi-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 say-hi on every
release, given the AUR_SSH_KEY secret; say-hi-git has no version to bump and
CI never touches it.
Never submit the versioned package with b2sums=('SKIP') — SKIP is correct
only on say-hi-git, whose source is a git ref.
A tap is just a GitHub repo named homebrew-tap with a Formula/ directory.
Copy packaging/homebrew/say-hi.rb to Formula/say-hi.rb there and
brew install ivy/tap/say-hi 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/say-hi.rb
brew test say-hi
brew audit --strict --new say-hi
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/say-hi. 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/say-hi is not reachable (HTTP status code 404)
* HEAD: The URL https://github.com/ivylikethevine/say-hi.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 ./say-hi_1.0.0_all.deb
The apk is signed 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/say-hi.rsa.pub \
https://raw.githubusercontent.com/ivylikethevine/say-hi/main/packaging/apk/say-hi.rsa.pub
apk add ./say-hi_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.
This is the half you run on a package you just built, before it goes anywhere. Its near-namesake at the bottom — Verifying a release download — is the other direction: what somebody who downloaded a release runs to check it is the one this repo published.
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/say-hi_*_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/say-hi_*_all.deb && echo "$_HI_HOME" && hi'
The tree is root-owned and holds nobody’s settings. Each user runs, once:
/usr/share/say-hi/scripts/install.sh --no-link
--no-link skips the /usr/bin/hi symlink the package already owns. Answers go
to ~/.config/say-hi/, 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.
Saying hi to a packaged machine works whether or not anyone ran that.
The package’s /etc/profile.d/say-hi.sh is what hi.sh’s _hi_remote_root
probe reads to find /usr/share/say-hi and use it in place instead of shipping
a payload over it, and /usr/share is on the probe’s install-prefix list even
if that snippet is gone (GLOSSARY: HI.33).
tests/targets/install_methods_test.sh installs a real .deb, .rpm and
.apk on real targets and asserts exactly that.
docs/tapes/generate.sh renders all of them: one
vhs run per tape, cheapest first, with a fixtures.sh down in between — no
tape cleans up after itself — and a summary of what rendered, what stood down
for a missing backend, and what failed. Name tapes to render a subset
(generate.sh docker kube); --list shows them, --down clears up after a
crashed run.
Seven of the eight render themselves.
.github/workflows/demos.yml runs every tape
but demo on the self-hosted runner — the only machine with all four backends —
on
a tape change, weekly, or on dispatch, and hands the GIFs to the Pages build,
which lays them over the committed copies at the same paths. Nothing is
committed back: a bot commit on top of the author’s is what branch protection
refuses, and it is the same reason the tests badge is published rather than
written into this file.
The top-of-README demo is the one that goes quietly wrong: it claims to be the
stock defaults, so it is stale the moment the header, the prompt or the tape
changes, and nothing about looking at it says so.
.githooks/demo_staleness.sh is the reminder
— it compares demo.gif’s last commit against the tape, the fixtures and the
shipped tree, and says which of them moved since. Run it by hand, or wire it up
as a pre-commit hook:
git config core.hooksPath .githooks
It only ever warns. Rendering a binary nobody looked at is the thing this section exists to argue against, so the hook will not do it for you and will never block a commit.
By hand it is one vhs docs/tapes/<name>.tape per GIF from the repo root, with
the backend running and hi on PATH; docs/tapes/fixtures.sh builds every
target the tapes connect to, fixtures.sh down removes them. There is one more
in CONFIGURATION.md — color_preview.tape, the
only one needing no backend at all.
Two things to get right when you do it that way — the two the script exists to
take care of. hi on $PATH must be this checkout (/usr/bin/hi may point
elsewhere; the script shims its own onto the front of $PATH). And the target
image builds from HEAD, so uncommitted work shows on the client side of the
GIF but not the target’s: render from a commit, or set
HI_DEMO_SOURCE=worktree, which is what the script picks for you on a dirty
tree.
Both sides of every GIF are staged, not inherited. Each tape sources a small rc
fixtures.sh writes, giving the outside shell hi’s own prompt under a chosen
user@host instead of the renderer’s — and every target gets an explicit
hostname rather than a backend’s random hex ID. The pairs vary on purpose:
docker’s client is cache-1 and one of its targets is cache-1 too, while the
rest say hi somewhere they are not.
Releases ship a SHA256SUMS, signed build provenance, and a detached
minisign signature over the sums (the
offline half — no gh, no network, one static public key):
sha256sum -c --ignore-missing SHA256SUMS # the bytes match the release
minisign -Vm SHA256SUMS -P 'RWTDcJ3LGWayrAxK6mbMysyOF8mNLOmMUGRl4YSWk5KIoayS+lW0Fy1L'
gh attestation verify say-hi_*_all.deb --repo ivylikethevine/say-hi # which CI run built them
That minisign line is load-bearing, not just an example. release.yml’s
publish job seds the public key out of it to build the checklist it puts in
every release body, and fails the release if the pattern stops matching — one
literal copy of the key in the tree rather than two that can drift apart. Keep
it a single line starting minisign -Vm SHA256SUMS -P ', with the key in single
quotes; the prose and the trailing comments around it are free to change.
That covers every file on the release, say-hi-<version>.tar.gz included —
the source tarball the Homebrew formula and the AUR package build from is one
the release built and attested, not GitHub’s auto-generated /archive/ one,
which carries neither sum nor signature. So
gh attestation verify say-hi-*.tar.gz --repo ivylikethevine/say-hi answers for
the sources the same way the line above answers for the .deb.