Shared urverk dev tools.
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Mikael Säker f0961ef1e2 sync-dev: fix two failures that only show on a published or dep-less dev
Three findings from adopting devkit in a project with no dependencies and a
backed-up dev branch.

1. `assert_has_path_deps` ran unconditionally, but `DEPS=""` is a supported
   configuration and the default. Every dep-less project therefore got
   "FATAL: build.zig.zon lost its path deps" from a sync that had SUCCEEDED
   — and because the die came after the merge was committed, the sync
   half-completed: version carry and amend never ran, exit status nonzero.
   Now a no-op when the project has no path deps, which is when there is
   nothing to lose.

2. When there was nothing to merge, `git merge` printed "Already up to date"
   and created no commit — but the script continued to the closing
   `git commit --amend`, which then rewrote whatever commit was HEAD. On a
   published dev that silently rewrites history and diverges the branch from
   its remote, for a command a user reasonably expects to be a no-op. It now
   detects HEAD == pre and exits 0 without touching anything.

3. devkit never pushes, by design — promote and release print "review, then:
   git push origin main" and leave publishing a decision. dev had no
   equivalent, so a backed-up dev silently fell behind after every sync,
   which is the one failure a backup exists to prevent. sync-dev now prints
   the same style of reminder, and only when dev actually has an upstream: a
   project that keeps it local should not be nagged to publish it.
2026-08-17 15:43:41 +02:00
cmd sync-dev: fix two failures that only show on a published or dep-less dev 2026-08-17 15:43:41 +02:00
lib sync-dev: fix two failures that only show on a published or dep-less dev 2026-08-17 15:43:41 +02:00
devkit Add devkit keygen -- per-project A/B signing keys 2026-07-25 23:57:22 +02:00
LICENSE Add MIT license 2026-07-25 12:00:15 +02:00
README.md keygen: --half and --replace for single-key incidents 2026-07-26 02:09:18 +02:00

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:

  • mainurl + hash pins, 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.

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

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".

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.
  • --replace retires, 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 .hash values stay valid.
  • sed -i differs between BSD and GNU; lib/common.sh handles both, so these run on macOS and on the Linux forge.