Changelog
August 30, 2026 · View on GitHub
Versions are CalVer — the date of the release. The API shape rarely changes, so SemVer's compatibility signal would buy little, while a date makes a stale embedded copy obvious on sight.
If you are running an embedded copy, the version is in its preamble:
sed -n 's/^shlib \(.*\)/\1/p' your-install.sh
Unreleased
Rename this heading to the release date when cutting a release — see docs/RELEASING.md.
2026.08.30
Removed
-
dist/shlib.min.shis gone, and nothing is minified any more.scripts/dist.shnow only concatenates;dist/shlib.shanddist/install-base.shship with their comments.If you fetch
dist/shlib.min.shfrom a raw URL, switch todist/shlib.sh-- the old path no longer exists and will 404.Stripping saved about 10 KB gzipped, once, at install time. Against that it shipped v2026.08.27 with valid checksums and missing code (the
#filter deleted code lines carrying trailing comments), it constrained what the library could contain -- any embedded awk, sed or python with a whole-line#would be gutted -- and it made acurl | shscript harder for a cautious user to read before running it.
Fixed
-
http_last_modifiedreturned nothing on Solaris and illumos. It sliced the header withtail -c 31 | head -c 29, and POSIXheadhas only-n-- Solaris ships exactly that, sohead -cprinted its usage line instead. It now matches the field name withsed, which is portable and does not depend on the date being exactly 29 characters. Output is unchanged on HTTP/1.1 and HTTP/2 responses alike. The function had no test; it has one now. -
unpackcollided with a real command on Solaris and illumos. The hook guards askedcommand -v NAMEand looked only at its exit status, which answers "does a command by this name exist", not "did the config define a function". Solaris and illumos ship/usr/bin/unpack(the companion topack/pcat), so the guard found it, skipped defining the default, and every install on those systems ran/usr/bin/unpackinstead ofuntar, failing withunpack: <file>: cannot open. The guards now comparecommand -v's output to the name — it prints the bare name for a function or builtin and an absolute path for an external program. Applies to all six hooks and tochecksum_nameinmain.sh. -
The installer leaked its temp directory on every failure path.
executeremoved it as its last statement, so all six|| return 1before that point left it behind — and a failed install is more likely to be retried than a successful one, so they accumulated inTMPDIR. The body is now_shlib_executeandexecutecleans up around it, whatever the outcome. (Not anEXITtrap, which is the idiommktmpdirdocuments:assert.shinstalls its ownEXITtrap to print test totals, andexecuteis called directly by the tests.) -
A failed download reported only the downloader's own message.
curl: (22) The requested URL returned error: 404names neither the project nor the URL, and the installer then exited silently. It now says:owner/repo err unable to download https://github.com/owner/repo/releases/download/v1.2.3/asset.tar.gzwhich is the information needed when a tag or an
archive_namedoes not match the real assets — the most common config mistake.
Added
-
make lintnow rejects flags Solaris does not have —head -c,grep -o/-E/-F/-x/-f,find -maxdepth/-mindepthandsed -i— in the library, the tests andinstall/. None of these fail usefully there: SVR4headprints its usage line and exits 2, so a pipeline yields the usage text or nothing, and the bug surfaces only when a Solaris CI leg runs. This is the fourth SVR4 surprise in this repo's history and the first one caught before it shipped.scripts/is exempt as developer tooling. -
http_download_node, the same idea for JavaScript runtimes.node:22-slimships neither curl, wget nor python3 — only node and a perl that cannot do TLS — so it was the one measured image shlib could not fetch anything in. It uses the globalfetch()(node 18+) rather than thehttpsmodule, which does not follow redirects; every GitHub release download redirects toobjects.githubusercontent.com. The body is streamed to disk rather than buffered, since release artifacts run to hundreds of megabytes. Accepts eithernodeornodejs, the name Debian and Ubuntu used for years. Tried afterpython3, so an image with both is deterministic. -
http_download_python, a downloader of last resort for container images. Measured:python:3.12-slim,debian:stable-slim,ubuntu:24.04andnode:22-slimship neither curl nor wget, so installing a tool in any of them failed withunable to find curl, wget, fetch or ftp.python3'surllibcloses that gap for the images that have it.It is tried last, after every base-system tool, and uses
python3only — never barepython, which may be python2, and pythons before 2.7.9 do not verify TLS certificates at all.It is also the only fallback branch that can send request headers, so
github_releasecan resolvelatestthere —fetch(1)and bothftp(1)s cannot. Covered by apython:3.12-slimCI leg that asserts no downloader is installed, then runs the full suite and a real install through it.No perl branch: Debian's
perl-basehas neitherIO::Socket::SSLnorLWP, and those images carry noopensslbinary, so perl cannot reach an HTTPS URL — it is present exactly where it cannot help.debian:stable-slimandubuntu:24.04stay uncovered, but not for lack of networking: they shipapt-helper, a working HTTPS client, and zero CA roots. Nothing can verify a TLS peer there.aptitself is unaffected because Debian and Ubuntu fetch packages over plain http and verify them by GPG signature. See docs/PORTABILITY.md. -
unpack, a hook for releases that are not archives.executecalleduntarunconditionally, anduntarrefuses anything without a recognised archive suffix, so a project publishing bare binaries could not be installed at all. Configs now override:FORMAT="" unpack() { :; } binary_path() { echo "${TARBALL}"; }Existing configs are unaffected — the default is
untar "\$1", defined behind the samecommand -vguard asbinary_pathandlatest_version.Unpacking is a hook rather than a
FORMAT=binaryvalue because the two are independent: hadolint's windows asset ishadolint-windows-x86_64.exe, a non-empty suffix that is still not an archive, and one field cannot express both.install/examples/hadolint.shis the worked example. -
FORMATmay now be empty.TARBALLis built by a newtarball_namefunction so the suffix is omitted rather than leaving a trailing dot, and so the construction can be unit tested — it previously lived inline inmain.sh, which tests cannot source.
Changed
-
Reframed what shlib says it is. It described itself as "portable posix shell functions", which undersold it in both directions: POSIX describes neither where the code runs (Windows via git bash is not a POSIX environment; Solaris and illumos ship pre-POSIX tools) nor what it provides (install-script primitives — detect the platform, download, verify, unpack, place a binary). POSIX
shis the constraint that makes those travel, not the product. The preamble inlicense.sh, which ships in every vendored copy, now reads:https://github.com/client9/shlib - portable shell functions for install scriptsThe
POSIX sh onlycoding rule is unchanged. Documentation also stopped claiming shlib "does not claim to run" on Windows, which contradicted a Windows CI leg that runs the full suite. -
The platform names are documented as shlib's own, not Go's. They still match GOOS/GOARCH — that is where the artifact-naming convention came from, and compatibility with it is deliberate — but Go is now provenance rather than authority.
docs/API.mdgains a How a name gets added section stating the rule (a realunamemaps to it, and projects name artifacts that way), which is what the three long-standing deviations already follow:midnightbsd,armv5/armv6/armv7, and the retainednacl/amd64p32. No mapping, no accepted value, and no return value changed. -
The self-check error messages no longer say GOOS / GOARCH, which meant nothing to anyone installing a non-Go binary:
uname_os_check 'Haiku' got converted to 'haiku' which is not a recognized OS name uname_arch_check 'sparc64' got converted to 'sparc64' which is not a recognized architecture nameAnything matching on the old
not a GOOS valuetext — the phrase is a useful fingerprint for stale vendored copies — should note that it now identifies a copy predating this release.
2026.08.28
Fixed
- The minifier silently dropped code. The documented strip pipeline
(
grep -v '^#' | grep -v ' #') also deleted code lines carrying trailing comments. In the2026.08.27bundle that removed thewin*) os="windows"mapping — soWindows_NTresolved towindows_nt, not a GOOS — and bothgitrepo=assignments fromgit_clone_or_update. Now only whole-line comments are stripped. Anyone who vendored2026.08.27should re-vendor. http_downloadhad nofetchbranch, so it failed outright on stock FreeBSD, which ships neither curl nor wget.github_releaseand the whole installer were unusable there.- Same gap on OpenBSD and NetBSD, which ship neither curl, wget nor fetch.
Their base downloader is
ftp(1), now a branch of its own — see below. - The library aborted under
set -u. Every optional argument was read as a bare$2/$3, which nounset treats as an error rather than an empty string, sogithub_release owner/repo(tag omitted — the documented form),http_download file url(no header),hash_sha256reading stdin, andgithub_apiwithoutGITHUB_TOKENall killed the calling shell. The installer was worse:curl … | shwith no tag argument aborted inparse_argsbefore anything was downloaded, and a config withoutPLATFORMSorchecksum_nameaborted too. An install script is exactly the kind of thing people run underset -eu. All optional parameters are now${n-};nounset_test.shcovers every documented call form. github_releasereturned an empty tag on Solaris.trconvertedecho's trailing newline to a space, leavingsedan unterminated final line, which SVR4 sed drops. Only surfaced once there was a Solaris CI leg.github_releasereturned whatever itssedfound, so a non-GitHub host or an error page yielded a garbage tag (<!DOCTYPE html> <html lang=) and a baffling 404 later. The result is now validated.- The installer used
install(1)to place binaries. It is not in POSIX and Solaris/illumos ship the SVR4 version with different grammar, so installs failed there entirely. mktmpdirtrustedmktemp's default mode.mktempis not in POSIX and git-bash creates0755; the mode is now set explicitly.- Internal variables leaked into the caller's shell. Calling
github_releaseoverwrote$version,uname_osoverwrote$os, and 26 more. All internals are now prefixed_shlib_.
Added
-
install/— a replacement for the archived godownloader. An install script isconfig.sh+dist/install-base.shconcatenated: no Go, no YAML, no template language. See docs/INSTALLERS.md and five worked examples. -
http_download_fetch, for FreeBSD. Notefetch(1)cannot send arbitrary headers;Acceptis mapped viaHTTP_ACCEPTand anything else fails loudly rather than being silently dropped. -
http_download_ftp, for OpenBSD and NetBSD, whose only base downloader isftp(1)— which despite the name does HTTP and HTTPS. Two different programs answer to that name and they differ in the one way that matters: NetBSD's tnftp has-Hfor arbitrary request headers, OpenBSD's ftp has no header support at all. The branch probes the binary's own usage line rather than trustinguname -s, and fails loudly where a header cannot be sent. Dispatch order is curl, wget, fetch, ftp;ftpis last because a Linux box may carry a legacy client that cannot fetch a URL at all.Acceptis refused on this branch even where-Hexists. tnftp writes its ownAccept: */*ahead of any-Hheader and nothing suppresses it, so the caller's Accept is delivered but never wins — GitHub answered with the HTML release page. Refusing beats returning the wrong representation.fetch(1)is unaffected:HTTP_ACCEPTreplaces its Accept rather than adding to it.Net effect for OpenBSD and NetBSD users: downloading a release artifact and its checksum file works, so an installer pinned to an explicit tag is fine. Resolving
latestthroughgithub_releasedoes not, and says so. -
OpenBSD, NetBSD and DragonFly CI, under QEMU via
vmactions. Beyond the full suite these assert thatuname -smaps toopenbsd/netbsd/dragonfly, and that theftp -Hprobe agrees with the real binary on each — in opposite directions, so either usage line changing is caught. OpenBSD and NetBSD also assert curl, wget and fetch really are absent, since there the absence is what makes theftp(1)branch run. NetBSD downloads overftp -Hfor real and pins the Accept refusal; DragonFly callshttp_download_fetchdirectly, including with an Accept header, which is the only proof that what these downloaders send is what GitHub accepts. -
nounset_test.sh, which runs every documented call form underset -u. -
install_exe— copy a file into place and make it executable. Placing a binary portably is a shell primitive, so it belongs in the library rather than inside the installer:install(1)is not in POSIX and its grammar differs by platform. It unlinks the destination first, so replacing a binary that is executing cannot hit ETXTBSY. -
docs/API.md — a generated index of every function, plus the recognised GOOS/GOARCH values and the non-obvious
unamemappings (includingSunOS→illumos/solaris).make docsregenerates it and CI fails if it drifts. -
Solaris and illumos CI (
vmactions/solaris-vm,vmactions/omnios-vm), running theSunOS→solaris/illumosbranch on real systems. That branch has caused more bugs than any other mapping here and had only ever been tested with a stubbeduname. -
Windows CI (git-bash and MSYS2), verifying that
MINGW64_NT-*andMSYS_NT-*really do map towindows— previously only ever tested with a stubbeduname. -
.gitattributes, pinning the tree to LF and markingfixtures/**as binary. Without it Windows checks fixtures out as CRLF and every hash test fails with a digest that looks arbitrary. -
A pre-commit hook (
make hooks) running the fast, offline half of CI. -
DOWNLOAD_BASEandlatest_version()hooks, so an installer can fetch from somewhere other than GitHub. Defaults are unchanged. -
Tests for
log.sh(35 assertions) and thefetchbranch.
Changed
- Error messages no longer end with "Please file bug at github.com/client9/shlib". Roughly 812 stale copies carry that line and send their bugs here — usually for problems fixed years earlier. Install-script authors should surface their own support URL.
- Assertions hold back stderr and print it only on failure. A passing run was emitting 25 lines of expected noise, which buried real failures.
actions/checkoutv4 → v7 (the v4 Node 20 runtime is deprecated).
2026.08.27
The first tagged release. shlib had been consumed by copy-and-paste since 2017, which is the problem this release exists to address: bugs fixed here never reached the copies. Roughly 920 vendored copies are in the wild; about 258 still lack a Windows fix from 2018.
This bundle has a known defect — see the minifier bug under Unreleased. Prefer the next release.
Added
- Versioned, downloadable bundles:
dist/shlib.sh(commented),dist/shlib.min.sh(stripped) andchecksums.txt, committed to the repo and attached to each release. Each carries its version in the preamble, so a bug report can be dated at a glance. - GitHub Actions replacing the dead Travis config, covering 13 shells across Linux (glibc), Alpine (musl), macOS and FreeBSD.
- A test suite that actually exercises the mapping tables:
unameis stubbed, so every branch runs on every machine rather than only the host's. untarsupport for.tar.bz2,.tar.xzand.tar.zst. Note busyboxtarhas no--zstd, so that path decompresses through a pipe.riscv64andloong64architectures;aix,ios,jsandwasip1operating systems.- Documentation on embedding and releasing.
Fixed
hash_sha256_verify/hash_sha512_verifymatched the filename with an unanchoredgrep, treating it as a regular expression. A checksum listed forevil-foo.tgzwould verifyfoo.tgz. Now an exact field match, and duplicate entries are refused rather than silently resolved.- The
opensslfallback inhash_sha256andhash_sha512had never worked — it ranopenssl -dst openssl dgstand thencut -f a, an illegal field spec. uname_osused==inside[ ], a bashism. Under any POSIX shell the illumos check silently fell through tosolaris.- The hash functions passed
/dev/stdinas a filename. ksh93 implements pipelines with socketpairs, so that path cannot be opened and every stdin-based hash failed there. mktmpdirreturned the shared$TMPDIRwhenever it was set — always, on macOS and in most CI — so callers shared one predictable directory.http_copyleaked its temporary file on the failure path and round-tripped the body through a variable, stripping trailing newlines.
Changed
- The test harness reports all failures instead of aborting at the first one.