Skip to main content

Creating a Bundle

Distribute a family of specs as one versioned, pinnable unit. Consumers pin one commit instead of ten URLs.

When to use a bundle​

  • A shared toolchain. One organization, many repos, the same twelve tools.
  • Atomic upgrades. One ref advance moves every spec together, so a consumer cannot end up with half of a coordinated change.
  • Shared helpers. Specs in a bundle can require() Lua modules that ship beside them, which is how a bundle becomes a library rather than a folder. See Shipping an API with your specs.
  • Helpers your consumers call. From envy 0.4.6 a consuming manifest reaches those same modules with envy.loadenv_bundle, so a bundle can ship the code that writes its own consumers' entries.

For one or two specs, a URL with a sha256 is simpler. Reach for a bundle when the specs start sharing code or need to move together.

Layout​

A bundle is a git repo or an archive with a manifest at its root:

envy-bundle.lua
lib/
├── github.lua
├── platform.lua
└── versions.lua
specs/
├── cmake.lua
├── ninja.lua
└── uv.lua
envy-bundle.lua
-- @envy schema "1"
BUNDLE = "acme.specs@r1"

SPECS = {
["acme.cmake@r0"] = "specs/cmake.lua",
["acme.ninja@r0"] = "specs/ninja.lua",
["acme.uv@r0"] = "specs/uv.lua",
}

BUNDLE is the identity consumers pin. SPECS maps each spec identity to a file inside the bundle.

The identity-match rule​

A spec file's IDENTITY has to equal its SPECS key. specs/cmake.lua must declare IDENTITY = "acme.cmake@r0", and a mismatch is an error rather than a warning.

That rule means a bundle cannot promise one identity and deliver a spec that claims another.

Consuming it​

envy.lua
BUNDLES = {
acme = {
identity = "acme.specs@r1",
source = "https://github.com/acme/envy-specs.git",
-- envy git-resolve https://github.com/acme/envy-specs refs/heads/main
ref = "ded36a39bbf13744f5a0e539f2f4741fecb61dd0",
},
}

PACKAGES = {
{ spec = "acme.cmake@r0", bundle = "acme", options = { version = "4.4.0" } },
{ spec = "acme.ninja@r0", bundle = "acme", options = { version = "1.13.2" } },
}

The bundle is itself a package. It gets a cache entry, reports its own row in the output, and is fetched once no matter how many specs are taken from it.

Shared helpers​

Specs from the bundle require() modules by path, because envy prefixes package.path with the bundle root:

The helper most bundles end up with first is platform naming, because every spec in the bundle needs the same answer:

lib/platform.lua
-- Platform naming shared by the specs in this bundle.
local M = {}

M.WINDOWS = envy.PLATFORM == "windows"

-- Windows release artifacts are almost always zips, everything else tarballs.
M.ARCHIVE_EXT = M.WINDOWS and ".zip" or ".tar.gz"

-- Rust-style target triple, matching how Rust projects name their release
-- artifacts. Linux picks musl so the binaries do not depend on the host glibc.
function M.rust_triple()
if envy.PLATFORM == "darwin" then
return (envy.ARCH == "arm64") and "aarch64-apple-darwin" or "x86_64-apple-darwin"
elseif envy.PLATFORM == "linux" then
return (envy.ARCH == "x86_64") and "x86_64-unknown-linux-musl"
or "aarch64-unknown-linux-musl"
end
return "x86_64-pc-windows-msvc"
end

return M

Getting this wrong in one spec is a bug in one spec. Getting it right in lib/platform.lua fixes it for the whole bundle, which is most of the argument for bundling in the first place.

lib/github.lua
local M = {}

---@param repo string "owner/name"
---@param tag string release tag
---@param filename string release asset filename
---@return string url
function M.release_url(repo, tag, filename)
return "https://github.com/" .. repo .. "/releases/download/" .. tag .. "/" .. filename
end

return M
specs/cmake.lua
local github = require("lib.github")

