# Pinning, release signing, and supply-chain verification

> Maintain SHA-256 download pins and Ubuntu PGP archive checks with scripts/bump.sh, build and sign releases with Minisign, and publish artifacts.

- Repository: devvmhq/devvm
- GitHub: https://github.com/devvmhq/devvm
- Human docs: https://grok-wiki.com/public/docs/devvmhq-devvm-4242986d2853
- Complete Markdown: https://grok-wiki.com/public/docs/devvmhq-devvm-4242986d2853/llms-full.txt

## Source Files

- `scripts/bump.sh`
- `scripts/release.sh`
- `scripts/publish.sh`
- `.goreleaser.yaml`
- `internal/ubuntu/pgp.go`
- `internal/ubuntu/archive.go`
- `internal/fetch/fetch.go`

---

---
title: "Pinning, release signing, and supply-chain verification"
description: "Maintain SHA-256 download pins and Ubuntu PGP archive checks with scripts/bump.sh, build and sign releases with Minisign, and publish artifacts."
---

`devvm` enforces cryptographic verification across every external download, Ubuntu cloud image, APT archive index, and release binary. Image build scripts pin tool versions and per-architecture SHA-256 digests managed by `scripts/bump.sh`, `internal/ubuntu` verifies OpenPGP signatures on Ubuntu cloud image checksums and APT `InRelease` files against embedded RSA keyrings, `internal/fetch` enforces redirect policies, size limits, and SHA-256 checks on host HTTP transfers, and `scripts/release.sh` alongside `scripts/publish.sh` builds GoReleaser artifacts from signed Git tags and signs `SHA256SUMS` with Minisign (`ED` Blake2b-512 prehashed Ed25519 signatures).

```mermaid
flowchart TB
  subgraph Maintainer["Maintainer & CI workflows"]
    Bump["scripts/bump.sh\n(.github/workflows/pins.yml)"]
    Tag["Signed Git tag (vX.Y.Z)\nchecked vs packaging/release-signer"]
    Rel["scripts/release.sh\n+ .goreleaser.yaml"]
    Pub["scripts/publish.sh\n(GitHub release + homebrew-tap)"]
  end

  subgraph RepoPins["Committed repository pins & keys"]
    Scripts["assets/layers/*.sh\nassets/features/*.sh\nassets/shared-tools/build.sh\nassets/oci/build.sh\ninternal/tools/mise.go"]
    PGPKeys["assets/layers/ubuntu-cloud.gpg\nassets/layers/ubuntu-archive.gpg"]
    MinisignPub["packaging/minisign.pub\n(packaging.MinisignKey)"]
  end

  subgraph RuntimeVerify["Host & guest verification"]
    Prelude["assets/prelude.sh (devvm_sha)\n+ /usr/local/bin/devvm-fetch"]
    UbuntuPkg["internal/ubuntu (pgp.go, archive.go)\n+ internal/build/packages.go"]
    FetchPkg["internal/fetch.Client\n(HTTPSOnly / SameScheme / Download)"]
    UpgradePkg["install.sh & internal/upgrade\n(PublicKey.Verify + SHA256SUMS)"]
  end

  Bump -->|"stages & validates regexes"| Scripts
  Scripts --> Prelude
  PGPKeys --> UbuntuPkg
  FetchPkg --> UbuntuPkg
  Tag --> Rel
  Rel -->|"minisign -Sm SHA256SUMS"| Pub
  MinisignPub --> Rel
  MinisignPub --> UpgradePkg
  Pub -->|"dist/* + SHA256SUMS.minisig"| UpgradePkg
  FetchPkg --> UpgradePkg
```

## Download pinning and `scripts/bump.sh`

Every external tool fetched during image construction or host toolchain resolution is pinned by version and per-architecture checksum. No build script runs unverified `curl | sh` pipelines or resolves floating `"latest"` URLs at build time.

### Guest download verification (`prelude.sh` and `devvm-fetch`)

Before `devvm` executes any image build script with `bash` (`assets/layers/base.sh`, `assets/layers/apt.sh`, built-in layers, features, `assets/shared-tools/build.sh`, and `assets/oci/build.sh`), it prepends `assets/prelude.sh` to the script input and hashes both together into the image key.

`assets/prelude.sh` sets two environment variables and defines one lookup function:

