bk

module
v0.9.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 5, 2026 License: BSD-3-Clause

README

bk — a pure-Go brewkit

bk is a from-scratch, CGO_ENABLED=0 reimplementation of pkgx's build tool brewkit (originally Deno/TypeScript with Ruby + patchelf helpers). It reads a pantry package.yml, builds the package in a pkgx environment, relocates the install tree, and packages a bottle.

The reference brewkit is a native build tool — everything pivots on the host it runs on being the thing it builds for. bk is Target ≠ Host by design: the build target (platform/arch/triple) is a first-class value, so cross-compilation — notably Windows PE bottles built on a linux/darwin host — is a supported path rather than a retrofit.

Status

In production: bk factory is what fills ghcr.io/go-pkgx/packages — 1585 projects, 42 746 signed (project, os, arch, version) bottles as of 2026-10-04 17:00 UTC+2 — linux/aarch64 15 136, linux/x86-64 13 255, darwin/x86-64 7 113, darwin/aarch64 6 649, windows/x86-64 554, and linux/s390x 39, an architecture being brought up now.

It is a moving target, and more literally than that phrasing suggests: the same bk builder --dry-run --platform linux/s390x reported 25 of 25 toolchain roots unresolvable at 11:45 and 21 of 25 at 17:03 the same day, because another session was publishing while this was measured. A registry count is dated to the HOUR. Re-measure it rather than trusting this line: go run ./catalog in go-pkgx/packages enumerates the registry itself, and https://go-pkgx.github.io/packages browses it.

The packages, all at 100% statement coverage (go test ./... -coverprofile + go tool cover -func, enforced in CI):

package what it does
target the single source of truth for the build target vs the host (BREWKIT_TARGET / --platform), incl. the llvm-mingw Windows cross triples
config the build workspace layout, keyed on the target so a cross build never collides with a native one
schema an authoritative JSON Schema for package.yml — there is no official one; this is derived from libpkgx's parser and validated against the entire pkgxdev/pantry corpus (0 rejections over 1890 recipes)
pantry parses + schema-validates a package.yml into a shared Recipe
pantry/hcl an HCL2 front-end — a package.hcl decodes into the same Recipe via the same schema, so a recipe written in HCL2 yields an identical build
fixup post-build relocatability: .pc/.cmake path rewriting, libtool .la cleanup, lib64→lib, single-dir header flattening, and a pure-Go ELF RUNPATH rewriter (debug/elf, no patchelf). A Windows target skips all POSIX relocation.
moustache pkgx's {{token}} template substitution (version/deps/prefix/hw)
buildscript the build/test script generator (if: guards vs the target, platform-reduced env, fixtures) + the porcelain Wrap (full runnable script)
fetch source download + extract (tar.gz/xz/bz2/zip, git), zip-slip-safe
bottlepkg package an install tree into a pkgx bottle + dist layout
build the pipeline orchestrator (Runner): dep-closure, base toolchain, sanitized env, autotools maintainer-mode defeat
overrides applies the factory's local recipe-override patches to a pantry checkout in pure Go — a git diff parsed and applied without shelling out to git apply, and idempotent (it resets the files it touches first)
recipefile reads a recipe and applies the project's logical override on the way in — nothing on disk is touched, which is what lets the same pantry checkout be read twice
logical the override algebra: set, substitute, remove, append, prepend, each reporting applied / already true / premise gone. A unified diff has two outcomes; the third is what distinguishes a defect from a job upstream has since done
httpretry which failed HTTP attempts are worth repeating. Measured on one 23-failure batch, eleven were transient HTTP and nothing else — a factory that gives a flaky host exactly one chance reports its own bad luck as a broken recipe
useragent the one User-Agent every HTTP path in bk sends. It exists because they differed: versions set one and fetch did not, so bk could LIST a project's versions and then get 403 downloading the tarball it had just found
versions resolves a project's upstream version from the recipe's versions: spec — deliberately distinct from what pkgx's dist advertises, which normalises versions the recipe's own source URL does not have
cmd/bk twenty-four subcommands. The pipeline: build, test, publish, factory, builder, source. What a recipe set SAYS: target, versions, closure, lock, catalog, tools, overrides, tohcl. What it gets WRONG: depgaps, undeclared, unresolved, lint, weather, sonames, runlog. Build-time shims a recipe invokes rather than a person: fixup, libtool, bkpyvenv. bk tools is worth singling out: it reports which external commands a recipe set invokes, parsed rather than grepped, and --scope build|test|all separates two surfaces that disagree — a build's is what the IMAGE must hold, a test's is what the package's own acceptance check needs, and 260 tests call a compiler where 6 declare one.

