Contributing to shlib
August 30, 2026 · View on GitHub
Everything you need to change this library without breaking somebody else's install script. It is worth reading The vendoring problem first: copies of this code are frozen into hundreds of third-party installers that will never be updated, and that single fact explains most of the rules here.
Two sections are worth knowing about before you open a pull request:
- Deliberate choices that look like bugs -- several things here are wrong on purpose, and have been "fixed" before.
- Where help is wanted -- scoped, measured tasks if you are looking for somewhere to start.
Companion documents: docs/PORTABILITY.md for shell and
platform behaviour, docs/INSTALLERS.md for building a
curl | sh installer, docs/API.md for the function index.
What this is
shlib is a set of portable shell functions for curl | sh installers --
detect the platform, download a file, verify a checksum, unpack an archive,
place a binary. Each function lives in its own <name>.sh file and is meant to
be concatenated into somebody else's script, not sourced as a dependency.
A curl ... | sh installer cannot have dependencies, so the code has to travel
with it.
That single fact drives almost every design decision here.
The code is written in POSIX sh, but that is the constraint, not the
claim. POSIX describes neither where this has to run nor what it has to
defend against: Solaris and illumos ship pre-POSIX tools, Windows via git
bash is not a POSIX environment at all, and several shells in the matrix are
not POSIX shells by default. Writing to the smallest common subset is how the
functions reach those places -- see Conventions for the rule
and docs/PORTABILITY.md for where reality falls short
of the standard.
Shell- and platform-specific gotchas live in docs/PORTABILITY.md, not here: which shells break which idiom, which systems ship pre-POSIX tools, and how to rehearse a platform you cannot boot. Reach for it when one CI leg is red and the rest are green. This file stays on conventions, architecture and open work.
The vendoring problem
Because copies travel, they go stale, and the copies never hear about fixes.
Measured with gh code search (Aug 2026):
| what | files |
|---|---|
carry shlib's not a GOOS value message | 920 |
still say file bug at .../client9/shlib | 812 |
generated by godownloader | 526 |
| contain the MinGW fix (Dec 2018) | 662 — so ~258 still lack it |
contain illumos / midnightbsd (2021) | 97 / 55 |
The not a GOOS value phrasing was retired after v2026.08.28 (the checks now
say not a recognized OS name / architecture name), so it stays a usable
search fingerprint for the existing population and additionally dates a copy as
pre-2026.08.29. Newer copies are dated by their version marker instead.
Two real bug reports arrived in 2026 for bugs fixed in 2018 and 2021. Neither
reporter ran shlib; both ran a frozen copy embedded in an install script
(fossa-cli, generated 2019-07-23; Bearer, copied from Trivy's copy).
v2026.08.28 is the first bundle that is not silently broken. v2026.08.27
shipped with the minifier bug below -- valid checksums, missing code -- so
anyone who vendored that tag should re-vendor. Verified on the published
artifacts: Windows_NT resolves to windows under v2026.08.28 and to
windows_nt under v2026.08.27.
When triaging a bug report, first establish which vintage the reporter is running. Since v2026.08.27 the bundle self-identifies:
sed -n 's/^shlib \(.*\)/\1/p' their-install.sh
Older copies must be dated by diffing uname_os against this repo's history.
Conventions
-
POSIX sh only. No
local, no[[, no==in[, no arrays, no$'...'. Variables are effectively global; that is accepted style here, not a bug to fix. -
One function (or small family) per file, named after the file.
-
Functions report errors with
log_err/log_critfromlog.sh, which needsechoerr.sh. Anything with a failure path therefore depends on both — this is the most common mistake when hand-assembling a bundle. -
Error messages are generic. Do not put a bug-reporting URL in them: 812 stale copies already do that and misroute other projects' bugs here. The shlib URL belongs only in
license.sh/license_end.sh. -
Internal variables are prefixed
_shlib_. The library is concatenated into other people's scripts, so a bareos=orversion=clobbers the caller. It used to:github_releaseoverwrote$versionanduname_osoverwrote$os. Every internal scratch variable now carries the prefix, anddist_test.shfails if any unprefixed lowercase global escapes.Not prefixed, because they are the documented contract: the installer config names (
OWNERREPOBINARYBINARIESFORMATBINDIRPLATFORMSPLATFORMOSARCHVERSIONTAGNAMETARBALLCHECKSUM…) and standard environment (TMPDIR,GITHUB_TOKEN).localis not POSIX, which is why prefixing is the mechanism. -
Optional arguments must be written
${2-}, never$2. The library is concatenated into other people's scripts, and an install script is exactly the kind of thing that starts withset -eu. Under nounset a bare$2for an argument the docs call optional does not default to empty -- it aborts the shell.github_release owner/repo(tag omitted) did precisely that, and it took the whole NetBSD and OpenBSD legs down before a single test ran, because the vmactions runner executes its script underset -u. Same for an optional environment variable:${GITHUB_TOKEN-}.nounset_test.shpins every documented call form; add to it when adding an optional argument. -
awk,grep,cut,tr,mktemp,tarare fair game. Assume busybox versions of all of them -- and Solaris's pre-POSIX ones, which is where the surprises are: see docs/PORTABILITY.md.
Commands
make test # all tests under /bin/sh
make test TEST_SHELL=dash # ... under one shell
make test TESTS=untar_test.sh # ... one file
make test-all # ... every shell installed locally
make lint # shellcheck (sh/bash/dash/ksh) + scripts/ + install/ + dist/ + shfmt
make fmt # shfmt -ci -p -i 2 -w
make dist # rebuild dist/ bundles
make docs # regenerate docs/API.md
make hooks # enable the pre-commit hook
make tools # pinned shellcheck + shfmt into ./bin (gitignored)
make lint needs ./bin/shellcheck; run make tools first.
Pre-commit hook
make hooks points core.hooksPath at .githooks/, enabling a pre-commit
hook that runs the fast, offline half of CI:
make dist docsand a diff againstdist/+docs/API.md(0.1s). This is the mistake most easily made -- edit a library file, forget to regenerate, and thegeneratedCI job goes red after the push.make lint(4.4s), but only when./bin/shellcheckexists. On a fresh clone it warns and allows the commit rather than blocking on a tool that has not been downloaded yet; CI is the real enforcement.
Deliberately excluded:
make testneeds the network and fails offline (verified: 3 assertions inhttp_download_test.sh). A hook that blocks commits on a train gets disabled.make fmtrewrites files mid-commit, changing what is being committed.make lintalready fails on unformatted files and says to run it.
Known limitation: the hook checks the working tree, not precisely what is
staged, so an unstaged edit can mask a problem CI then catches. Fixing that
means stashing mid-hook, which risks losing work. git commit --no-verify
bypasses it.
Testing
assert.sh provides assertTrue/assertFalse/assertEquals/assertNotEquals plus
assert_skip. Assertions are non-fatal — a failure is recorded and the file
continues, so one bad assertion no longer hides everything after it. An EXIT
trap prints totals and sets the exit status, so a test file cannot forget to
report. (Verified: exit inside an EXIT trap behaves identically in all 13
shells in the matrix.)
Conventions that matter:
-
Stub
unameto test mapping tables.uname_os_test.shanduname_arch_test.shdefine a localuname()andunset -f unameafterwards, so the whole case statement is exercised on any machine. Without this the suite only ever tested the host's own branch — which is exactly how the illumos bashism survived from 2021 to 2026. -
Every
test_fooinvocation needs atest_foo()definition. A lost definition prints "command not found" and the file can still report ok. Bothscripts/lint.sh(static check) andassert.sh(zero assertions is a failure) now guard this; it slipped through twice before that. -
A regression test must fail against the old code. Every fix in this repo was checked that way; several "tests" passed both before and after until corrected. Prove the discriminating power, do not assume it.
-
Assertions hold back stderr and show it only on failure. Negative tests are expected to make things complain --
assertFalse "hash_sha256 NONEXISTANT"really does run sha256sum on a missing file. That produced 25 lines of stderr against 14 of stdout on a fully green run, which made passing runs look broken and buried real failures._assert_runstashes stderr to a temp file and_assert_show_stderrprints it, indented, under the FAIL line. A passing run is now silent on stderr in every shell.evalstill runs in the current shell, not a subshell, so assertions with side effects behave exactly as before. -
Skip, don't fail, on a missing dependency (
assert_skip), e.g. openssl orzstd. But make CI install those tools, or coverage is hollow. -
A stub that always succeeds cannot test a refusal.
-
Tests run from the repo root and use
fixtures/.
Shell-specific traps in the harness itself -- how eval hides a nounset abort
on ksh93 and zsh, why a stub cannot be scoped with ( … ) -- are in
docs/PORTABILITY.md.
CI
Eleven CI workflows (28 test legs), plus release.yml on v* tags. Badges are
workflow-scoped — no per-job or per-matrix-leg badge exists — which is why they
are split by platform rather than one ci.yml.
| workflow | covers |
|---|---|
| `lint$ | \text{shellcheck} \times 4 \text{dialects}, $scripts/, dist/shlib.sh, shfmt, a static check for flags Solaris does not have, **and the dist` sync job** |
linux | dash, bash, ksh93, mksh, yash, posh, busybox ash |
macos | sh, bash 3.2, ksh, zsh, dash |
freebsd | 14.4 + 15.1 via vmactions/freebsd-vm (QEMU on a Linux runner; FreeBSD sh is a distinct ash) |
openbsd | 7.9 + 7.8 via vmactions/openbsd-vm — full suite under sh (OpenBSD ksh), and the only leg where http_download's ftp(1) branch runs without header support |
netbsd | 11.0 + 10.1 via vmactions/netbsd-vm — full suite under sh (ash) and ksh (pdksh); the leg where ftp -H puts a real header on the wire, and where the deliberate refusal of Accept is pinned |
dragonflybsd | 6.4.2 via vmactions/dragonflybsd-vm — full suite under sh; DragonFly → dragonfly needs no fixup. The image ships curl, so this leg calls http_download_fetch directly rather than asserting curl's absence |
sunos | Solaris 11.4 and OmniOS via vmactions/*-vm — full suite under sh, plus an assertion that SunOS resolves to solaris/illumos for real rather than stubbed |
alpine | busybox ash on musl, via docker run (not container: — actions/checkout needs glibc) |
windows | git bash + msys2 — full suite, plus an assertion that MINGW64_NT-*/MSYS_NT-* map to windows |
runtimes | python:3.12-slim and node:22-slim via docker run — each asserts curl, wget, fetch and ftp are all absent, then runs the full suite and a real install through http_download_python / http_download_node |
Notes below are about how the legs are built. What each platform actually does differently -- the reason a leg goes red -- is in docs/PORTABILITY.md.
- Windows is supported through git bash and msys2, where the full suite
runs and the
unamemapping is asserted against the real thing. It is not a POSIX environment, and nativecmd/PowerShell is not a target. The leg exists because a git-bash user's report is what surfaced a stale-copy bug, and theunamemapping had previously only been tested with a stubbeduname. WSL is deliberately excluded: it reportsLinux, so the linux workflow already covers it. - The
sunoslegs assert theuname_osresult before running the suite, so a failure later still leaves the mapping result in the log. Both legs now run the full suite; they install no packages and use nogmake, which removed a failure mode unrelated to what they test. uses:cannot take a matrix expression, which is why solaris and omnios are two jobs rather than two legs of one.- A negative assertion must pin the reason, not just the failure. The
openbsd leg checks that
github_releasecannot resolvelatestwithout an Accept header. Its first version only checked that the call failed, and it duly went green while the real cause was theset -uabort above. It now matches the error message. - An example config that makes an ABI claim has to be proved on hardware
that has that ABI.
install/examples/hugo.shmapsillumosonto hugo'ssolarisbuild, because hugo publishes no illumos asset. Whether a Solaris-built Go binary runs on illumos is not a question about shell, soinstall_test.shcan only check that the config produceshugo_..._solaris-amd64.tar.gz. The omnios leg therefore assembles that config into a real installer, runs it, and executes the binary. The static evidence pointed the same way -- interpreter/lib/amd64/ld.so.1, NEEDEDlibsendfile.so/libsocket.so/libc.so, no versioned symbol needs -- but that is an argument, not a result. The result: green on OmniOS r151054 on 2026-08-28, the binary reporting itself ashugo v0.165.0 solaris/amd64while running on illumos. - Do not assert the absence of an alternative when you can exercise the
branch directly. The dragonflybsd leg first asserted no curl, copying the
freebsd leg; the vmactions DragonFly image ships curl in
/usr/local/bin, so the leg failed on its own premise. It now callshttp_download_fetchdirectly, which tests the branch rather than a property of somebody else's disk image. The freebsd/openbsd/netbsd legs keep their absence assertions because there the absence IS the thing under test. - The QEMU legs run as root, which quietly changes what a test means: any
assertion that something is forbidden is vacuous there unless it guards
for it.
docker run alpineis a cheap way to check the root path of such a guard without booting a VM.
Installers (install/)
The replacement for the archived godownloader. An install script is
config.sh + dist/install-base.sh concatenated -- no Go, no YAML, no template
language, because a shell function is already a lazily-evaluated template.
install/runner.shdefines functions only, so tests can source it.install/main.shruns the flow and is concatenated LAST, so a truncatedcurl | shcannot do a partial install.- The config is concatenated FIRST, so
runner.shfills inadjust_*hooks only when absent (viacommand -v, notis_command-- if the latter were missing the guard would fail open and clobber the project's hook). PLATFORMSis declared by the project, giving a real error instead of a 404.FORMATis a filename suffix, not a format identifier. It is consumed in exactly one place --tarball_nameappends it to whatarchive_namereturned -- and is never compared against anything, so its legal values are precisely the suffixesuntarmatches. The name is inaccurate (TARBALLis too, whenFORMAT=zip) but both are in the documented contract and in every vendored config, so they are documented rather than renamed.- Whether the download needs unpacking is a SEPARATE axis from what it is
called, which is why
unpackis a hook and there is noFORMAT=binarysentinel. hadolint proves they are independent: its windows asset ishadolint-windows-x86_64.exe-- a non-empty suffix that is still not an archive -- so no single field can express both. A sentinel would also have needed special-casing in two places (suppress the dot, skip the untar). - Clean up around a function, not at the end of it.
executefreed its temp directory in its last statement, so every|| return 1leaked one. The fix is a wrapper (execute) around a body (_shlib_execute) so cleanup runs on all paths. AnEXITtrap -- the idiommktmpdir.shdocuments -- is not usable here:assert.shinstalls its ownEXITtrap to print test totals andinstall_test.shcallsexecutedirectly, so a trap set inside would silence the whole test report. - In
install/runner.sh, unprefixed function names are the onesmain.shcalls;_shlib_-prefixed ones are private. That distinguishes them from the hooks a config is meant to override (unpack,binary_path,adjust_*,latest_version).install_test.sh'suntarstub originally never failed, sotest_execute_bare_binarypassed even withexecutestill callinguntardirectly -- the bug it exists to catch. The stub now refuses an unrecognised suffix the way the real one does. Checked by reverting each of the three changes in turn and confirming the matching test goes red.
The platform traps this code has hit -- Solaris's /usr/bin/unpack shadowing
the hook, posh's getopts, Windows [ -x ] -- are in
docs/PORTABILITY.md.
See docs/INSTALLERS.md.
Documentation
docs/API.md is a generated index: function, one-line summary, link to source.
scripts/mkapi.sh takes the first meaningful line of the comment block above
each function, so the source stays the single place documentation is written.
- Run
make docsafter adding or renaming a function, and commit the result -- thegeneratedCI job runsmake dist docsand fails on any diff. - A function with no summary comment makes
make docsfail outright, so new code cannot land undocumented. - Deliberately an index, not a reference: arguments, returns and side effects
live in the source comments. A fuller convention (
Arguments:/Returns:/Side effects:headers, extracted into the table) is a possible next step. install/runner.shis excluded; it is internal to a generated installer and documented indocs/INSTALLERS.md.
Releases and dist/
dist/shlib.sh (the functions) and dist/install-base.sh (functions plus the
installer flow) are generated
by scripts/dist.sh and committed, so consumers can fetch a stable raw URL
at build time instead of hand-vendoring:
https://raw.githubusercontent.com/client9/shlib/master/dist/shlib.sh
Nothing is stripped or minified. cat is the only transformation, and
that is deliberate. There used to be a dist/shlib.min.sh built by piping the
bundle through grep -v '^[[:space:]]*#':
- It shipped a silently broken release. The original idiom also filtered
#, which deleted code lines carrying trailing comments -- v2026.08.27 went out with valid checksums, nowin*) os="windows"mapping, and nogitrepo=assignments ingit_clone_or_update. - Even fixed, it constrained what the library could contain: any embedded awk,
sed or python carrying a whole-line
#would be silently gutted. - It bought about 10 KB gzipped, once, at install time. GitHub raw serves gzip; the TLS handshake costs more than the difference.
- A
curl | shscript that users are told to read before running is more useful with its comments intact.
Do not reintroduce it.
The version marker still lives inside a cat /dev/null <<EOF heredoc rather
than a # comment. That began as a way to survive stripping; it stays because
sed -n 's/^shlib \(.*\)/\1/p' is the documented way to date a vendored copy
and must keep matching the copies already in the wild.
The version comes from the VERSION file. Do not "improve" this. Both
obvious alternatives were tried and both break the dist sync job:
date -uchanges daily, sodist/shows a diff every day.git logcounts only committed history whilemake distbundles the working tree, so the version lags a commit and then jumps — making freshly committeddist/instantly stale. Chicken-and-egg.
A plain file is stable, needs no git, and works in shallow clones and tarballs.
CalVer (2026.08.27), tagged v2026.08.27. Step-by-step in
docs/RELEASING.md; consumer guidance in
docs/EMBEDDING.md.
If you edit any library .sh, run make dist and commit the result or the
dist CI job fails.
Deliberate choices that look like bugs
-
The platform names are shlib's, not Go's. They match GOOS/GOARCH because that is where the artifact-naming convention came from and compatibility is worth keeping -- but Go is provenance, not authority. The admission rule, written down in
uname_os_check.shand generated intodocs/API.md: a name is recognized when some real system'sunamemaps to it AND it is the spelling projects use for release artifacts. Nothing is added because Go added it or dropped because Go dropped it. The three consequences:midnightbsdis accepted byuname_os_checkbut is not a GOOS. Added by MidnightBSD's maintainer (PR #33); kept on purpose.naclandamd64p32were removed from Go in 1.14 but are still accepted, to avoid breaking unknown downstreams. Commented as historical.uname_archreturnsarmv5/armv6/armv7, not Go'sarm+GOARM. Intentional -- artifacts are named that way.
sunosis the reverse case: realuname -s, but no project names an artifact for it, so it is resolved tosolaris/illumosand never returned.Projects using raw kernel spellings (
x86_64,aarch64) map back with the installer'sadjust_archhook. That round-trip is the cost of the Go-derived vocabulary for non-Go projects; it is accepted, not overlooked. -
hash_*functions avoid pipes intocuton the same line as the hashing command, so failures surface withoutpipefail.
Where help is wanted
In rough priority order. These are real, scoped tasks, not aspirations -- each one below has been measured rather than guessed at.
1. Resolving latest without content negotiation
github_release asks https://github.com/OWNER/REPO/releases/latest for
Accept: application/json. Neither OpenBSD's ftp nor NetBSD's tnftp can
deliver a usable Accept (see the note in the CI section), so latest is
dead on both — which is most of what an installer needs.
Three findings from measuring this, rather than guessing:
The specific-tag case needs no JSON at all. releases/tag/<tag> answers
200 for a tag that exists and 404 for one that does not, so
github_release owner/repo v1.2.3 — which only validates existence — could be
a plain http_download status check on every downloader we support. This is
the easy half and could be done on its own.
releases.atom is header-free but not prerelease-aware. It needs no
Accept, so every downloader can read it, and entries are newest-first with
the tag in a parseable id:
sed -n 's/.*<id>tag:github.com,2008:Repository\/[0-9]*\/\(.*\)<\/id>/\1/p' | head -1
But it is chronological, not filtered the way releases/latest is.
Measured: neovim/neovim reports v0.12.5 for latest and nightly as its
first atom entry. Taking the first entry would install a nightly build. There
is no prerelease marker in the feed to filter on. It is also fat — the feed
embeds full release-note HTML for the last several releases, 290 KB for
cli/cli against a few KB of JSON, which matters for curl | sh.
The clean answer exists but is out of reach. releases/latest is a 302
to releases/tag/<tag> — with correct prerelease and draft semantics, and
regardless of Accept. The tag is right there in the Location header. But
no BSD base downloader can expose a response header or stop at a redirect:
curl has -w '%{redirect_url}' and -I, fetch and both ftps have
nothing equivalent. http_last_modified has the same shape of problem.
So the realistic shape is: status check for an explicit tag, atom feed for
latest with the prerelease caveat documented and probably a log_err when
the chosen entry cannot be confirmed non-prerelease. Worth doing only if BSD
installers matter; a config can already override latest_version() today.
2. Untested functions
git_clone_or_update has no test, plus the http_download_curl/_wget
branches (exercised indirectly through http_download, and by
nounset_test.sh, but never directly for what they download). github_api
has nounset coverage only.
http_last_modified was on this list until a head -c in it -- a GNU/BSD
extension Solaris does not have -- shipped for years without anything
catching it. http_last_modified_test.sh stubs curl with canned header
blocks, so it needs no network; that is what had kept the function untested.
3. Possible, not planned
- Outreach to the ~258 repos still carrying the pre-2018 MinGW bug.
ghcode search finds them; see the table at the top.