- `devvm_arch`: normalizes `uname -m` (`aarch64`/`arm64` to `arm64`, `x86_64`/`amd64` to `amd64`).
- `devvm_libc`: sets `musl` when `apk` is present on `PATH`, or `gnu` otherwise.
- `devvm_sha <tool>`: resolves `<tool>_sha256_<arch>_musl` when running on `musl` (if defined in the script), or `<tool>_sha256_<arch>`. Fails immediately if the script does not define the variable.

`assets/layers/base.sh` installs `/usr/local/bin/devvm-fetch`, which all layer, feature, and shared-tool scripts call to download pinned files:

```sh title="assets/layers/base.sh"
#!/bin/sh
set -eu
url=$1 want=$2 out=$3
curl -fsSL --retry 3 -o "$out" "$url"
sum=sha256sum
[ "${#want}" -ne 128 ] || sum=sha512sum
got=$("$sum" "$out" | cut -d' ' -f1)
if [ "$got" != "$want" ]; then
  rm -f "$out"
  printf 'devvm-fetch: checksum mismatch for %s\n  expected %s\n  got      %s\n' "$url" "$want" "$got" >&2
  exit 1
fi
```

`devvm-fetch` checks 64-character hex strings with `sha256sum` and 128-character hex strings (used for Microsoft `.NET` SDK archives in the tools lock) with `sha512sum`, removing the output file before exiting on mismatch.

### Pin targets managed by `scripts/bump.sh`

`scripts/bump.sh` inspects and updates all 14 pinned targets across `assets/`, `internal/tools/mise.go`, `tests/e2e/`, and `.github/workflows/e2e.yml`. Requires `curl` and `jq` on `PATH`.

```bash
# Compare all current pins against upstream without modifying files
scripts/bump.sh --check

# Update all pins and print a commit-ready summary
scripts/bump.sh

# Update specific targets only
scripts/bump.sh mise rustup docker
```