bk build runs the whole pipeline — resolve version → fetch source → parse recipe → dependency closure → generate + wrap the build script → run it in a sanitized env → fix-up → package a bottle. bk factory drives that over a whole list of projects: it expands them to their topologically-ordered runtime-dependency closure, skips any (project, version, platform) already published, applies the overrides, and publishes each bottle signed with an SBOM and provenance. After each publish it runs that package's own test: block and RECORDS the outcome in tests.txt — four states, one line each — without ever changing the chunk's result: measured over 120 packages, only 2 of 12 test failures were a package that does not work, so a gate would stop a run over a missing host config file. --test=false turns it off, --test-timeout bounds one package's test, and --test-only builds nothing and tests what the registry already holds — the factory tests what it publishes once, and that is how a bottle which stopped working gets noticed afterwards.

bk test runs a recipe's own test: block against the INSTALLED package, in an emptied sandbox holding that package, its test.dependencies and the recipe's own files (recipes name fixtures by bare relative name: cc test.c -lz) — and a compiler when the test's own script calls one, which 260 of them do. What it does not get is the build's flags, its dependencies or its source tree, because the question is whether what we published works and not whether the build did. It answers in four states, not two: 0 passed, 1 ran and failed, 3 the recipe declares no test, and 4 the test never ran (an unresolvable version, an unreachable version source, a sandbox that could not be made). A 503 from a version source is not a broken package, and a sweep that cannot tell the two apart files bugs for an outage.

Building bottles that owe nothing to the build container

--libc=pkgx retargets the compiler at the pkgx gnu.org/glibc bottle — its crt objects, its libc, its dynamic linker — instead of the build container's, so the output runs FROM scratch. --glibc <version> pins that sysroot to an exact glibc line, and bk builder stages the sovereign rootfs itself: static Go binaries plus toolchain bottles from the signed registry, nothing else.

docs/from-scratch-toolchain.md is the design note behind it, kept because its hazard list is still the one that bites.

A scratch tree has nothing underneath it, so bk builder answers three questions a build would otherwise answer hours later and attributed to the wrong thing:

  • --dry-run resolves the toolchain for --platform and stages nothing, so "does this architecture have the bottles" costs a round trip rather than a runner. On a refusal it names every blocked root, not the first — on an architecture being brought up, one gap per build is one build per gap — and it tells a CONFLICT between roots apart from a missing bottle.
  • after staging, it reports every NEEDED soname the tree does not contain, naming who asks. A binary that wants one cannot start, and it says so only when something runs it. The s390x second generation met libselinux.so.1 twice, a full build apart, before this existed.
  • it says when a prefix is already on disk and will not be re-installed. bottle.InstallFor treats the existence of <project>/v<ver> as "already present", so a tree left half-written by a run that died — and inherited by the next job on a self-hosted runner — is indistinguishable from a complete one. Three runs reported "42 packages" and installed none.

None of the three refuses anything: the build that follows is the verdict.

Recipes in YAML or HCL2

The package.yml schema (schema/package.schema.json) doubles as editor autocompletion — add to a recipe:

# yaml-language-server: $schema=https://go-pkgx.github.io/bk/package.schema.json

The same recipe in HCL2 (package.hcl) decodes to the identical Recipe:

distributable {
  url              = "https://curl.se/download/curl-{{version}}.tar.bz2"
  strip-components = 1
}
dependencies = { "openssl.org" = "^1.1", "zlib.net" = "^1.2.11" }
build {
  script = ["./configure $ARGS", "make --jobs {{ hw.concurrency }} install"]
  env    = { ARGS = ["--prefix={{prefix}}", "--with-openssl"] }
}
provides = ["bin/curl", "bin/curl-config"]

Why

Completes the pure-Go go-pkgx family (bottle / pkgm / pkgx / mirror) with the last Deno piece — a single static binary that builds pantry recipes with no Deno/Ruby/patchelf runtime, and cross-builds cleanly.

Naming a tree, not only a package

A recipe describes one package: versions, distributable, build, test, provides, dependencies. Until recently the only way to name a group was a flat text file beside the format — seed/order.txt, 106 lines, and builder/toolchain.txt, 95 — which could not be versioned, published, installed or depended on.

A recipe carrying members is a set:

# go-pkgx/base-toolchain
members = {
  # clang, lld, compiler-rt — the compiler itself
  "llvm.org" = "*"

  # libc, crt objects and the dynamic loader
  "gnu.org/glibc" = "*"
}

