Fetch Dependencies
The bootstrap problem. This project's specs live in Artifactory. The Artifactory CLI is itself an envy package, so it has to be installed before any of those specs can be fetched. Ordinary dependencies cannot express that. They order phases of packages whose specs envy already has. Fetch dependencies order the acquisition of the spec itself.
The shape
A source table carries both the tools it needs and the custom fetch that uses
them. Here it is on a manifest BUNDLES declaration, fetching a bundle of specs
out of Artifactory with a jf that envy installed:
BUNDLES = {
corp = {
identity = "corp.specs@r1",
source = {
-- Installed end to end, including setup, before the bundle is fetched.
dependencies = {
{ spec = "tools.jfrog-cli@r1", product = "jf",
source = envy.abspath("envy/tools.jfrog-cli.lua") },
},
fetch = function(tmp_dir)
envy.run(envy.template([[
{{jf}} config add ci --url={{url}} --access-token=$JF_TOKEN --interactive=false
{{jf}} rt download --flat --fail-no-op '{{repo}}/' '{{dest}}'
]], {
jf = envy.product("jf"), -- provided by tools.jfrog-cli
url = "https://acme.jfrog.io/artifactory",
repo = "envy-specs/corp.specs@r1",
dest = tmp_dir .. "/",
}))
envy.commit_fetch({ "envy-bundle.lua", "specs" })
end,
},
},
}
PACKAGES = {
{ spec = "corp.toolchain@r2", bundle = "corp", options = { version = "15.2" } },
}
The fetch dependency declares product = "jf", so the fetch function resolves
the tool with envy.product("jf") and gets an absolute path into the cache.
Nothing assumes a jf on PATH, and nothing hardcodes a cache path. Wrapping
the command in envy.template keeps the two jf invocations and the three
varying values in one place. envy.package(identity) is available the same way
when you need the dependency's whole directory rather than one product.
fetch writes into tmp_dir and calls envy.commit_fetch, which verifies any
hashes and moves the files into the durable fetch directory. A bundle commits
its envy-bundle.lua and spec files. A single spec commits spec.lua, plus
any helper files beside it: everything committed lands in the spec directory,
so envy.loadenv("helper") from inside the fetched spec finds it.
Where it can be declared
A BUNDLES declaration, in a manifest or a spec, and a spec DEPENDENCIES
entry. A manifest PACKAGES entry cannot carry source.fetch, and since envy
0.3.1 that is a parse error rather than a failure part way through the run:
error: Package 'source' cannot be a { fetch = ... } table: nothing can ever call
that function from here. Declare the spec in another spec's DEPENDENCIES,
or the bundle in a BUNDLES table.
A spec-declared source.fetch receives (tmp_dir, options). A bundle's fetch
receives (tmp_dir) only.
Everything a spec-declared fetch can see belongs to the dependency it is fetching, not to the spec whose file holds the closure:
optionsare the entry's own.{ spec = "corp.sdk@r1", options = { region = "eu" }, source = { fetch = ... } }hands the function{ region = "eu" }whatever the declaring spec was built with.envy.productandenvy.packageresolve the entry's ownsource.dependencies, so a tool the entry declares is reachable and one the declaring spec declares is not.- Each option set gets its own spec cache entry. A closure has no fingerprint to hash, so the options are what keep two variants apart.
Before envy 0.3.1 the function ran in the declaring spec's context, which handed it the wrong options table and hid its own declared prerequisites.
The ordinary case, for comparison
Once a spec is loaded, its own verbs resolve tools the same way, and needed_by
decides how early that is legal:
local hashes -- version -> sha256, at the bottom of this file
DEPENDENCIES = {
{ spec = "tools.jfrog-cli@r1", product = "jf", source = "tools.jfrog-cli.lua",
needed_by = "fetch" },
}
FETCH = function(tmp_dir, opts)
envy.run(envy.template([[
{{jf}} rt download --flat --fail-no-op 'toolchains/{{version}}.tar.zst' '{{dest}}'
]], {
jf = envy.product("jf"), -- provided by tools.jfrog-cli
version = opts.version,
dest = envy.path.join(tmp_dir, "toolchain.tar.zst"),
}))
envy.commit_fetch({ filename = "toolchain.tar.zst", sha256 = hashes[opts.version] })
end
needed_by = "fetch" is what legalizes the call in FETCH. The default,
build, produces envy.product: product 'jf' needed_by 'build' but accessed during 'fetch'. A fetch dependency needs no needed_by, because it is already
gated on the earliest phase there is.
On Windows
The bootstrapped tool is an executable in a cache directory, so nothing about the mechanism changes. Two things about the fetch function do:
- Resolve the tool with
envy.product, then build the command line withenvy.path.join. The resolved path contains backslashes, and a hand-built"/"path will not survive being handed tocmd. - A script string inside the fetch function runs under the platform default, so
PowerShell on Windows. A one-line CLI invocation is usually identical in both
dialects, but redirection and quoting are not. Prefer passing the command to
envy.runas a single line and letting the tool write the file itself.
Rules
-
The guarantee. Every entry in
source.dependenciesgoes through its entire lifecycle, install and setup included, before the dependent's spec is fetched. Compareneeded_by = "fetch", which gates payload fetching only. -
source.dependenciesrequiressource.fetch. If nothing custom runs, the tool was not needed. Asourcetable with neither is an error: a URL or a path is written as a plain string, not a table. -
No
needed_byon asource.dependenciesentry. A fetch prerequisite always blocks the parent's spec fetch, which is the earliest gate there is. Declare the dependency in the spec'sDEPENDENCIESto couple it to a later phase instead. Since envy 0.3.1 the key is refused rather than ignored. -
Strong references only, through the whole closure. Every
source.dependenciesentry needs its ownspecandsource:error: source.dependencies entry 'tools.jf@r1' in spec 'corp.sdk@r1' must be astrong referenceThe rule extends transitively. A fetch dependency and everything it pulls in run their phase ladders during resolution, so nothing in that closure may hold an unresolved weak reference either:
error: source.dependencies closure must use strong dependencies: 'corp.tls@r1'holds a weak reference to 'openssl' but runs outside the window whereweak references resolveThe weak pass runs at a resolution barrier, after every spec fetch, including the one still waiting on this function. There is no point at which a weak reference could be ordered in time. The same applies to the
PACKAGE_DEPOTSandDEFAULT_SHELLclosures, which run in the same window. -
Products work inside the fetch function.
source.dependenciesentries are wired withneeded_by = spec_fetchbefore it runs, soenvy.product("jf")andenvy.package(identity)resolve there. -
Chains work. A fetch dependency's own spec can have fetch dependencies, and bootstrap chains resolve bottom-up.
-
Cycles are detected among fetch dependencies and reported.
The same machinery appears twice more without being called this: fetching
bundles, and authenticated depot indexes with
DEPENDS, which hands its fetch function ctx.deps[identity].pkg_path.