module-alpine — wrapping prebuilt Alpine packages
module-alpine is a yoe module that wraps prebuilt Alpine Linux .apk files as
yoe units. Where module-core builds packages from upstream source, this module
fetches a binary apk from a pinned Alpine release, verifies its integrity, and
repacks it as a yoe artifact. The unit’s “build” is just extracting the apk into
$DESTDIR.
It declares Alpine’s main and community repos as two alpine_feed(...)
calls in MODULE.star, rather than shipping a .star file per package. The
feed exposes every package in those repos; units materialize lazily as a
project’s runtime closure references them.
The module lives at https://github.com/yoebuild/module-alpine. Open it to
browse the checked-in feeds/**/APKINDEX, the alpine_feed declarations in
MODULE.star, or to send a PR adding a service-enable companion unit.
Implementation details: how Alpine apks pass through yoe’s pipeline (signature swap, noarch routing, lazy feed materialization, the docker-openrc punt) live in apk-passthrough.md. This doc is the “when to reach for it” rubric; the other is the “how it works end-to-end” reference.
When to reach for it
The policy yoe follows:
- Yoe builds the easy stuff. Small leaf libraries (
zlib,xz,expat,libffi,readline,ncurses, …) and small userland tools (less,htop,vim,procps-ng,iproute2, …) stay inmodule-coreeven though Alpine ships them too. Their build is cheap, and keeping them in yoe preserves the option to retarget glibc or a different init system later. module-alpineships Alpine-native and hard-to-build packages. Alpine-native meansmusl,apk-tools,alpine-keys,alpine-baselayout— things that only make sense from Alpine. Hard-to-build means packages where Alpine’s expertise (configure flags, security review, codec/license decisions, multi-language coupling) earns its keep:openssl,openssh,curl, eventuallypython,llvm,qt6-qtwebengine, and similar.- Keep building from source anything where the build defines the product.
Toolchain, kernel, bootloader,
busybox, init scripts,base-files— these are not packages, they are the distribution.
For the broader strategic context — why this rubric exists, where Alpine doesn’t fit (notably edge AI on Jetson), and how yoe expects to handle glibc/systemd targets in the future — see libc-and-init.md.
Alpine release coupling
The Alpine release pinned in MODULE.star and classes/alpine_pkg.star
(_ALPINE_RELEASE = "v3.21" at the time of writing) must match the
FROM alpine:<release> line in
@module-core//containers/toolchain-musl/Dockerfile. All currently point at
v3.21.
The coupling is not aesthetic. Three things tie them together:
- libc ABI. Anything compiled in the toolchain container links against the toolchain’s musl headers and libc. Anything the feed fetches was compiled against a specific Alpine release’s musl. Mix versions and you produce images that compile and link cleanly, then crash on first run when the dynamic linker resolves a symbol whose layout has changed.
- Signing keys. Every Alpine release ships with a build-host signing key.
Prebuilt apks are signed by that key, and
apk-toolsinside the target image verifies signatures against the keyring baked into the toolchain container at build time. A version skew means the keyring doesn’t recognise the signatures on the packages you’re trying to install. - Library co-versioning. Many Alpine packages declare
D:so:libfoo.so.Nruntime dependencies pinned to specific minor versions. Pullingpackage-Afrom one release andpackage-Bfrom another lands you with conflictingso:constraints thatapkwill refuse to install.
When bumping the Alpine release, do all three in lockstep across the yoe repo and the module-alpine repo:
- Update
FROM alpine:<release>inmodules/module-core/containers/toolchain-musl/Dockerfilein the yoe repo, then rebuild the toolchain container so its baked apk-tools keyring matches. - Update
_ALPINE_RELEASEinMODULE.star(thebrancheachalpine_feedpins) and inclasses/alpine_pkg.starin the module-alpine repo. - Run
yoe update-feedsin the module-alpine repo to refresh every checked-infeeds/**/APKINDEXto the new release, then review the diff and commit (see the maintainer playbook below). Every package’s version and integrity hash comes from the refreshed, signature-verified APKINDEX — there are no per-package files to hand-edit.
alpine_feed: declaring a whole repo as one module entry
alpine_feed(...) is a Starlark builtin that turns a checked-in directory of
APKINDEX files into a lazily-materialized synthetic module. One alpine_feed
call exposes thousands of packages from an upstream Alpine repo (main,
community, etc.) with a single declaration. Units materialize on demand as an
image’s runtime closure references them, so a project pulling 300 packages from
a 60k-entry feed pays for 300 unit allocations, not 60k. This is the normal way
packages come out of module-alpine — there are no per-package .star files.
The two declarations in module-alpine/MODULE.star:
module_info(name = "alpine")
_ALPINE_KEYS = [
"keys/alpine-devel@lists.alpinelinux.org-6165ee59.rsa.pub", # x86_64
"keys/alpine-devel@lists.alpinelinux.org-616ae350.rsa.pub", # aarch64
]
alpine_feed(
name = "main", # synthetic module is alpine.main
url = "https://dl-cdn.alpinelinux.org/alpine",
branch = "v3.21", # Alpine release tag
section = "main", # repo section
index = "feeds/main", # dir holding <arch>/APKINDEX
keys = _ALPINE_KEYS,
)
alpine_feed(
name = "community",
url = "https://dl-cdn.alpinelinux.org/alpine",
branch = "v3.21",
section = "community",
index = "feeds/community",
keys = _ALPINE_KEYS,
)
Alpine signs each arch’s APKINDEX with a separate key, so both are listed —
update-feeds accepts whichever the upstream mirror serves for a given arch.
The composed module name is <parent>.<feed-name> — alpine.main,
alpine.community. The resolver consults synthetic modules after every real
module so a from-source override (module-core/units/openssl.star, say) wins
against the feed automatically.
Maintainer playbook: yoe update-feeds
When Alpine cuts a new release or ships a security patch, the module-alpine maintainer refreshes the checked-in APKINDEX files with one command. Run it inside the module repo:
cd path/to/module-alpine
yoe update-feeds # refresh every alpine_feed for every existing arch
yoe update-feeds --arch x86_64 # restrict to one arch
yoe update-feeds --module-dir ../some/other/module
What it does, per alpine_feed() call, per arch:
- Fetch
<url>/<branch>/<section>/<arch>/APKINDEX.tar.gzover HTTP. - Verify the RSA-SHA1 signature against the keys declared in
alpine_feed(keys=[...]). Pure-Go verification — never consults/etc/apk/keys/on the maintainer’s host, so the trust list the module declares is the one that’s actually enforced. - Decompress the inner APKINDEX and atomically write it to
<module>/<index>/<alpine-arch>/APKINDEX.
yoe update-feeds writes only — it does not stage, commit, or push. The
intended workflow is:
yoe update-feeds # refresh every feed
git diff feeds/ # spot-check version bumps, new packages, removals
git add feeds/
git commit -m "module-alpine: refresh feeds to Alpine v3.21.2"
git push # ships to consumers on next `yoe build`
When the diff looks unexpected
- Lots of new packages or removals: confirm the Alpine release moved (a point release or branch flip).
- A signature failure: either Alpine rotated keys (see below) or the download was tampered. The failing key fingerprint is in the error message; cross-reference against Alpine’s release signing keys before adding a new key.
- HTTP 404: the upstream mirror dropped the branch (very old release) or the
section name in
alpine_feedis wrong.
Key rotation
When Alpine rotates its signing key (rare, but happens around major release
boundaries), commit the new public key alongside the old one and add it to
alpine_feed(keys=[...]):
alpine_feed(
name = "main",
# ... other fields ...
keys = [
"keys/alpine-devel@lists.alpinelinux.org-6165ee59.rsa.pub", # old
"keys/alpine-devel@lists.alpinelinux.org-5e69ca50.rsa.pub", # new
],
)
Both keys verify during the transition period. Once every active Alpine release the module ships has rotated to the new key, drop the old one in a follow-up commit.
Hand-writing an alpine_pkg unit (rare)
Normally you never write a per-package unit — you name an Alpine package in
deps and the feed materializes it. Reach for the alpine_pkg class directly
only when you need to pin one specific apk the feed doesn’t expose: a revision
the live mirror has dropped, a package from a different release, or a unit you
want to hand-tune. The class is what the feed materializer produces under the
hood, so a hand-written unit and a feed-materialized one behave identically.
load("@module-alpine//classes/alpine_pkg.star", "alpine_pkg")
alpine_pkg(
name = "sqlite-libs",
version = "3.48.0-r4",
license = "blessing",
description = "SQLite shared library (Alpine v3.21)",
runtime_deps = ["musl"],
sha256 = {
"x86_64": "...",
"arm64": "...",
},
)
The version is Alpine’s full pkgver (e.g., 3.48.0-r4), not just the upstream
version. The sha256 dict keys are yoe canonical arches; the class maps them to
Alpine arch tokens (arm64 → aarch64).
To find the version + sha256 for a package:
# 1. Find the version in the APKINDEX:
curl -sLO https://dl-cdn.alpinelinux.org/alpine/v3.21/main/x86_64/APKINDEX.tar.gz
tar -xzOf APKINDEX.tar.gz APKINDEX | awk -v RS= '/(^|\n)P:sqlite-libs(\n|$)/ { print; exit }'
# 2. Fetch the apk and sha256 it:
curl -sLO https://dl-cdn.alpinelinux.org/alpine/v3.21/main/x86_64/sqlite-libs-3.48.0-r4.apk
sha256sum sqlite-libs-3.48.0-r4.apk
Repeat for each architecture you target.
How dependencies resolve
Alpine packages declare runtime dependencies via the D: field in APKINDEX,
including so:libfoo.so.N and cmd:foo tokens. The two paths handle these
differently:
- Feed-materialized units follow the
D:closure automatically. As the feed materializes a unit, it walks each dep token and resolves it through a project-wide providers table built from every feed’s APKINDEX — so acommunitypackage’sso:libcrypto.so.3findsmain’sopenssl-libswithout anyone listing it. Only the packages a runtime closure actually reaches are materialized. - Name shadowing keeps the import surface honest. Because the resolver
consults the feed after every real module, a dep that resolves to a bare
name yoe already ships from source (
busybox,musl, …) binds to that source unit, not the feed’s copy. Auto-following is safe precisely because the things yoe wants to own still win by name. - Hand-written
alpine_pkgunits listruntime_depsexplicitly. The Starlark class does not read theD:field — when you hand-write a unit (the rare case above), declare the deps you need inruntime_depsand leave out the ones you don’t.
Override with a from-source unit
Because units in module-alpine use the bare names (musl, sqlite-libs, …),
any later-priority module — including the project itself — can override them by
defining a unit with the same name. See
naming-and-resolution.md.
# PROJECT.star
modules = [
module("https://github.com/yoebuild/module-alpine.git", ref = "main"), # ships musl, sqlite-libs, …
module("https://github.com/yoebuild/yoe.git", ref = "main", path = "modules/module-core"), # source-built kernel, busybox, …
module(..., path = "modules/my-overrides"), # last → wins
]
# modules/my-overrides/units/musl.star
unit(name = "musl", source = "https://git.musl-libc.org/git/musl",
tag = "v1.2.5", tasks = [...])
The override unit produces an apk under the same name. Consumers writing
runtime_deps = ["musl"] get the override automatically.