bk closure go-pkgx/base-toolchain resolves it. So does bk factory --recipes go-pkgx/base-toolchain, and so would anything else that takes a project name — a set is expanded at the root, before the dependency walk looks at it, so nothing downstream learns the word.

Why a recipe and not a new kind of file

The three systems that solved this agree on the shape:

Nix a tree is a derivation. buildEnv takes a list of packages and its output is a tree of symlinks. No new kind of object.
Guix a manifest names packages; a profile is the tree it makes.
Spack spack.yaml is the abstract set, spack.lock the concrete one — the same root specs "may concretize differently".

Nix's answer is the one taken. A set is an ordinary recipe, so it is signed, attested, published and installed by everything that already does those things for a package — rather than a second kind of artefact to keep in step.

A set is UNORDERED, and that is the point

All three of those manifests are. The order comes out of resolution, not out of the file, and bk closure produces it from the dependency graph.

Which is why seed/order.txt is not a set and must not become one. That file is a build order: inside a dependency cycle the topological order is a guess, and order.txt is the hand-tuned guess that works — bk closure --check-order exists to judge it. builder/toolchain.txt is a different thing, an install list, where order carries nothing; that is the one that moved.

bk lock — the concrete half

Guix's manual puts it plainly: "to reproduce a profile bit-for-bit, manifests alone might not be enough" — the same names resolve differently against a different package set, so a manifest needs the channel revisions beside it. Spack names the two halves: an environment from spack.yaml has "the same root specs... may concretize differently", one from spack.lock has "the same concrete specs".

members is the abstract half. bk lock is the concrete one:

$ bk lock --platform linux/x86-64 -o base-toolchain.lock.hcl go-pkgx/base-toolchain
set: go-pkgx/base-toolchain names 25 member(s)
lock: 41 project(s) pinned → base-toolchain.lock.hcl
lockfile_version = 1
bk               = "v0.7.0"
platform         = "linux/x86-64"
generated        = "2026-10-05T09:10:39Z"
pantry           = "fd646990024e22f5752578cfdcfe2518b20c0797"
overlay          = "f8f6dcfd7b17410a6de780bdd58c81fbbb38d1b3"
roots            = ["go-pkgx/base-toolchain"]

locked = {
  "curl.se" = { version = "8.17.0", spec = "sha256:5284d597c180…" }
  "gnu.org/bash" = { version = "5.3", spec = "sha256:…" }
  …
}

And it matters more here than in Guix. A Guix manifest resolves deterministically once the channel revision is pinned, because the version is in the checkout. A pkgx recipe's versions: asks GitHub for tags at resolve time, so pinning the pantry commit is not enough: the same pantry resolves gnu.org/binutils to whatever the newest tag is the day you ask. The resolved version has to be written down or it is not pinned at all.

The platform is in the header because it changes the answer: the same set locks to 41 projects on linux/x86-64 and 40 on darwin/aarch64, github.com/besser82/libxcrypt being the difference. A lock that did not say which platform it was taken on would be read as the other one's.

Two deliberate shapes:

  • Sorted by project, not in build order. A lock is read as a diff, and a topological order makes every line move when one dependency does.
  • An unresolved project exits non-zero and is named on stderr. A lock with a hole in it that reads as a success is a lock somebody commits.
spec — because a version is not enough either

Spack's packaging guide says what goes into a spec hash: "build, link, and run dependencies all affect the hash of Spack packages (along with sha256 sums of patches and archives used to build the package, and a canonical hash of the package.py recipes)".

The last clause is the one worth copying. Pinning a version catches an upstream that moved; hashing the recipe catches the other half — a build script edited in the pantry resolves to the same version and produces a different package. A lock with versions alone calls those two builds the same, which is the thing a lock exists not to do.

spec is a Merkle hash over the platform, the project, the resolved version, the parsed recipe (every half of it the closure read) and the spec hashes of the dependencies. It is taken over the parsed value, not the file, so reformatting a recipe or rewriting its comments does not move it and changing what it says does — Spack's "canonical", by a different route.

Measured: of 2091 recipes in the pantry and the overlay, 0 fail to serialise. The property that matters is tested both ways round — a change to a leaf moves the leaf and everything above it; a change at the top moves the top alone — and that test was checked against a deliberately broken hash, which fails it, so it is not passing for free.

bk lock --check — a lock nobody reads is a wish too

Cargo's --locked and npm's ci exist because a lock nobody verifies is a lock nobody can rely on: both refuse to run when the lock and a fresh resolution disagree.