| Target | Pinned file(s) | Variable(s) updated | Upstream source and checksum verification |
| --- | --- | --- | --- |
| `docker` | `assets/layers/docker.sh` | `docker_version`, `containerd_version`, `buildx_version`, `compose_version`, `model_version`, `docker_gpg_sha256` | Reads `download.docker.com/linux/ubuntu/dists/resolute/stable/binary-arm64/Packages` for `docker-ce`, `containerd.io`, `docker-buildx-plugin`, `docker-compose-plugin`, and `docker-model-plugin`; hashes `https://download.docker.com/linux/ubuntu/gpg`. |
| `nix` | `assets/layers/nix.sh` | `nix_version`, `nix_sha256_arm64`, `nix_sha256_amd64` | Parses the tarball version from `https://nixos.org/nix/install` and reads published `.tar.xz.sha256` files from `releases.nixos.org`. |
| `direnv` | `assets/layers/nix.sh` | `direnv_version`, `direnv_sha256_arm64`, `direnv_sha256_amd64` | Resolves latest GitHub release of `direnv/direnv` and computes SHA-256 from `direnv.linux-arm64` and `direnv.linux-amd64` binaries. |
| `nix-direnv` | `assets/layers/nix.sh` | `nix_direnv_version`, `nix_direnv_sha256` | Resolves latest GitHub release of `nix-community/nix-direnv` and computes SHA-256 of `direnvrc`. |
| `mise` | `assets/layers/base.sh`, `internal/tools/mise.go` | `mise_version`, `mise_sha256_arm64`, `mise_sha256_amd64`, `mise_sha256_arm64_musl`, `mise_sha256_amd64_musl`, `miseVersion`, `macos-arm64`, `macos-x64`, `linux-arm64`, `linux-x64` | Reads `SHASUMS256.txt` from the latest `jdx/mise` release. Updates both guest `base.sh` and host `internal/tools/mise.go` together (`TestMisePins` enforces equality). |
| `rustup` | `assets/shared-tools/build.sh` | `rustup_version`, `rustup_sha256_arm64`, `rustup_sha256_amd64`, `rustup_sha256_arm64_musl`, `rustup_sha256_amd64_musl` | Reads `https://static.rust-lang.org/rustup/release-stable.toml` and fetches published `rustup-init.sha256` files for glibc and musl targets. |
| `crane` | `assets/oci/build.sh` | `crane_version`, `crane_sha256_arm64`, `crane_sha256_amd64` | Reads `checksums.txt` from the latest `google/go-containerregistry` GitHub release. |
| `omb` | `assets/features/omb.sh` | `omb_commit` | Resolves the `master` branch HEAD commit SHA of `ohmybash/oh-my-bash`. |
| `eza` | `assets/features/cli-tools.sh` | `eza_version`, `eza_sha256_arm64`, `eza_sha256_amd64` | Resolves latest `eza-community/eza` release and computes SHA-256 of the `aarch64-unknown-linux-gnu` and `x86_64-unknown-linux-musl` tarballs. |
| `tealdeer` | `assets/features/cli-tools.sh` | `tealdeer_version`, `tealdeer_sha256_arm64`, `tealdeer_sha256_amd64` | Resolves latest `tealdeer-rs/tealdeer` release and reads published `.sha256` files for `aarch64-musl` and `x86_64-musl`. |
| `e2e-tools` | `tests/e2e-mise.lock` | Header `# devvm e2e: shared-tools/mise.toml sha256:<hex>` and all `[[tools.*]]` entries | Downloads the host `mise` binary pinned in `internal/tools/mise.go`, verifies its SHA-256, runs `mise lock --platform linux-arm64,linux-arm64-musl,linux-x64,linux-x64-musl` on `assets/shared-tools/mise.toml`, injects `.NET` SDK URLs and SHA-512 sums from `builds.dotnet.microsoft.com`, and verifies every non-`rust` tool has a platform checksum. |
| `e2e-from` | `tests/e2e/from_test.go` | `fromNodeDigest`, `fromAlpineDigest`, `fromUbuntuDigest` | Fetches manifest list indexes from `registry-1.docker.io` for `library/node:24`, `library/alpine:3`, and `library/ubuntu:26.04` and computes their `sha256:<hex>` digests. |
| `e2e-ubuntu` | `tests/e2e/sandbox_test.go` | `e2eUbuntuRelease` | Reads `https://cloud-images.ubuntu.com/releases/resolute/` and selects the newest `release-YYYYMMDD` directory. |
| `e2e-lima` | `.github/workflows/e2e.yml` | `LIMA_VERSION`, `LIMA_SHA256` | Fetches the latest `lima-vm/lima` release, extracts the `SHA256SUMS` file's own SHA-256 from the release notes body, verifies the downloaded `SHA256SUMS` against that digest, and extracts the checksum for `lima-<version>-Linux-x86_64.tar.gz`. |

### Atomic staging and validation rules in `scripts/bump.sh`

Because several files modified by `scripts/bump.sh` execute on the host or inside build VMs, `scripts/bump.sh` validates every upstream value against a strict regular expression in `valid()` before staging any edit:

- `*_sha256*`: `^[0-9a-f]{64}$`
- `*_commit`: `^[0-9a-f]{40}$`
- `*_digest` or `*Digest`: `^sha256:[0-9a-f]{64}$`
- `*_release`: `^release-[0-9]{8}$`
- `*_built`: `^[0-9]{8}T[0-9]{6}Z$`
- All other version pins: `^[0-9A-Za-z][0-9A-Za-z.+~_-]*$`

In `report()`, control characters (`\000-\037\177`) are stripped before printing upstream strings to the terminal, and if an upstream release marker lags behind the currently pinned version (`sort -V`), `bump.sh` reports `newer than upstream's latest` and refuses to downgrade the pin. All edits are queued in the `staged` array by `set_var()` and applied in `write_staged()` only after every target succeeds, leaving the working tree untouched if any download or validation fails.

<Note>
Every Monday at `06:47 UTC`, `.github/workflows/pins.yml` runs `scripts/bump.sh --check` and opens or updates a single GitHub issue titled `"Pinned versions to bump"` (or closes it when all pins are current). The workflow has `contents: read` permission and never commits changes automatically.
</Note>

## Host HTTP client (`internal/fetch`)

All host-side HTTP requests in `devvm` go through `internal/fetch.Client`. Constructing a zero `&fetch.Client{}` literal fails every request with `fetch.ErrNoClient`; callers must call `fetch.NewClient(timeout, redirects)`.

| Function / Type | Signature | Behavior and verification |
| --- | --- | --- |
| `NewClient` | `NewClient(timeout time.Duration, r Redirects) *Client` | Clones `http.DefaultTransport`, sets the request timeout (covering connection and full body read), and attaches a redirect policy (`HTTPSOnly` or `SameScheme`, capped at `maxRedirects = 10`). |
| `HTTPSOnly` | `Redirects = iota` | Rejects any redirect target whose URL scheme is not `https` with `fetch.ErrRedirect`. Used by `upgrade.NewSource`. |
| `SameScheme` | `Redirects` | Rejects any redirect that changes the initial request's URL scheme (`from <scheme> to <scheme>`), preventing an HTTP server from redirecting a `file://`-capable client to a local file. Used by `ubuntu.NewClient`. |
| `ReadCapped` | `ReadCapped(r io.Reader, limit int64) ([]byte, error)` | Reads up to `limit + 1` bytes and returns `fetch.ErrTooLarge` if the stream exceeds `limit`. |
| `Get` | `Get(ctx context.Context, c *Client, url string, limit int64) ([]byte, error)` | Requires `200 OK` (returning `*fetch.StatusError` otherwise) and reads the body through `ReadCapped(body, limit)`. |
| `Download` | `Download(ctx context.Context, c *Client, url, path, sum string, limit int64) error` | Streams `url` to `path` (`0600`) while hashing with `crypto/sha256` and limiting size to `limit`. Deletes `path` and returns `fetch.ErrTooLarge` or `fetch.ErrChecksum` if the byte count exceeds `limit` or the hex digest does not match `sum`. |
| `Retry` / `Transient` | `Retry(ctx context.Context, retries int, delay time.Duration, try func() (bool, error)) error` | Retries with exponential backoff (`delay *= 2`) only when `Transient(err)` is true: network I/O errors, HTTP `408`, `429`, or `5xx`. Non-transient errors (`ErrRedirect`, `ErrNoClient`, `ErrProxy`, `ErrTooLarge`, `ErrChecksum`) stop immediately. |

## Ubuntu OpenPGP and archive verification

`devvm` does not shell out to `gpg` on the host. Instead, `internal/ubuntu/pgp.go` implements a minimal RFC 4880 packet reader backed by Go's standard library (`crypto/rsa`, `crypto/sha256`, `crypto/sha512`) that verifies Ubuntu cloud image checksums and APT archive `InRelease` files against embedded RSA keyrings.

### Embedded Ubuntu keyrings

| Constant | Embedded path | Key identity and fingerprint | Verification method |
| --- | --- | --- | --- |
| `ubuntu.Keyring` | `assets/layers/ubuntu-cloud.gpg` | Ubuntu Cloud Image Builder (`D2EB44626FDDC30B513D5BB71A5D6C4C7DB87C81`) | `verifyDetached` (`sigBinary = 0x00`) over `<release>/SHA256SUMS` and `<release>/SHA256SUMS.gpg` |
| `ubuntu.ArchiveKeyring` | `assets/layers/ubuntu-archive.gpg` | Ubuntu Archive Automatic Signing Key (2018) `<ftpmaster@ubuntu.com>` (`F6ECB3762474EDA9D21B7022871920D1991BC93C`) | `VerifyRelease` / `verifyClearsigned` (`sigText = 0x01`) over suite `InRelease` files |

### OpenPGP packet verification rules (`internal/ubuntu/pgp.go`)

`verify()` and `verifyClearsigned()` enforce strict packet constraints and reject any other input with `errBadSignature`:

1. **Keyring parsing (`rsaKeys`)**: Accepts only version 4 `tagPublicKey` (`6`) and `tagSubkey` (`14`) packets using algorithm `pubRSA` (`1`) or `pubRSASignOnly` (`3`), with an exponent between 1 and 4 bytes. Streamed partial-length packets are refused.
2. **Armor decoding (`dearmor`, `decodeArmor`)**: Accepts binary packets or ASCII-armored `-----BEGIN PGP SIGNATURE-----` blocks, validating the 24-bit OpenPGP CRC-24 checksum (`init = 0xB704CE`, `poly = 0x1864CFB`) when present.
3. **Signature packet constraints (`readSignature`, `checkSubpackets`)**:
   - At most `maxSignatures = 4` signature packets (`tagSignature = 2`) are tried per file.
   - Must be version 4, matching `sigType` (`0x00` binary or `0x01` canonical text), signed with RSA (`1` or `3`), and hashed with SHA-256 (`8`), SHA-384 (`9`), or SHA-512 (`10`).
   - Hashed subpackets are inspected for creation time (`subCreated = 2`) and expiration offset (`subExpires = 3`). If `now.Unix() > int64(created)+int64(expires)`, the signature is rejected. Any critical subpacket (`typ & 0x80 != 0`) other than `subCreated`, `subExpires`, `subIssuer` (`16`), or `subIssuerFP` (`33`) is rejected.
4. **Clearsigned text framing (`verifyClearsigned`)**:
   - Must begin with `-----BEGIN PGP SIGNED MESSAGE-----`, followed only by `Hash: ` headers, a blank line, dash-escaped text (`- ` prefix stripped; any unescaped leading `-` fails), and `-----BEGIN PGP SIGNATURE-----`.
   - Trailing spaces/tabs are stripped per RFC 4880, lines are joined with `\r\n` for signature verification (`sigText`), and returned normalized with `\n` line endings.

### Cloud image discovery and pinning (`internal/ubuntu/ubuntu.go`)

On the first build of `base` (Ubuntu `26.04`, series `resolute`), `ubuntu.Finder.Newest` fetches `https://cloud-images.ubuntu.com/releases/resolute/` (up to `maxBody = 4 MiB`, `timeout = 60s`, 3 retries) and iterates `release-YYYYMMDD` directories from newest to oldest:

1. Downloads `<release>/SHA256SUMS` and `<release>/SHA256SUMS.gpg`.
2. Verifies `SHA256SUMS.gpg` against `assets/layers/ubuntu-cloud.gpg` via `verifyDetached`. If a signature fails verification, `Finder.Newest` stops immediately with `ubuntu_bad_signature`—it never falls back to an older release on a bad signature.
3. Extracts the 64-hex digest for `ubuntu-26.04-server-cloudimg-<arch>.img`. Releases that do not list an image for the host architecture are skipped (`errNoImage`).
4. Saves `state.UbuntuPin{Release, File, Digest, Pinned}` on the host and hashes `<release> <file> <digest>` (`ubuntu.PinArg`) into `base`'s image key.

### APT package recording and host verification (`internal/build/packages.go`)

When building `base` for a new image key, `assets/layers/apt.sh` runs in `record` mode inside the build VM and streams `/var/cache/devvm/packages/out` back to the host (`taken.take`). Because the build VM is untrusted, the host re-verifies every file in `internal/build/packages.go`:

1. **Stream caps**: Enforces limits on `Packages` (`64 MiB`), individual `.deb` files (`2 GiB`), total `.deb` stream (`16 GiB`, max `20,000` tar entries), individual `.InRelease` files (`4 MiB`, `32 MiB` total), and `.Packages` lists (`512 MiB`, `2 GiB` total).
2. **`InRelease` signature and staleness check (`taken.verified`)**:
   - Verifies each `<n>.InRelease` against `assets/layers/ubuntu-archive.gpg` via `ubuntu.VerifyRelease`.
   - Rejects any suite not in `{resolute, resolute-updates, resolute-security, resolute-backports}`.
   - Checks `Release.Stale(pin.Release)`: Because Ubuntu's `InRelease` files omit `Valid-Until`, `Stale` rejects the `resolute` release pocket if its `Date` is before `ubuntu.Released` (`20260423`), and rejects `-updates`, `-security`, or `-backports` pockets if their `Date` is more than `pocketLag` (`24h`) older than the pinned cloud image's `release-YYYYMMDD` date (`packages_stale_mirror` or `packages_stale`).
3. **Suite completeness (`signed.complete`)**: Requires verified `InRelease` files and all non-empty `Packages` lists for both `resolute-updates` and `resolute-security` (`ubuntu.RequiredSuites()`) across every architecture used by the recorded packages (`packages_lists_missing`).
4. **List and `.deb` digest binding**: Hashes each `<n>.Packages` list on the host and matches its SHA-256 and byte size against a verified `InRelease`. Hashes each `<sha256>.deb` as it streams to disk and verifies its metadata and SHA-256 match the signed `Packages` list entry (`archiveEntry`).
5. **No downgrade check (`signed.checkNewest`)**: Compares every recorded `.deb` version against the newest version listed across `resolute`, `resolute-updates`, and `resolute-security` (`ubuntu.CompareVersions`), failing with `packages_not_newest` if an older package was substituted.

Once verified, the `.deb` files and `state.PackageSet` are committed to the host cache under `PackagesLock()`. Subsequent builds of the same `base` key run `apt.sh` in `replay` mode using `deb [trusted=yes] file:/var/cache/devvm/packages/in ./` with no network access, after the host re-verifies each cached `.deb`'s SHA-256 and size (`checkCached` and `sendCached`).

### Testing against Ubuntu's newest cloud image

End-to-end tests normally pin `base` to `e2eUbuntuRelease` (`tests/e2e/sandbox_test.go`). To verify that a user's unpinned first build of `base` works against Ubuntu's latest cloud image and live mirror packages, run:

```bash
mise run e2e -- --newest-base
```

`--newest-base` runs in an isolated sandbox (`~/.lt-e2e-go-newest`, `/tmp/devvm-go-e2e-newest`, port offset `+1000`), deletes cached images and package sets first, runs the `images`, `project`, and `tools` sections, and fails with `UPSTREAM DRIFT` if `base` fails to build or falls back to an older release (`origin_no_boot`).

## Building and signing releases (`scripts/release.sh`)

Releases are built locally from a signed Git tag using GoReleaser (`.goreleaser.yaml`) and signed with Minisign. The maintainer's secret Minisign key never leaves the local machine and is never stored in GitHub Actions secrets.

### Key setup and rotation

`scripts/release.sh` enforces two independent cryptographic keys:

1. **Git tag-signing key (`packaging/release-signer`)**:
   - Tags must be created with `git tag -s v<version>`.
   - `scripts/release.sh` runs `git verify-tag` and `git verify-tag --raw`, extracting the GPG fingerprint (`[GNUPG:] VALIDSIG`) or SSH key fingerprint (`SHA256:<43-base64-chars>`) and matching it case-insensitively against `packaging/release-signer` in the current working tree (never reading `packaging/release-signer` from the tagged commit itself).
   - To rotate the tag-signing key, add the new fingerprint to `packaging/release-signer`, sign the tag with the new key, and remove the old fingerprint.
2. **Minisign release key (`packaging/minisign.pub`)**:
   - Generated once with `minisign -G -p packaging/minisign.pub -s ~/.minisign/devvm.key`.
   - `packaging/minisign.pub` is committed to the repository and embedded into the `devvm` binary by `packaging/key.go` (`packaging.MinisignKey()`).
   - The secret key defaults to `~/.minisign/devvm.key` (overridable via `DEVVM_MINISIGN_KEY`).

### `scripts/release.sh` modes and environment variables

Run `scripts/release.sh` through `mise` so the pinned `goreleaser` and `minisign` binaries from `mise.toml` are on `PATH`:

```bash
# Build and sign dist/ from the signed tag v<VERSION>
mise exec -- scripts/release.sh

# Validate tag signature, VERSION, and build prerequisites without building
mise exec -- scripts/release.sh --check

# Build a snapshot from HEAD for local testing
mise exec -- scripts/release.sh --head

# Regenerate Homebrew formulas from an already-published release's SHA256SUMS + SHA256SUMS.minisig
mise exec -- scripts/release.sh --formula-from /path/to/downloaded/dir
```

<ParamField body="DEVVM_MINISIGN_KEY" type="string" default="~/.minisign/devvm.key">
  Path to the Minisign secret key used to sign `dist/SHA256SUMS`. When running `scripts/release.sh --head` without `DEVVM_MINISIGN_KEY`, the build succeeds unsigned and sets `minisign_key=""` in `dist/install.sh`.
</ParamField>

<ParamField body="DEVVM_MINISIGN_PUB" type="string" default="packaging/minisign.pub">
  Public key file used only with `scripts/release.sh --head` to test signing with a throwaway key. Tag builds always use the `packaging/minisign.pub` committed in the tag.
</ParamField>

<ParamField body="DEVVM_RELEASE_URL" type="string" default="https://github.com/devvmhq/devvm/releases/download/v<VERSION>">
  Base URL written into `dist/devvm.rb` and `dist/devvm-rc.rb` for archive downloads.
</ParamField>

### Build pipeline and `dist/` outputs

When invoked for a release, `scripts/release.sh` executes the following sequence:

1. **Version and tag validation**: Reads `VERSION`, verifying it matches `^[0-9]+\.[0-9]+\.[0-9]+(-[a-z]+\.[0-9]+)?$` (such as `0.2.0` or `0.2.0-rc.1`). Verifies `refs/tags/v<version>` exists, passes `git verify-tag` against `packaging/release-signer`, and has an identical `VERSION` file inside the tagged commit.
2. **Clean detached clone build**: Clones the repository into a temporary directory (`$TMPDIR/devvm-release.XXXXXX/src`), checks out the tagged commit detached, and runs `DEVVM_VERSION=$v goreleaser release --clean --skip=publish`.
   - `.goreleaser.yaml` builds `./cmd/devvm` with `CGO_ENABLED=0`, `-trimpath`, `mod_timestamp: "{{ .CommitTimestamp }}"`, and `-ldflags "-s -w -X github.com/devvmhq/devvm/internal/cli.version={{ .Version }}"` across `darwin` and `linux` on `arm64` and `amd64`.
3. **Host binary version check**: Runs the built `devvm --version` binary matching the host OS and architecture and verifies its output equals `VERSION`.
4. **Installer and Homebrew formula generation**:
   - Injects `release_version="<v>"` and `minisign_key="<pubkey>"` into the tagged commit's `install.sh` and writes `dist/install.sh`.
   - Renders `packaging/homebrew/devvm.rb` into `dist/devvm-rc.rb` (class `DevvmRc`, `conflicts_with "devvmhq/tap/devvm"`) for all releases, plus `dist/devvm.rb` (class `Devvm`, `conflicts_with "devvmhq/tap/devvm-rc"`) for non-prereleases, substituting `@URL_<OS>_<ARCH>@` and `@SHA256_<OS>_<ARCH>@` from `dist/SHA256SUMS` and failing if any `@...@` placeholder remains.
5. **Checksum and Minisign signing**:
   - Appends the SHA-256 of `dist/install.sh` to `dist/SHA256SUMS` so users can verify `install.sh` against the release signature before executing it.
   - Signs `dist/SHA256SUMS` with `minisign -Sm "$out/SHA256SUMS" -s "$key" -t "devvm $v"`, binding the version in the signed trusted comment (`devvm <version>`).
   - Verifies the generated `dist/SHA256SUMS.minisig` immediately against `packaging/minisign.pub` (`minisign -Vm "$out/SHA256SUMS" -P "$pubkey"`).

:::files
dist/
├── devvm-<version>-darwin-arm64.tar.gz
├── devvm-<version>-darwin-amd64.tar.gz
├── devvm-<version>-linux-arm64.tar.gz
├── devvm-<version>-linux-amd64.tar.gz
├── devvm-<version>.tar.gz
├── SHA256SUMS
├── SHA256SUMS.minisig
├── install.sh
├── devvm-rc.rb
└── devvm.rb
:::

## Publishing releases (`scripts/publish.sh`)

`scripts/publish.sh` orchestrates the end-to-end release publication to `devvmhq/devvm` and `devvmhq/homebrew-tap`. Every step is idempotent and resumable if a network failure interrupts publication.

<Warning>
Never run `scripts/publish.sh` without `--dry-run` unless explicitly publishing a release. The script pushes to `origin` and `devvmhq/homebrew-tap` and creates public GitHub releases.
</Warning>

<Steps>
  <Step title="Bump VERSION, update CHANGELOG.md, and sign the tag">
    Update `VERSION` and add a matching `## <version>` section to `CHANGELOG.md` on `main`. Commit the changes and create a signed tag at `main`'s `HEAD`:

    ```bash
    git tag -s "v$(tr -d '[:space:]' < VERSION)" -m "devvm $(tr -d '[:space:]' < VERSION)"
    ```
  </Step>
  <Step title="Verify preconditions and preview with --dry-run">
    Run `scripts/publish.sh --dry-run` via `mise` to run all repository, tag, GitHub, and Homebrew tap checks, build and sign `dist/`, and inspect the release notes and formula diffs without pushing:

    ```bash
    mise exec -- scripts/publish.sh --dry-run
    ```

    Before prompting for the Minisign password, `scripts/publish.sh` checks that:
    - The working tree is clean (`git status --porcelain`), `gh` is authenticated (`gh auth status`), and `minisign` is installed.
    - `scripts/release.sh --check` succeeds, the signed tag points to `refs/heads/main`, `origin/main` is an ancestor of `main`, and `origin` does not have a conflicting `refs/tags/v<version>`.
    - The `CHANGELOG.md` file in the tagged commit has a non-empty `## <version>` section.
    - The Homebrew tap checkout at `DEVVM_TAP_DIR` (default `../homebrew-tap`) exists, is clean, is on branch `main`, and is not behind `origin/main`.
  </Step>
  <Step title="Publish artifacts and update the Homebrew tap">
    Run `scripts/publish.sh` and confirm the terminal prompt (`Publish devvm <version>? [y/N]`):

    ```bash
    mise exec -- scripts/publish.sh
    ```

    `scripts/publish.sh` executes four steps in order, skipping any step already completed on a prior run:
    1. `git push origin main`
    2. `git push origin refs/tags/v<version>`
    3. Creates the GitHub release `v<version>` as a **draft** first (`gh release create --verify-tag --draft`, plus `--prerelease` when `VERSION` contains `-`), uploads all 4 platform archives, the source tarball, `SHA256SUMS`, `SHA256SUMS.minisig`, and `install.sh`, and then publishes the release with `gh release edit --draft=false` so a partial upload is never visible.
    4. Updates `devvmhq/homebrew-tap`: writes `Formula/devvm.rb` (for stable releases) and `Formula/devvm-rc.rb` (whenever the new version is newer than the tap's current `Formula/devvm-rc.rb`, evaluated by `newer()`), commits with message `devvm <version>` (or `devvm-rc <version>` for prereleases), and pushes `main`.
  </Step>
</Steps>

## Client-side signature verification (`install.sh` and `devvm upgrade`)

Both `install.sh` and `devvm upgrade` (`internal/upgrade`) verify the Minisign signature on `SHA256SUMS` before unpacking any release archive.

| Surface | Public key source | Verification implementation | Rollback and safety guarantees |
| --- | --- | --- | --- |
| `install.sh` | `minisign_key` injected by `scripts/release.sh` | Runs `minisign -Vqm "$tmp/SHA256SUMS" -x "$tmp/SHA256SUMS.minisig" -P "$minisign_key"`, then matches `sha256sum` / `shasum -a 256` of `devvm-<v>-<os>-<arch>.tar.gz` against `SHA256SUMS`. | Extracts to `~/.local/lib/devvm/.<v>.new.$$`, atomically updates `~/.local/bin/devvm`, moves to `~/.local/lib/devvm/<v>`, and retains older versions still referenced by running VM processes (`ps -A -ww -o command=`). |
| `devvm upgrade` | `packaging.MinisignKey()` embedded from `packaging/minisign.pub` | `upgrade.Source.Fetch` (`internal/upgrade/source.go`) calls `PublicKey.Verify` (`internal/upgrade/minisign.go`), verifying: (1) algorithm is prehashed `ED` (rejecting legacy unhashed `Ed`), (2) 8-byte key ID matches, (3) Ed25519 signature over `blake2b.Sum512(SHA256SUMS)` is valid, (4) global Ed25519 signature over `signature \|\| trusted_comment` is valid, and (5) trusted comment equals `"devvm " + version`. | Downloads archive via `fetch.Download` (enforcing `archiveCap = 256 MiB` and the `SHA256SUMS` digest), unpacks to `.version.new.<pid>`, runs `devvm --version` before and after switching the symlink (`switchLink`), and rolls back the symlink and directory on failure. Refuses downgrades unless `--to` (`Options.Version`) is specified. |

## Related pages

<CardGroup cols={2}>
  <Card title="Installation" href="/installation">
    Install devvm via Homebrew or the signed release installer and verify Minisign signatures.
  </Card>
  <Card title="Images, layers, and content keys" href="/image-and-layer-model">
    How pinned scripts, prelude.sh, and Ubuntu cloud image pins hash into deterministic image keys.
  </Card>
  <Card title="Manage disk retention, garbage collection, and upgrades" href="/disk-retention-and-upgrades">
    Upgrade devvm installations with devvm upgrade and clean up unused images and cached packages.
  </Card>
  <Card title="Codebase invariants, testscript stubs, and E2E suite" href="/architecture-and-test-harness">
    Run unit, structure, and real-VM E2E tests including --newest-base drift checks.
  </Card>
</CardGroup>
