download provider
A download stage brings prebuilt release archives into the environment:
fetch, checksum, unpack, put on PATH. It is what the “download a release
tarball by hand” shell script (see the worked example in
custom) looks like once it is a provider — the same four
jobs, but idempotent, checksum-verified and --fast-aware without every
project writing that logic again.
[ninja-setup]
provider = "download"
[[ninja-setup.packages]]
name = "ninja"
url = "https://github.com/ninja-build/ninja/releases/download/v1.13.2/ninja-linux.zip"
sha256sum = "5749cbc4e668273514150a80e387a957f933c6ed3f5f11e03fb30955e2bbead6"
env-prepend = { PATH = "${DENVER_UNPACK_DIR}:" }
(provider:/description:/disabled:/depends-on:/scripts:/env:/env-prepend:/env-append: are generic keys every stage has —
see “Generic stage keys” in Configuration. Everything below is specific to download.)
Key reference
The stage has exactly one key of its own:
packages— a list of packages, one[[<stage>.packages]]table per archive. They are processed in the order they are written, each one fully (download, unpack, environment) before the next.
Package keys
name(required) — this package’s id: it names the default unpack dir and prefixes every log line about it. Unique within the stage.url(required) — where to fetch the archive from. Must behttp(s);${...}interpolation works (e.g. an internal mirror in[env]).mirrors— a list of alternative urls, tried in order ifurlfails (and then each other, in the order written) until one succeeds. “Fails” includes a checksum mismatch, not just a transfer error — a mirror serving the wrong bytes is treated the same as one that is unreachable, and the next source is tried. Only when every one of them fails does the download fail. Same rules asurl:http(s)only,${...}interpolation works. Each attempt logs a line — what it is fetching, and, on failure, that it is falling back to the next mirror — so a run that needed one is never silent about it.method—GET(default) orPOST. Needed for the release that won’t hand over its archive to a plainGETat all — a download gated behind accepting a license agreement, say, where the real transfer only starts once that acceptance is posted.data— the request body to send withmethod: POST, sent exactly as written (${...}interpolation works, e.g. a token from[env]) withContent-Type: application/x-www-form-urlencoded— the same default curl’s own-d/--datasends, for a server whose form expectskey=value&key=valuepairs. Only valid alongsidemethod: POST; on aGET(the default) there is no body to send it in, and denver says so rather than silently dropping it.[jlink-setup] provider = "download" [[jlink-setup.packages]] name = "jlink" url = "https://www.segger.com/downloads/jlink/JLink_Linux_V882_x86_64.tgz" method = "POST" data = "accept_license_agreement=accepted&submit=Download+software" md5sum = "1691b1c79764bf1caade424cc39c2e0c" unpack-cmd = 'tar -xf "$DENVER_DOWNLOAD_ARCHIVE" --strip-components=1' env-prepend = { PATH = "${DENVER_UNPACK_DIR}/bin:" }
mirrors:(above), if given, are sent with the samemethod:/data:— they are alternative sources for the same request, not a different one.outfile— the file name to store the archive under, inside the downloads folder (default: the file name theurlends in). An absolute value puts the archive wherever it names instead.sha256sum/md5sum— the expected checksum of the downloaded file. Both optional, both checked when given. Without either, whatever the url serves at any given time is trusted — which is exactly what pinning a version in the url alone does not protect you from.unpack-dir— where the archive is unpacked (default:<env workdir>/download/<name>). Relative values resolve like every other denver path — against the env dir, then imported base envs.unpack-cmd— a shell command that unpacks the archive itself, replacing python’s own archive handling. See “Unpacking” below.env-prepend/env-append— what the unpacked package contributes to the environment, as{ VAR = "value" }. A value is used exactly as written, once${...}-interpolated — the same rule the generic per-stageenv-prepend:/env-append:keys follow (see “Generic stage keys” in Configuration) — plus one interpolation variable these don’t have:${DENVER_UNPACK_DIR}, this package’s ownunpack-dir:(above).env-prepend:glues the result directly in front of whatever the variable already holds,env-append:directly behind it — no separator inserted, so a value that needs one (almost always true for a:-joined variable likePATH) has to carry it itself: a trailing:forenv-prepend:("${DENVER_UNPACK_DIR}/bin:"), a leading:forenv-append:(":${DENVER_UNPACK_DIR}/share").description— free text about this package, for whoever reads the config. denver never acts on it; it only shows up in--show-config.
Authenticated downloads
A url behind a login is served by the top-level [[download-auth]] entries —
credentials per host, not per package:
[[download-auth]]
host = "artifactory.example.com"
username = "${ARTIFACTORY_USER}"
password = "${ARTIFACTORY_TOKEN}"
[[download-auth]]
host = "api.github.com"
headers = { Authorization = "Bearer ${GH_TOKEN}", Accept = "application/octet-stream" }
They are a top-level key, not a stage key, because a token belongs to a
server: one entry covers every package of every download stage that
fetches from that host, and a base env can declare the company mirror’s
credentials once for every env importing it.
host(required) — the host these credentials belong to, matched case-insensitively against the url’s own host. Write it ashost:port(e.g.nexus.example.com:8443) to match only that port; without a port it matches the host on its default port. The first entry matching a url wins, and nothing later is merged into it.username/password— sent as an HTTP BasicAuthorizationheader. Optional, but if either is set both must be — a half-configured login is an error, not a bare request (the same ruledocker:’sregistries:follows).headers— headers to send verbatim, as{ Name = "value" }. This is what bearer tokens and vendor-specific schemes need (Authorization = "Bearer ...",PRIVATE-TOKEN,X-JFrog-Art-Api, …). AnAuthorizationwritten here wins over one built fromusername:/password:.
An entry that would send nothing at all (no username:, no headers:) is an
error rather than a silently unauthenticated download.
Write the secret as ${VAR}, not as a literal. Credential values are
interpolated per fetch, at the moment the request is built — never while the
config is resolved — so --show-config prints the ${ARTIFACTORY_TOKEN} the
file was written with and never the token it stands for. A ${VAR} that is
unset in the environment is a hard error, not an empty password sent to the
server.
Credentials never follow a redirect off their host. Release downloads
redirect constantly (a github/gitlab/artifactory url answers 302 and hands
the transfer to a CDN or a pre-signed S3 url), and urllib would otherwise
replay the Authorization header written for one host at whatever host it
is pointed at next. denver drops every configured auth header as soon as a
redirect changes scheme, host or port. A 401/403 says which
[[download-auth]] entry was missing, or that the one it found was
rejected.
Where things go
<env dir>/.denver/<config stem>/ # the env's workdir (DENVER_ENV_WORKDIR)
├── downloads/ # the archives -- never deleted by denver
│ └── ninja-linux.zip
└── download/ # the unpacked packages
└── ninja/ # <- unpack-dir: (default) -- env-prepend: above points at it explicitly
├── ninja
└── .denver-download # what this tree was unpacked from
The two tiers are deliberately different in kind. An archive is expensive
to fetch and cheap to keep, so nothing in this provider ever deletes one —
--force included. An unpacked tree is the opposite, so it is rebuilt
from the archive next to it whenever it no longer matches the config.
That is what the .denver-download stamp is for: it records the url,
outfile, checksums and unpack-cmd the tree was built from, and it is
written last, once unpacking succeeded. A tree whose stamp doesn’t match
the current config (a bumped url, a re-downloaded archive, a changed
unpack-cmd) is stale and gets rebuilt; a tree with no stamp at all was
never finished and is rebuilt too.
What runs, and what is skipped
Per package, in order:
Download — skipped when the archive is already there and passes its configured checksums. An archive already on disk that fails them is deleted and fetched again.
urlis tried first, then eachmirrors:entry in order on any failure of the one before it — and a checksum mismatch counts as a failure exactly like a transfer error: a mirror serving a stale or corrupted file is treated no better than one that is simply down, and the next source gets a chance. Only once every source has failed (transfer or checksum) does the download fail, naming what went wrong with each one it tried; the bad file is never left behind. The transfer itself goes to a.partfile that is checksummed and renamed into place only once complete and verified, so an interrupted or corrupted run never leaves a truncated or wrong archive that the next run would accept.Unpack — skipped when the stamp above says this exact package is already unpacked there. Otherwise the tree is removed and rebuilt: the archive is extracted into a staging dir next to
unpack-dir:and moved into place only once extraction succeeded, so a failure can never leave a half-unpacked tree behind.Environment —
env-prepend:/env-append:are folded into the environment. Never skipped: this is the activation half, the thing later stages and the final command actually depend on.
Unpacking
Without unpack-cmd:, the archive is unpacked by python itself
(shutil.unpack_archive), which picks the format from the file name —
.zip, .tar, .tar.gz/.tgz, .tar.bz2, .tar.xz.
Two things happen on top of that:
Executable bits from a zip are restored. Python’s
zipfiledeliberately ignores the unix permissions a zip records, so a release likeninja-linux.zip— whose whole payload is one executable — would otherwise extract as a non-executable file, and the next stage would fail with “permission denied”. (tar archives need nothing here;tarfilerestores modes itself.)A download that isn’t an archive at all — a bare binary, an AppImage — becomes the package’s single file, made executable, since that is the only thing such a release is ever for. No
unpack-cmd:needed for that case.
unpack-cmd: takes over from all of it when the archive needs something
python doesn’t do (a self-extracting installer, a nested archive, an
installer that needs flags). It runs via bash -c, with the staging dir
as its working directory — whatever it leaves there is what gets moved
into unpack-dir:. Three variables name what it is working on:
variable |
value |
|---|---|
|
the package’s |
|
absolute path of the downloaded file |
|
absolute path of the staging dir (= the cwd) |
Denver’s own ${VAR}/${VAR:-fallback} interpolation (see “Variable
interpolation” in Configuration) is
shell-quoted here before unpack-cmd: ever reaches bash — a substituted
value lands as one literal argument/word, whatever characters it contains,
rather than being re-parsed as shell syntax (the same rule custom’s
cmd: follows — see its own shell-injection note in
custom.md).
[[toolchain-setup.packages]]
name = "toolchain"
url = "https://example.invalid/toolchain-1.2.3.tar.gz"
sha256sum = "..."
# strip the archive's own top-level directory, so the paths below are correct
unpack-cmd = 'tar -xzf "$DENVER_DOWNLOAD_ARCHIVE" --strip-components=1'
env-prepend = { PATH = "${DENVER_UNPACK_DIR}/bin:", LD_LIBRARY_PATH = "${DENVER_UNPACK_DIR}/lib:" }
Design notes
Why a provider and not a
custom: cmd:script. The shell version of this (seecustom’s worked example) is ~30 lines that every project rewrites, and the parts that are easy to get wrong are the ones that only bite later: recognising an already-installed release, never accepting a truncated download, never leaving a half-unpacked tree that the next run then treats as finished. Here that logic exists once.Downloads are persistent state, not cache. They live in the env’s own workdir (
DENVER_ENV_WORKDIR, itself overridable) — next to.logs/, deleted with the env and with nothing else. They are not in the sharedDENVER_CACHE_DIR, which denver only ever points other tools at, never writes itself.Checksums are checked on the file, not on the transfer. An existing archive is verified on every run, not only right after it is fetched — a file corrupted on disk months later is caught the same way a bad download is. That costs one hash of a local file per start;
--fastskips it along with everything else.env-prepend:beforeenv-append:. A package’s ownPATHentry almost always wants to be prepended: a package exists to provide a specific version of a tool, and appending it would let whatever the OS happens to ship win instead.env-append:is there for the cases where the opposite is true (a fallbackMANPATH, a low-priorityCMAKE_PREFIX_PATH).Credentials are configured per host, once. Per-package credentials would mean the same token repeated in every entry fetching from the same server — and repeated again in the next stage, and in the next env that imports this one.
[[download-auth]]is matched against the url a package already has, so adding a package behind the same login needs no auth config at all. See “Authenticated downloads” above.--fastskips the download and the unpack entirely and only appliesenv-prepend:/env-append:; it dies with a clear message if a package has never been unpacked — run once without--fastfirst.--forcedoes nothing here, deliberately. This provider has no expensive computation to redo and no checksum-based skip to bypass: an archive that matches its checksum is the right archive, and an unpacked tree whose stamp matches is the right tree. To re-provision a package, delete itsunpack-dir:(or, to re-fetch, its archive).--dry-runreports the download and the unpack instead of performing them, and leaves whatever is already on disk untouched. The environment is still applied, for the same reasoncustom:’ssource:still runs: every later stage’s commands are rendered against it.