Skip to content
Maintaining

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 --check lists 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 needs curl and jq. When upstream’s latest is older than a pin, as when a release marker hasn’t moved yet, --check says the pin is newer and bump.sh leaves it as it is.
  • Every Monday the Pins workflow (.github/workflows/pins.yml) runs scripts/bump.sh --check and 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 run scripts/bump.sh with the tools it lists, then the --from tier 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). docker moves Docker’s packages (assets/layers/docker.sh), read from its repository for base’s Ubuntu release, and its signing key. Packages added to assets/layers/base.sh or assets/features/ need no pin of their own: they change base’s key, whose first build records them.
  • mise moves 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. TestMisePins fails 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.lock is a fixed lock for the tests.
  • When opencode’s major version changes, or a bump of e2e-tools moves opencode to a version that adds migrations, refresh internal/vm/testdata/opencode-<version>.sql, the schema of opencode’s database that TestOpencodeCleanSchema cleans. Print it with sqlite3 ~/.local/share/opencode/opencode.db .schema in a VM with that opencode, after opencode auth list has 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 into opencodeLogins (internal/vm/agents.go) and the design note’s opencode’s database in history exports.
  • e2e-tools, e2e-from and e2e-ubuntu move the end-to-end tests’ own pins, which keep their cached images valid between runs: e2e-tools resolves tests/e2e-mise.lock again, 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-from moves the digests of node:24, alpine:3 and ubuntu:26.04 in tests/e2e/from_test.go to Docker Hub’s; e2e-ubuntu moves e2eUbuntuRelease in tests/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: bump e2e-ubuntu before that. Run e2e-tools after any change to assets/shared-tools/mise.toml, because the end-to-end tests refuse a lock resolved from another one (end-to-end tests). mise run test catches it first: TestE2ELockPins checks the lock’s first line against mise.toml and that it locks each of its tools, and TestE2EUbuntuPins checks that e2eUbuntuRelease is a dated release of base’s Ubuntu release, which e2e-ubuntu lists, and that scripts/bump.sh and tests/e2e name only that release.
  • e2e-lima moves the Lima release that CI’s end-to-end workflow installs, LIMA_VERSION in .github/workflows/e2e.yml, to Lima’s newest release, and LIMA_SHA256 to its Linux-x86_64 tarball’s line in that release’s SHA256SUMS. Lima’s release notes print the SHA-256 of SHA256SUMS itself, so e2e-lima checks the downloaded file against that value first and edits nothing when they differ.
  • assets/layers/ubuntu-cloud.gpg is Ubuntu’s cloud image signing key (fingerprint D2EB44626FDDC30B513D5BB71A5D6C4C7DB87C81), from keyserver.ubuntu.com. If Ubuntu changes it, pinning a new image (a first build of base, or devvm rebuild base) stops with ubuntu_bad_signature: fetch the new key, check its fingerprint against Ubuntu’s announcement, and replace the file (gpg --export FPR).
  • assets/layers/ubuntu-archive.gpg is Ubuntu’s archive signing key, “Ubuntu Archive Automatic Signing Key (2018)” (fingerprint F6ECB3762474EDA9D21B7022871920D1991BC93C), fetched from keyserver.ubuntu.com by that fingerprint, checked against the fingerprint that Ubuntu’s security team lists, and exported with gpg --export-options export-minimal --export FPR. devvm checks the InRelease files of a first build of base against it (ubuntu.ArchiveKeyring). If Ubuntu signs the archive with a new key, that build stops with packages_refused: fetch the new key, check its fingerprint the same way, and replace the file. The key must be RSA, because internal/ubuntu/pgp.go checks only RSA signatures. Moving base to a new Ubuntu release (ubuntu.Series, ubuntu.Version) also moves ubuntu.Released, the day Ubuntu released it, which is the date of that release’s own InRelease (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.sh changes 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), then devvm 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:

  1. 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 under data/devvm/logs/runs/.
  2. Run mise run e2e -- --only images on 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.
  3. Fix devvm for the new release, such as a change to assets/layers/base.sh or to the Lima template. Then run --newest-base again until it passes.
  4. Bump e2e-ubuntu to 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 (commit packaging/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 with gpg.format ssh) that git tag -v verifies on this machine. Commit its fingerprint in packaging/release-signer, one per line (# comments): the GPG fingerprint (gpg --fingerprint, no spaces), or the SSH key’s SHA256:… (ssh-keygen -lf KEY.pub). release.sh refuses 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 the CHANGELOG.md entry and commit it on main. Then tag main’s HEAD with git tag -s v<version> and run mise 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.sh checks what it needs and stops with nothing done when a check fails. The tag v<version> has to pass release.sh’s own checks, so it exists, is signed, and is signed by a key in packaging/release-signer. The tag’s commit has to be main’s HEAD, and origin’s main has to be an ancestor of it, so that the push is a fast-forward. If origin already has the tag, it has to be the same tag. The working tree has to be clean, gh has 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 of devvmhq/homebrew-tap, has to exist, be clean and be on main: it’s DEVVM_TAP_DIR, or ../homebrew-tap next to this repo by default.

  • Then it builds and shows the release. It runs scripts/release.sh, which builds dist/ and signs SHA256SUMS, 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 the CHANGELOG.md entry for the version, and the tap’s change as a git diff of Formula/devvm.rb. It lists the four steps, each marked done or to do. Then it asks Publish devvm <version>? [y/N] on the terminal, and any answer but y stops it with nothing published. --dry-run stops before the question, so it pushes and publishes nothing and makes no commit in the tap, and --help describes the script.

  • Then it publishes, in this order. It pushes main and then the tag to origin. It makes the GitHub release devvmhq/devvm v<version> with gh release create --verify-tag, titled devvm <version>, with the release notes and the archives, the source tarball, SHA256SUMS, SHA256SUMS.minisig and install.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 copies dist/devvm.rb to the tap’s Formula/devvm.rb, commits it as devvm <version> with the tap’s own commit signing, and pushes the tap. It uses gh for 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, or brew trust --formula devvmhq/tap/devvm and then brew install devvmhq/tap/devvm, and the install script’s curl line.

  • 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 main or tag, a published release whose SHA256SUMS matches 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, whose SHA256SUMS doesn’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>, the gh release create above (or gh release upload --clobber to a draft, then gh release edit v<version> --draft=false), and then copy dist/devvm.rb to the tap, commit it and push it.

  • scripts/release.sh checks the tag with git tag -v and its signer against this checkout’s packaging/release-signer, then builds dist/ from the tagged commit with GoReleaser (.goreleaser.yaml), in a clone of its own, so only what’s committed goes in, with one goreleaser release --skip=publish. It checks that the binary built for this host prints the version, writes install.sh and adds its checksum to SHA256SUMS, then signs SHA256SUMS with ~/.minisign/devvm.key (or DEVVM_MINISIGN_KEY) and verifies it with packaging/minisign.pub. Nothing is published, because publishing is scripts/publish.sh’s. scripts/release.sh --check runs only the tag’s checks and the build’s preconditions, prints the tag’s commit and builds nothing, and scripts/publish.sh runs it first.

  • dist/ then holds:

    FileWhat
    devvm-<version>-<os>-<arch>.tar.gzthe binary, its files (assets/) embedded, with README.md, CHANGELOG.md and LICENSE, for darwin and linux, arm64 and amd64
    devvm-<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 signature
    install.shthe install script, set to this release and to packaging/minisign.pub’s key
    devvm.rbthe Homebrew formula, set to each platform’s archive, its URL and its checksum from SHA256SUMS
  • scripts/release.sh --head builds from HEAD’s commit instead, for testing, with VERSION’s version. It’s unsigned unless DEVVM_MINISIGN_KEY names a test key; DEVVM_MINISIGN_PUB names its public key in place of packaging/minisign.pub. DEVVM_RELEASE_URL points the formula at a test location. A commit without install.sh is refused.

  • scripts/release.sh --formula-from DIR writes only dist/devvm.rb, for VERSION’s release once it’s published, and builds nothing. Download the release’s SHA256SUMS and SHA256SUMS.minisig into DIR first. The script checks the signature against packaging/minisign.pub, then fills in the formula the same way a build does, from that SHA256SUMS.

  • To try dist/install.sh on such a build, copy dist/ to DIR/releases/download/v<version>/, then in a throwaway HOME with DEVVM_PREFIX under it: DEVVM_INSTALL_TEST=1 source dist/install.sh, set repo_url=file://DIR and protos='=https,file' (it downloads over HTTPS only), and run main --no-deps. Without --no-deps it 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 read VERSION. 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.sh fills in each archive’s URL and its checksum, which it reads from the SHA256SUMS it just wrote, on every line that isn’t a comment. The generated formula passes brew audit --strict --online and brew style.

  • Publishing is the maintainer’s call, so an agent asks first and never runs scripts/publish.sh without --dry-run on its own. The script’s tests (testdata/script/publish.txtar) run it against a local bare repo as origin, a local bare tap and a fake gh.

Last updated on