$ bk lock --check base-toolchain.lock.hcl
lock: linux/x86-64 · 2026-10-05T09:10:39Z · 41 pinned, 2 hour(s) old
lock: pantry revision differs: fd64699… → 9ab12cd…
  gnu.org/binutils                   2.47 → 2.48
  zlib.net                           spec 6a2d499db9ab → spec 90fae00e667c — same version, so something it is built FROM changed
lock: 2 of 41 moved

Exit 0 when nothing moved, 1 when something did, 2 when the check could not be made at all.

The second line of that diff is the one a version-only lock cannot produce. Measured on the real zlib.net recipe: a comment added to its build block moves nothing, and one flag added to its ./configure line moves the spec hash while the version stays 1.3.2.

It takes its roots, its platform and the revisions it expects from the file. Naming them again on the command line would let the two disagree, and the check would then be of a different question from the one the file answers — so it refuses a project name beside --check.

A revision that moved is said and is not fatal: a pantry can move without moving any answer, and calling that drift would make the check cry wolf until nobody ran it. An unresolved project is kept apart from drift for the mirror-image reason — a registry outage must not read as a pantry that changed.

The header is data, not a comment

The first version wrote the platform, the roots and the revisions as # lines. Right facts, wrong place: a comment cannot be read back. They are attributes now, with lockfile_version beside them — Spack's lockfile carries a lockfile-version and records which spack wrote it, and the compatibility rule that comes with it (new readers read old locks; old readers refuse new ones) is worth copying before it is needed rather than after.

What is still missing, named rather than left out

The spec hash covers the inputs, as Nix's derivation hash and Spack's spec hash do. It does not cover the output bytes of the built bottle. bottle can verify a digest but has no exported way to be asked for one, and a lock taken before anything is built has no output to name. The header of every lock says so, rather than letting a reader assume a guarantee that is not there.

Where the source came from, and whether it changed

bk extracts tarballs from 233 distinct upstream hosts, and in the sovereign lane the build that consumes them runs as root inside a chroot. What checks that?

Almost nothing, before this. bk does verify a source checksum when a recipe declares one — but measured over a fresh pkgxdev/pantry, exactly one recipe of 904 declares one, and that one (openssl.org) points at a .sha256 served by the same host as the tarball: it detects corruption, not substitution. The same recipe declares sig: ${{url}}.asc, and nothing in bk reads that field at all.

So --source-mirror grew a second job. It already kept every archive a build downloads, addressed by its sha256; now it also records which digest each URL served, and refuses bytes that disagree:

source pin: 9f86d0…  matches what https://ftp.gnu.org/…/sed-4.10.tar.xz served before
fetch: this URL has served different bytes before: https://…/x-1.2.tar.gz
  now serves 2c26b4…, and served 9f86d0… before

Trust on first use. It does not make an upstream trustworthy — it makes a change visible, which is the part nobody had.

A version bump is not a change here: the version is in the URL, so a new release asks a question that has never been asked and is recorded rather than refused. What fires is the same URL serving different bytes — a re-cut release, a compromised mirror, a hijacked domain.

The uncomfortable third case is a store that cannot be asked. That is not an absent pin, and treating the two alike is how trust-on-first-use quietly becomes trust-every-time. They are distinguished, said out loud, and --source-pin-strict decides which one is fatal — warn and continue by default, because an unreachable registry is not evidence of tampering and a build must not die because ghcr hiccuped.

