- Shell 100%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
Projects keep their own devkit clone and nothing updates it. platen's was two commits behind, so its v0.12.0 release still amended the pushed promote merge that |
||
| cmd | ||
| lib | ||
| devkit | ||
| LICENSE | ||
| README.md | ||
devkit
Shared release mechanism for the main/dev pinned-manifest model used across the urverk Zig projects. Extracted from quote and comb, which had hand-copied — and already diverged — copies of these scripts.
The model
build.zig.zon differs by design between branches:
main—url + hashpins, so consumers can clone and build.dev—.path = "../sibling"deps, so siblings are editable in place.
A merge=ours gitattribute on the manifest is not sufficient: merge
drivers run only on conflicts, a one-sided change flows through silently,
and a fast-forward bypasses drivers entirely. So each command re-asserts
its branch's manifest after the merge, and asserts the result.
Re-asserting main's manifest keeps everything in it as main had it, so two
fields are carried over explicitly: the version (release sets it, sync-dev
brings it back to dev) and minimum_zig_version, which promote and
release take from dev -- requiring a newer Zig is a code change like any
other, and without the carry main kept declaring the old one.
Commands
devkit pin-deps bump main's pins to each sibling's PUSHED head
devkit release X.Y.Z dev -> main: merge, re-pin, set version, test, tag
devkit promote dev -> main: same, but no version bump and no tag
devkit sync-dev main -> dev: merge, keep path deps, carry version
devkit publish <file>.. create the forge release for vX.Y.Z, upload assets
Use promote for a library that keeps the main/dev split so consumers have a
stable branch to pin, but doesn't publish versioned releases — it advances
main under the same manifest discipline, without inventing a version.
pin-deps uses ls-remote, never a local sha, so an unpushed commit can
never leak into a pin. It refuses to run against a manifest containing
../ path deps — see the note below.
Install (per project)
Clone alongside the project's other tools; tools/ is gitignored:
git clone https://git.urverk.org/urverk/devkit tools/devkit
Nothing updates that clone, so every command except keygen first fetches
devkit's origin and warns, listing the missing commits, when the clone is
behind (git -C tools/devkit pull --ff-only). It never blocks: offline, a
copy that isn't a clone, or DEVKIT_NO_SELF_CHECK=1 skips the check.
Then add .devkit.conf to the project root — commit it on BOTH main and
dev. devkit release runs on main and loads the config before it
merges dev, so a config that exists only on dev cannot bootstrap the very
command that would bring it over. After the first release, merges keep the two
copies in sync.
HOST=https://git.urverk.org/urverk
DEPS="plumbuz:plumbuz" # space-separated name:repo for pin-deps
TEST_CMD="zig build test"
# only needed for `devkit publish`
FORGE_API=https://git.urverk.org/api/v1/repos/urverk/myproject
TOKEN_ENV=URVERK_TOKEN
TOKEN_KEYCHAIN=urverk-token
Run as tools/devkit/devkit release 0.1.0.
Defaults if unset: MAIN_BRANCH=main, DEV_BRANCH=dev,
MANIFEST=build.zig.zon, TEST_CMD="zig build test".
Release notes (opt-in)
Set CHANGELOG=CHANGELOG.md in .devkit.conf and keep notes under a
## Unreleased heading on dev:
# Changelog
## Unreleased
- what changed, for the people who use the project
## 0.1.0 — 2026-09-01
...
devkit release X.Y.Z then requires a non-empty ## Unreleased
section (otherwise it rolls back and tags nothing), renames it to
## X.Y.Z — YYYY-MM-DD inside the release commit, and devkit publish
uses that section as the forge release text (asset files become optional;
re-publishing updates the text). Without CHANGELOG nothing changes.
The setting must be on main before the release that should use it:
release loads the config before it merges dev.
Pins that aren't a plain head lookup
Define devkit_extra_pins in .devkit.conf; it runs after the DEPS loop
with pin and head_of in scope. Example — lockstep a shared dependency to
whatever another repo's published manifest pins, so both resolve one module:
devkit_extra_pins() {
sm_sha=$(curl -fsS "$HOST/sift/raw/branch/main/build.zig.zon" \
| grep -o 'sift-matcher#[0-9a-f]*' | head -1 | cut -d'#' -f2)
[ -n "$sm_sha" ] || die "could not read sift's sift-matcher pin"
pin sift_matcher sift-matcher "$sm_sha"
}
Why pin-deps refuses on dev
zig fetch --save replaces a dependency's value but keeps its existing
field name. Run against dev's manifest, .path = "../plumbuz" becomes
.path = "git+https://…#sha" — a path dep holding a URL, with no hash —
and zig exits 0 without a warning. The guard exists because this actually
happened.
Signing keys
devkit keygen <project> # both keys
devkit keygen --half b --replace <project> # one key, e.g. after a compromise
Generates the A/B set the auto-updater expects: A signs every release,
B is an offline reserve whose public half ships from release one so it can
take over if A is lost or compromised. Both public keys go to src/keys/
(override DEVKIT_KEYS_DIR), secrets to ~/.minisign/ (DEVKIT_SECRET_DIR),
mode 0600.
Keys are per project, not shared across projects. minisign is a flat scheme — no certificate chain, no delegation — so a shared key cannot be scoped to one app, and one theft would reach everything you ship. It also breaks the compromise response: the reserve signs a release that drops the stolen key, which with shared keys means re-releasing every project before any of them is safe.
By default devkit generates both passphrases (24 random bytes) and prints
them once at the end, after both keys are proven. Nothing here needs to be
memorable — A's lives in the Keychain for unattended signing, B's is recorded
offline with the reserve — so a typed passphrase buys nothing and risks a typo.
minizign -G reads the passphrase once and never confirms it, so a slip
silently produces a key nobody can open. That matters most for B: a typo in
A shows up at the first signing, but a mistyped reserve stays silent until the
day A is lost, which is the one day it has to work.
--ask types your own instead; devkit then prompts twice and compares.
Either way the key is proven before it is accepted: devkit signs a scratch file with the passphrase and verifies it against the freshly generated public key, rolling back if that fails.
Because a generated passphrase exists only in that process, the command refuses to run when stdout is not a terminal — and refuses before creating anything, since keys whose passphrase was piped into a log or lost would be permanently unusable. The printed passphrases land in your scrollback; clear it once they are stored.
Three safety properties, all deliberate:
- It never overwrites an existing key. A key whose public half is already embedded in a shipped binary is unrecoverable if the secret is clobbered — every install would reject all future updates.
- Generation is all-or-nothing. A partial run would otherwise leave half a key set that the overwrite guard then refuses to complete.
- Every key is proven before it is accepted (the sign/verify round trip above), so a mistyped passphrase can never reach an offline reserve.
--replaceretires, never deletes. The old key moves to a timestamped folder, because until the recovery release ships it is still the only thing that can sign for installs that trust it.
Use --half when a single key is lost or compromised — the common incident.
It regenerates that half and leaves the surviving one untouched, which is what
lets the survivor sign the recovery release.
What it does not do is custody. It prints those steps, because they are the part that actually protects you:
- Back up A's encrypted key file, not just its passphrase. minisign encrypts the secret key, so the file is the key and the passphrase merely opens it — a recorded passphrase restores nothing on its own. Without a backup, a dead drive forces the lost-A drill and burns the reserve over a hardware failure. Being encrypted, the file is safe in ordinary backups; keep it apart from wherever the passphrase lives.
- Move B off the machine. An on-disk reserve next to the working key is not a reserve.
A failed release is a no-op
release, promote and sync-dev roll the branch back to its pre-merge
commit on any failure — merge conflict, pin-deps error, failing tests, or
Ctrl-C. Nothing is left half-merged and nothing is tagged. All three
require a clean tree up front, so the rollback cannot destroy uncommitted work.
The interrupt case matters because the merge commit already exists by the time
tests run: without the trap, Ctrl-C left main merged with a half-rewritten
manifest, and the next run refused with "working tree not clean".
The common conflict is a file modified on main but deleted on dev
(git reports modify/delete). Resolve it by making the two branches agree —
usually by applying the deletion on main — then retry.
Notes
- The package hash is content-derived, not host-derived: the same commit
fetched from two forges yields the same hash. Moving a dependency between
forges is a URL swap; existing
.hashvalues stay valid. sed -idiffers between BSD and GNU;lib/common.shhandles both, so these run on macOS and on the Linux forge.