FETCH = function(tmp_dir, opts)
return {
source = github.release_url("Kitware/CMake", "v" .. opts.version, asset(opts)),
sha256 = hashes[opts.version][platform_key()],
}
end

Write the LuaCATS annotations. Consumers of your bundle get completion and hover docs on your helpers through the .luarc.json that envy init maintains.

The pattern worth aiming for is a helper that returns whole verb sets, so a spec becomes a name, a repo, and a hash table. That is covered in Shipping an API with your specs.

Helpers your consumers call​

A module under lib/ is not only for the bundle's own specs. From envy 0.4.6 a consuming manifest loads one with envy.loadenv_bundle, which means the bundle can also ship the builder that writes its consumers' PACKAGES entries:

lib/github.lua
local M = {}

---One source tree from GitHub, vendored where the build system compiles it.
---@param name string leaf directory name under the consumer's VENDOR_ROOT
---@param repo string "owner/name"
---@param ref string full commit sha
---@return table entry a PACKAGES entry
function M.repo(name, repo, ref)
return {
spec = "acme.github@r0",
bundle = ENVY_BUNDLE.alias,
vendor = (VENDOR_ROOT or "vendor") .. "/" .. name,
options = { repo = repo, ref = ref },
}
end

return M

ENVY_BUNDLE.alias, from envy 0.4.7, is whatever the consuming manifest happened to call your bundle in its BUNDLES table. Reading it there means the builder does not have to take an alias its caller already typed, and the same helper works in a project that spells the alias differently. The spec identity stays written out, because that is your bundle's promise rather than the consumer's naming.

VENDOR_ROOT is the consumer's too. A module reads the globals of the file that loaded it. The builder puts each tree under whatever root the consumer chose, or under vendor/ when there is none. From envy 0.4.8 this also holds when the loading file is an imported component manifest.

A consumer is then one line per dependency:

envy.lua
VENDOR_ROOT = "third_party"

local gh = envy.loadenv_bundle("tools", "lib.github")

PACKAGES = {
gh.repo("libb64", "libb64/libb64", "ce864b1d3f4b9e0e2b0a4e5f0c9d8a7b6c5d4e3f"),
}

Treat a builder's arguments, and any global it reads, as consumer-visible API, the same as a spec's option names. Changing them is a bundle identity bump.

Testing a bundle before publishing​

Point a scratch manifest at your working copy. A local path needs no ref:

envy.lua
BUNDLES = {
acme = { identity = "acme.specs@r1", source = envy.abspath("../envy-specs") },
}

That is the fastest edit-and-run loop, because there is nothing to commit or push between attempts. Switch to the git source with a pinned ref before anyone else consumes it.

Migrating loose specs into a bundle​

Consumers keep their identities, so the change is small:

  1. Move the spec files into the bundle and list them in SPECS. Identities do not change.
  2. Publish the bundle and resolve a ref.
  3. In each consumer, add the BUNDLES entry and change source = "..." to bundle = "acme" on the affected entries.

Nothing about the packages changes, so no cache entry is invalidated and no version moves. That is worth doing as its own commit, separate from any version bump, so a bisect can tell the two apart.

Versioning discipline​

Two identities move independently, and the distinction matters:

ChangeBump
A spec gains an option, renames a product, or changes its install layoutthat spec's revision, acme.cmake@r0 to @r1
The bundle adds, removes, or renames specs, reorganizes lib/ in a way consumers can see, or changes a helper a consuming manifest callsthe bundle identity, acme.specs@r1 to @r2
A spec gains a new version in its hash tableneither. That is a normal commit consumers pick up by advancing the ref.

Bumping the bundle identity is a consumer-visible break, because BUNDLES pins it by name. Bumping a spec revision is narrower: only entries naming that spec change.

Declaring the same bundle identity twice with different sources or refs is an error, so a superproject and a component cannot disagree silently about which bundle they mean.

See also​