
bashdeps, exact dependencies without a package manager
11 min read
A few of my recent Bash projects consume small external artifacts during their builds. adrctl, for example, consumes mktext, and other projects use bash-doxygen to generate reference documentation.
A common solution is to put a few curl commands and checksum checks in the Makefile, download the files into vendor/, and move on.
That works until the same security-sensitive logic exists in several projects, each copy evolves slightly differently, and one of them eventually makes the wrong assumption.
That happened in adrctl. A cached mktext artifact remained at the expected path after the desired dependency version changed. The surrounding build metadata said one thing while the bytes actually embedded in the generated artifact came from an older version.
The filename was right. The bytes were wrong.
That failure became the reason for bashdeps.
bashdeps is deliberately smaller than a package manager. Its runtime executable, bashdeps.bash, reads exact dependency declarations, verifies bytes against committed SHA-256 digests, and materializes those bytes at declared project-relative destinations.
The project also includes manifest-manager.bash, a separate maintainer-side CLI for inspecting those declarations and preparing deliberate source changes. The two tools share a manifest format and release lifecycle while preserving a useful boundary: one consumes approved dependency state, while the other helps a maintainer prepare proposed changes to that state.
Neither tool decides which dependency a project should trust. The repository makes that decision through the manifest and its normal review process.
The manifest is the contract
The conventional manifest is dependencies.txt. A dependency record contains four required named fields:
id=wesley-dean/mktext@VERSION \
url=https://github.com/wesley-dean/mktext/releases/download/vVERSION/mktext.bash \
dest=vendor/mktext.bash \
digest=sha256:DIGESTThe important field is the digest. id is useful metadata, and the URL tells bashdeps where to obtain a missing artifact, but ordinary byte acceptance is determined by the committed SHA-256 digest.
That means the consuming project owns the approval decision. If upstream publishes a checksum, the project can use that value when deciding what digest to commit. If upstream does not publish one, the project can calculate the digest of the exact artifact it reviewed and commit that value itself.
There is no hash-optional mode.
That is intentional. Without an approved digest, bashdeps would have no reliable way to distinguish the dependency the project approved from stale, corrupted, modified, or silently replaced bytes that happened to have the expected name.
Corroborating the committed digest
Some upstream projects publish SHA-256 checksum files beside their release artifacts. bashdeps can now use those checksums as supplemental evidence during acquisition through an optional fifth field:
id=example/tool@VERSION \
url=https://example.test/releases/VERSION/tool.bash \
dest=vendor/tool.bash \
digest=sha256:DIGEST \
digest_url=https://example.test/releases/VERSION/tool.bash.sha256The distinction between digest= and digest_url= is important.
digest= remains the consuming repository’s approval boundary. It is committed source that states which artifact bytes the project has chosen to accept. digest_url= names an explicit HTTPS resource containing an upstream-published SHA-256 checksum that can corroborate newly acquired bytes.
When digest_url is present and bashdeps must download an artifact, the candidate must satisfy both checks. Its SHA-256 digest must match the value committed in digest=, and it must also match the SHA-256 value retrieved from the declared digest_url resource. Either comparison can reject the candidate; the live upstream checksum cannot replace or rewrite the committed digest.
That last boundary matters. An artifact and an adjacent checksum file can both be replaced by the same compromised release path and still agree with each other. Treating the live checksum as the authority would therefore weaken the original trust model. Requiring agreement with the repository’s committed digest makes the upstream checksum an additional rejection signal instead.
The runtime also avoids turning this feature into a standing network dependency. If the destination already contains bytes that match the committed digest=, install and sync do not retrieve digest_url. The verify command remains network-free and checks local state against committed source only.
Declaring digest_url does introduce an intentional availability tradeoff when a new acquisition is required. If bashdeps cannot retrieve or validate the specified checksum resource, it rejects that acquisition even when the artifact matches the committed digest. A project opting into the extra corroboration is also choosing to require that corroboration when new bytes are downloaded.
The runtime does not guess where that evidence lives. It does not append .sha256, inspect a release page, or derive a checksum URL from url=. The manifest states the location explicitly.
sync, verify, and install
The common operation is:
bashdeps.bash syncWith no additional arguments, bashdeps reads dependencies.txt and uses vendor/ as the allowed destination root.
sync validates the complete manifest before publishing anything. It identifies missing or mismatched artifacts, downloads the required candidates, performs the required digest checks, and only then begins intentional publication.
For CI or other checks where network access would be undesirable, there is a separate verification path:
bashdeps.bash verifyverify performs no network access and no intentional filesystem mutation. It succeeds only when every declared destination already contains the bytes approved by its committed digest= value. An optional digest_url= is validated as part of the manifest syntax, though its remote resource is not retrieved.
There is also an install command for a single explicitly declared artifact. That is useful when a manifest is unnecessary or when another tool is generating the declaration directly.
The distinction between synchronization and verification is something I wanted to make explicit. A command that claims to verify existing dependency state should not silently repair that state or reach out to the network while doing so.
Maintaining manifests deliberately
As I started using bashdeps across more repositories, another repeated task appeared: maintaining the dependency declarations themselves.
Editing a four- or five-field record by hand is manageable. Programmatic maintenance is more interesting because changing a dependency can mean changing an identity, release URL, digest, and possibly an upstream checksum URL while preserving surrounding comments, blank lines, destinations, and unrelated records.
That became the job of manifest-manager.bash.
Its current command surface is deliberately narrow:
manifest-manager.bash list
manifest-manager.bash add id=... url=... dest=... digest=... [digest_url=...]
manifest-manager.bash remove ID
manifest-manager.bash update OWNER/REPO [VERSION]
manifest-manager.bash update --alllist validates the selected manifest and emits its complete dependency identities in manifest order. add appends one declaration supplied explicitly by the maintainer. remove deletes the record whose complete identity matches the requested value. Those three operations require no release discovery, and add does not calculate a digest or invent an artifact URL, destination, or checksum location.
update has a different job. For supported GitHub raw-content and release-download URL forms, it can discover the latest release or accept an explicit version, retrieve the candidate artifact, calculate its proposed digest, and update the selected manifest record. If that record already declares digest_url, the manager also preserves that relationship, derives the corresponding updated URL only within the supported GitHub form, and verifies the upstream checksum while preparing the change. It does not add digest_url to a record that did not already declare one.
The manager fails when it cannot establish those relationships safely rather than guessing how a repository names versions, artifacts, or checksum resources.
I consider the separation between the runtime and the manager more important than the convenience of the commands themselves. bashdeps.bash does not discover a new release and silently change what a repository trusts. manifest-manager.bash can prepare that source change, but the resulting manifest remains a proposal until the normal review and commit process accepts it.
The manager also preserves the existing manifest source instead of regenerating the whole file from parsed fields. An update changes the intended record fields; an add preserves the original file as an exact prefix before appending the new record; a remove omits the selected logical record while leaving neighboring comments and blank lines alone. That makes automated maintenance easier to review because unrelated formatting does not need to change with the dependency.
The destination boundary matters, too
Verifying downloaded bytes is only part of the problem. A dependency tool also writes files into a repository, so where it is allowed to write is a security boundary.
By default, bashdeps requires destinations to be strictly beneath vendor/:
dest=vendor/mktext.bash
dest=vendor/doxygen-bash.awkAbsolute paths, traversal components, repeated separators, textual aliases such as ./path, and existing symbolic-link path components are rejected.
A project can intentionally choose another destination root:
bashdeps.bash sync --dest-root assets dependencies.txtThat changes the allowed containment boundary for the invocation. It does not rewrite the paths in the manifest or turn --dest-root into an implicit prefix.
The goal is to make a change in write policy visible and reviewable rather than a side effect of a dependency record.
It manages artifacts, not packages
The name can invite the wrong mental model, so this distinction is worth making explicit: bashdeps does not know what a dependency means.
A managed artifact can be a Bash library, a script, a Doxygen filter, a template, a data file, an image, or some other ordinary file. bashdeps does not infer whether the file should be executable, what language it contains, or how the consumer intends to use it.
Newly materialized files use mode 0644. If a consuming build needs an artifact to be executable, that policy belongs to the consuming project rather than being guessed from a filename, URL, extension, or shebang.
Similarly, an identity such as project@1.2.3 is opaque metadata to the runtime. bashdeps does not interpret semantic versions and does not decide that 1.2.4 would be a better choice.
The manifest manager interprets a narrower GitHub-oriented identity and URL relationship only when a maintainer explicitly asks it to perform an update. That maintenance convenience does not change the runtime manifest semantics or turn bashdeps into a version solver.
That is package-manager territory, and there are already tools designed for that problem.
The trust claim is intentionally narrow
SHA-256 verification is useful, though its assurance can be overstated.
A successful local verification means the bytes at the destination match the digest approved by the consuming repository. A successful new acquisition with digest_url additionally means those candidate bytes agreed with the explicitly declared upstream checksum at acquisition time.
Neither result proves that the software is safe, free from vulnerabilities, correctly labeled, or trustworthy merely because it came over HTTPS or because multiple hashes agreed.
The manifest itself is trusted source code. A change that modifies the URL and the approved digest changes which bytes the repository trusts, so that change should receive the same kind of review attention as other supply-chain-sensitive source changes. manifest-manager.bash can make such a change easier to prepare without making the review decision disappear.
I like that boundary because it is modest enough to be true. bashdeps does not try to solve software supply-chain security in general. It solves a specific, repeated problem: make sure the build consumes the exact external bytes the repository says it approved, and reject new acquisitions when explicitly declared corroborating evidence disagrees.
Bootstrap remains bootstrap
There is one unavoidable wrinkle: bashdeps cannot use itself to obtain bashdeps before it exists.
A consuming repository therefore keeps one small bootstrap path for the pinned bashdeps executable, usually in its Makefile. Once that executable has been obtained and verified, dependencies.txt can own the project’s ordinary external artifacts.
A typical boundary looks like this:
Makefile
-> bootstrap and verify vendor/bashdeps.bash
-> make deps
-> bashdeps.bash sync dependencies.txt
-> vendor/mktext.bash
-> vendor/doxygen-bash.awkThat bootstrap is not hidden or hand-waved away. The benefit is that one small, reviewable bootstrap path replaces several independent download, cache, verification, staging, and publication implementations spread across a project.
bashdeps is available on GitHub, including its runtime and manifest-manager executables, manifest specification, Architecture Decision Records, tests, and release artifacts.
Next, I’ll look at bashlog, a sourceable logging library that applies the same preference for explicit boundaries to logging, presentation, and redaction.