Still open (go-pkgx/bk#282): nothing reads sig:. Verifying a detached signature needs a key policy — whose key — and that is a decision, not a patch.

The ELF RUNPATH rewriter

fixup.SetRunpath overwrites an ELF's DT_RUNPATH/DT_RPATH string in place (pure Go): it locates the string offset in .dynstr and rewrites the bytes, zero-padding slack. It cannot grow .dynstr, so the build links binaries with a long -Wl,-rpath placeholder that the final $ORIGIN-relative value fits inside — the same budget model patchelf --set-rpath sidesteps by rewriting the file.

Where it is proven to work

A build tool whose whole premise is Target ≠ Host has to be right about widths and byte order, because that is exactly what it is manipulating: ELF DT_RUNPATH strings rewritten in place, .dynstr offsets, tar and xz streams, OCI manifests. None of that is checked by a compiler. So CI builds eight targets — linux on amd64, arm64, riscv64, ppc64le, s390x and loong64, plus darwin/arm64 and windows/amd64, all CGO_ENABLED=0 — and then runs the suite on five of them under qemu-user:

test (arm64, qemu)  test (riscv64, qemu)  test (ppc64le, qemu)
test (s390x, qemu)  test (loong64, qemu)

s390x is there because it is big-endian and nothing else here is; an ELF header read through the host's byte order instead of encoding/binary is wrong on that machine and on no other. -count=1, so a cached PASS from the host architecture cannot stand in for a run that never happened. The same job holds the 100% statement-coverage gate: below it, CI prints the uncovered blocks and fails.

License

BSD-3-Clause. Copyright the bk authors.

Directories

Path Synopsis
Package bottlepkg packages a finished install tree into a pkgx bottle.
Package bottlepkg packages a finished install tree into a pkgx bottle.
Package build holds the orchestration helpers that turn a parsed recipe into a runnable build: the dependency closure (recipe deps reduced for the target), the ambient base toolchain brewkit provides, a sanitized run environment, and the autotools maintainer-mode defeat.
Package build holds the orchestration helpers that turn a parsed recipe into a runnable build: the dependency closure (recipe deps reduced for the target), the ambient base toolchain brewkit provides, a sanitized run environment, and the autotools maintainer-mode defeat.
Package buildscript turns a recipe's build/test node into the shell script brewkit runs, a faithful port of libpkgx's usePantry.getScript: moustache expansion, `if:` guards evaluated against the build TARGET, platform-reduced env, working-directory wrapping and prop/fixture here-docs.
Package buildscript turns a recipe's build/test node into the shell script brewkit runs, a faithful port of libpkgx's usePantry.getScript: moustache expansion, `if:` guards evaluated against the build TARGET, platform-reduced env, working-directory wrapping and prop/fixture here-docs.
cmd
bk command
Command bk is the pure-Go brewkit — a from-scratch reimplementation of pkgx's build tool that is Target≠Host by design (first-class cross-compile, notably Windows PE bottles built on a linux/darwin host).
Command bk is the pure-Go brewkit — a from-scratch reimplementation of pkgx's build tool that is Target≠Host by design (first-class cross-compile, notably Windows PE bottles built on a linux/darwin host).
Package config computes the on-disk workspace layout for a build.
Package config computes the on-disk workspace layout for a build.
Package fetch downloads and extracts pkgx package sources.
Package fetch downloads and extracts pkgx package sources.
Package fixup performs the post-build relocatability fix-ups brewkit applies before a package is bottled: rewriting hardcoded paths in .pc/.cmake files and in installed scripts, removing libtool .la files, consolidating lib64→lib, flattening single-dir include trees, and (via rpath.go) fixing ELF RUNPATHs.
Package fixup performs the post-build relocatability fix-ups brewkit applies before a package is bottled: rewriting hardcoded paths in .pc/.cmake files and in installed scripts, removing libtool .la files, consolidating lib64→lib, flattening single-dir include trees, and (via rpath.go) fixing ELF RUNPATHs.
Package httpretry decides which failed HTTP attempts are worth repeating.
Package httpretry decides which failed HTTP attempts are worth repeating.
Package logical applies a recipe override that says WHAT it changes rather than WHERE the lines are.
Package logical applies a recipe override that says WHAT it changes rather than WHERE the lines are.
Package moustache implements pkgx's `{{token}}` template substitution, a faithful port of libpkgx's useMoustaches.
Package moustache implements pkgx's `{{token}}` template substitution, a faithful port of libpkgx's useMoustaches.
Package overrides applies the factory's local recipe-override patches to a pantry checkout, in pure Go — no `git apply` (or any other CLI) shell-out.
Package overrides applies the factory's local recipe-override patches to a pantry checkout, in pure Go — no `git apply` (or any other CLI) shell-out.
Package pantry parses and validates pkgx pantry package.yml recipes.
Package pantry parses and validates pkgx pantry package.yml recipes.
hcl
Package hcl is an HCL2 front-end for pkgx pantry recipes.
Package hcl is an HCL2 front-end for pkgx pantry recipes.
Package recipefile is the single place that decides what a recipe file is called and how it is read.
Package recipefile is the single place that decides what a recipe file is called and how it is read.
Package schema embeds the authoritative JSON Schema for a pkgx pantry package.yml.
Package schema embeds the authoritative JSON Schema for a pkgx pantry package.yml.
Package target is the single source of truth for the *target* platform/arch of a build.
Package target is the single source of truth for the *target* platform/arch of a build.
Package useragent holds the one User-Agent bk sends, so every HTTP path in the factory sends the same one.
Package useragent holds the one User-Agent bk sends, so every HTTP path in the factory sends the same one.
Package versions resolves a project's upstream version from a pantry recipe's `versions:` specification.
Package versions resolves a project's upstream version from a pantry recipe's `versions:` specification.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL