Maintaining
For maintainers: version bumps and releases. Run the scripts with mise, which puts the pinned GoReleaser and minisign on PATH: mise exec -- scripts/release.sh.
Updating pinned versions
scripts/bump.sh --checklists every pin against upstream;scripts/bump.sh [tool...]rewrites the pins (all tools by default) and prints a summary for the commit message. It writes a value only if it has the shape of a pin (a version, a 64-hex checksum, a dated release, a commit), since some of the edited files run on the host, and edits nothing unless every value is good. It needscurlandjq. When upstream’s latest is older than a pin, as when a release marker hasn’t moved yet,--checksays the pin is newer andbump.shleaves it as it is.- Every Monday the Pins workflow (
.github/workflows/pins.yml) runsscripts/bump.sh --checkand opens or updates one issue, “Pinned versions to bump”, which lists the outdated pins, and closes it once none is left. It never bumps anything: you runscripts/bump.shwith the tools it lists, then the--fromtier of the end-to-end tests, because pin bumps change images. - The tools:
docker,nix,direnv,nix-direnv,mise,rustup,crane,omb,eza,tealdeer,e2e-tools,e2e-from,e2e-ubuntu,e2e-lima. base’s Ubuntu cloud image and its apt packages have nothing to bump: devvm pins the newest image on each machine at its first build of base, and records the newest packages for each key of base at its first build (Ubuntu’s cloud image, base’s packages).dockermoves Docker’s packages (assets/layers/docker.sh), read from its repository for base’s Ubuntu release, and its signing key. Packages added toassets/layers/base.shorassets/features/need no pin of their own: they change base’s key, whose first build records them. misemoves base’s mise (assets/layers/base.sh) and devvm’s host mise (internal/tools/mise.go) together: the host one writes the lock base’s installs from.TestMisePinsfails when their versions or Linux checksums differ, and base’s mise version is in the key of each tool image mise installs, so a bump builds them again.- The shared tools aren’t pinned for users: holds go in
assets/shared-tools/mise.toml(node = "24"stays on that major).testdata/mise.lockis a fixed lock for the tests. - When opencode’s major version changes, or a bump of
e2e-toolsmoves opencode to a version that adds migrations, refreshinternal/vm/testdata/opencode-<version>.sql, the schema of opencode’s database thatTestOpencodeCleanSchemacleans. Print it withsqlite3 ~/.local/share/opencode/opencode.db .schemain a VM with that opencode, afteropencode auth listhas made the database, and copy only the schema, never rows. Name the file for the version, and in the test put each new table in its list of tables that devvm empties or keeps, which the test asks for. A table or a column that holds a login also goes intoopencodeLogins(internal/vm/agents.go) and the design note’s opencode’s database in history exports. e2e-tools,e2e-fromande2e-ubuntumove the end-to-end tests’ own pins, which keep their cached images valid between runs:e2e-toolsresolvestests/e2e-mise.lockagain, with devvm’s pinned host mise, for both architectures’ glibc and musl VMs, and refuses a tool whose download has no checksum on every platform yet;e2e-frommoves the digests ofnode:24,alpine:3andubuntu:26.04intests/e2e/from_test.goto Docker Hub’s;e2e-ubuntumovese2eUbuntuReleaseintests/e2e/sandbox_test.go, the dated release of Ubuntu’s cloud image that e2e pins base to in place of the newest, to the newest Ubuntu lists. Ubuntu removes dated releases after a few months, and e2e’s going stops its first builds of base: bumpe2e-ubuntubefore that. Rune2e-toolsafter any change toassets/shared-tools/mise.toml, because the end-to-end tests refuse a lock resolved from another one (end-to-end tests).mise run testcatches it first:TestE2ELockPinschecks the lock’s first line againstmise.tomland that it locks each of its tools, andTestE2EUbuntuPinschecks thate2eUbuntuReleaseis a dated release of base’s Ubuntu release, whiche2e-ubuntulists, and thatscripts/bump.shandtests/e2ename only that release.e2e-limamoves the Lima release that CI’s end-to-end workflow installs,LIMA_VERSIONin.github/workflows/e2e.yml, to Lima’s newest release, andLIMA_SHA256to itsLinux-x86_64tarball’s line in that release’sSHA256SUMS. Lima’s release notes print the SHA-256 ofSHA256SUMSitself, soe2e-limachecks the downloaded file against that value first and edits nothing when they differ.assets/layers/ubuntu-cloud.gpgis Ubuntu’s cloud image signing key (fingerprintD2EB44626FDDC30B513D5BB71A5D6C4C7DB87C81), from keyserver.ubuntu.com. If Ubuntu changes it, pinning a new image (a first build of base, ordevvm rebuild base) stops withubuntu_bad_signature: fetch the new key, check its fingerprint against Ubuntu’s announcement, and replace the file (gpg --export FPR).assets/layers/ubuntu-archive.gpgis Ubuntu’s archive signing key, “Ubuntu Archive Automatic Signing Key (2018)” (fingerprintF6ECB3762474EDA9D21B7022871920D1991BC93C), fetched from keyserver.ubuntu.com by that fingerprint, checked against the fingerprint that Ubuntu’s security team lists, and exported withgpg --export-options export-minimal --export FPR. devvm checks theInReleasefiles of a first build of base against it (ubuntu.ArchiveKeyring). If Ubuntu signs the archive with a new key, that build stops withpackages_refused: fetch the new key, check its fingerprint the same way, and replace the file. The key must be RSA, becauseinternal/ubuntu/pgp.gochecks only RSA signatures. Moving base to a new Ubuntu release (ubuntu.Series,ubuntu.Version) also movesubuntu.Released, the day Ubuntu released it, which is the date of that release’s ownInRelease(dists/<series>/InRelease), because devvm refuses a release pocket dated before it.- Edited scripts change image keys, so the affected images rebuild on next use. An edit to
assets/prelude.shchanges the key of every image and every shared tool image. Build them in a sandbox of their own (LIMA_HOME,DEVVM_LIMA_HOME,XDG_DATA_HOME,XDG_CONFIG_HOME) before committing (devvm build docker, or a VM made with the tools whose build script changed), thendevvm gc, and run the end-to-end tests.
Checking base against Ubuntu’s newest
The end-to-end tests pin base to e2eUbuntuRelease, so they never build base the way a user’s first build does, from Ubuntu’s newest cloud image and the mirrors’ newest packages. An upstream change can break those builds before any test sees it. For example, vz’s direct kernel boot needs an uncompressed arm64 kernel, and a VM shuts down only when its kernel has gpio_keys. mise run e2e -- --newest-base builds base as a user’s first build does and runs the checks that prove it works: images, then project (a VM that boots, stops, starts again and shuts down) and tools. --only chooses other sections in their place.
The run gives devvm no release to pin. It reads the newest release from Ubuntu’s listing, as scripts/bump.sh e2e-ubuntu does, and prints it at its start next to e2eUbuntuRelease. It runs in a sandbox of its own, which is slot 0’s with -newest added to its Lima home, state folder, Keychain service and lock (~/.lt-e2e-go-newest, /tmp/devvm-go-e2e-newest, devvm-go-e2e-newest and /tmp/devvm-go-e2e-newest.lock), with each fixed port slot 0’s plus 1000. So the cached pinned images in the other sandboxes stay as they are. Each run first deletes its sandbox’s images, tool images and package cache, so base is built from nothing, with its packages from the mirrors. The images section checks that devvm pinned the release Ubuntu listed as its newest and that base booted from it. When the newest doesn’t boot, a user’s devvm falls back to the release before it (origin_no_boot), so that check fails on the fallback. Its times go in timings/plain-newest/ in slot 0’s state folder, apart from the cached runs’. --newest-base can’t be combined with --slots or --slot.
Run it before each release and every week or two between them, on an arm64 Mac (vz) and on a Linux host (QEMU) when you can, because the two boot base differently. At its end, it prints the release Ubuntu listed and the release devvm pinned. When a check fails, it says UPSTREAM DRIFT and names the release, because a user’s first build of base gets the same failure now. Then:
- Read the failed check’s output and the run log of base’s build, which the build’s output names (
log_details), in the sandbox’s state folder underdata/devvm/logs/runs/. - Run
mise run e2e -- --only imageson the pinned release. If it passes, the failure is upstream’s, from the newer image or its packages. If it fails too, the fault is devvm’s own, so fix that first. - Fix devvm for the new release, such as a change to
assets/layers/base.shor to the Lima template. Then run--newest-baseagain until it passes. - Bump
e2e-ubuntuto that release once it passes, so the other end-to-end runs test it too.
Until a fix ships, users can still build base, because devvm’s fallback to the release before covers a newest release that doesn’t boot. A package that breaks base has no fallback, so a fix for one goes in the next release.
Releasing
Once, the keys: a minisign key for releases,
minisign -G -p packaging/minisign.pub -s ~/.minisign/devvm.key(commitpackaging/minisign.pub; the secret key and its password never go in the repo), and a key git signs tags with (git config user.signingkey, GPG, or SSH withgpg.format ssh) thatgit tag -vverifies on this machine. Commit its fingerprint inpackaging/release-signer, one per line (#comments): the GPG fingerprint (gpg --fingerprint, no spaces), or the SSH key’sSHA256:…(ssh-keygen -lf KEY.pub).release.shrefuses a tag signed by any other key, even one git trusts. To rotate, add the new key’s line, tag with it, then remove the old one.Each release: bump
VERSION, add theCHANGELOG.mdentry and commit it onmain. Then tag main’s HEAD withgit tag -s v<version>and runmise exec -- scripts/publish.sh, which asks for the minisign password once and asks once more before it publishes anything. The secret key never leaves this machine, and only minisign reads it, so no CI job and no GitHub secret ever has it.Before it builds,
scripts/publish.shchecks what it needs and stops with nothing done when a check fails. The tagv<version>has to passrelease.sh’s own checks, so it exists, is signed, and is signed by a key inpackaging/release-signer. The tag’s commit has to bemain’s HEAD, andorigin’smainhas to be an ancestor of it, so that the push is a fast-forward. Iforiginalready has the tag, it has to be the same tag. The working tree has to be clean,ghhas to be installed and logged in (gh auth status), and minisign has to be installed. A published GitHub release for the tag is refused unless it already has every file, which is the case when an earlier run stopped after it. The tap’s checkout, a clone ofdevvmhq/homebrew-tap, has to exist, be clean and be onmain: it’sDEVVM_TAP_DIR, or../homebrew-tapnext to this repo by default.Then it builds and shows the release. It runs
scripts/release.sh, which buildsdist/and signsSHA256SUMS, and where minisign asks for the password. It prints the version, the tag’s commit, each file with its size and SHA-256, the release notes, which are theCHANGELOG.mdentry for the version, and the tap’s change as agit diffofFormula/devvm.rb. It lists the four steps, each marked done or to do. Then it asksPublish devvm <version>? [y/N]on the terminal, and any answer but y stops it with nothing published.--dry-runstops before the question, so it pushes and publishes nothing and makes no commit in the tap, and--helpdescribes the script.Then it publishes, in this order. It pushes
mainand then the tag toorigin. It makes the GitHub releasedevvmhq/devvmv<version>withgh release create --verify-tag, titleddevvm <version>, with the release notes and the archives, the source tarball,SHA256SUMS,SHA256SUMS.minisigandinstall.sh. The release starts as a draft and is published only once every file is uploaded, so nobody sees a release that is missing files. Last, it copiesdist/devvm.rbto the tap’sFormula/devvm.rb, commits it asdevvm <version>with the tap’s own commit signing, and pushes the tap. It usesghfor GitHub and never puts a GitHub token on a command line. At the end it prints how to check the release:brew update && brew upgrade devvm, orbrew trust --formula devvmhq/tap/devvmand thenbrew install devvmhq/tap/devvm, and the install script’scurlline.When a step fails, it says which steps it published and which it didn’t. Fix what failed and run it again, because every step skips what’s already done: a pushed
mainor tag, a published release whoseSHA256SUMSmatches the new build’s, and a tap already at the version. A draft release that an earlier run left gets this build’s files again before it’s published. A release published from another build, whoseSHA256SUMSdoesn’t match, is refused. To finish by hand instead, run the steps it didn’t do in order:git push origin main,git push origin v<version>, thegh release createabove (orgh release upload --clobberto a draft, thengh release edit v<version> --draft=false), and then copydist/devvm.rbto the tap, commit it and push it.scripts/release.shchecks the tag withgit tag -vand its signer against this checkout’spackaging/release-signer, then buildsdist/from the tagged commit with GoReleaser (.goreleaser.yaml), in a clone of its own, so only what’s committed goes in, with onegoreleaser release --skip=publish. It checks that the binary built for this host prints the version, writesinstall.shand adds its checksum toSHA256SUMS, then signsSHA256SUMSwith~/.minisign/devvm.key(orDEVVM_MINISIGN_KEY) and verifies it withpackaging/minisign.pub. Nothing is published, because publishing isscripts/publish.sh’s.scripts/release.sh --checkruns only the tag’s checks and the build’s preconditions, prints the tag’s commit and builds nothing, andscripts/publish.shruns it first.dist/then holds:File What devvm-<version>-<os>-<arch>.tar.gzthe binary, its files ( assets/) embedded, withREADME.md,CHANGELOG.mdandLICENSE, fordarwinandlinux,arm64andamd64devvm-<version>.tar.gzthe source, attached to the release for whoever wants to build devvm themselves SHA256SUMS,SHA256SUMS.minisigtheir checksums and install.sh’s, and the signatureinstall.shthe install script, set to this release and to packaging/minisign.pub’s keydevvm.rbthe Homebrew formula, set to each platform’s archive, its URL and its checksum from SHA256SUMSscripts/release.sh --headbuilds from HEAD’s commit instead, for testing, withVERSION’s version. It’s unsigned unlessDEVVM_MINISIGN_KEYnames a test key;DEVVM_MINISIGN_PUBnames its public key in place ofpackaging/minisign.pub.DEVVM_RELEASE_URLpoints the formula at a test location. A commit withoutinstall.shis refused.scripts/release.sh --formula-from DIRwrites onlydist/devvm.rb, forVERSION’s release once it’s published, and builds nothing. Download the release’sSHA256SUMSandSHA256SUMS.minisigintoDIRfirst. The script checks the signature againstpackaging/minisign.pub, then fills in the formula the same way a build does, from thatSHA256SUMS.To try
dist/install.shon such a build, copydist/toDIR/releases/download/v<version>/, then in a throwawayHOMEwithDEVVM_PREFIXunder it:DEVVM_INSTALL_TEST=1 source dist/install.sh, setrepo_url=file://DIRandprotos='=https,file'(it downloads over HTTPS only), and runmain --no-deps. Without--no-depsit offers to install what the host lacks: answer no, or run it with no terminal.Every build sets the version with
-X github.com/devvmhq/devvm/internal/cli.version=<version>: devvm doesn’t readVERSION. The formula (packaging/homebrew/devvm.rb) builds nothing. It has an archive for each of macOS and Linux on arm64 and Intel, the same archives the install script installs, and it installs the binary alone from the one for this machine.release.shfills in each archive’s URL and its checksum, which it reads from theSHA256SUMSit just wrote, on every line that isn’t a comment. The generated formula passesbrew audit --strict --onlineandbrew style.Publishing is the maintainer’s call, so an agent asks first and never runs
scripts/publish.shwithout--dry-runon its own. The script’s tests (testdata/script/publish.txtar) run it against a local bare repo asorigin, a local bare tap and a fakegh.