Releasing#

Cutting one#

Bump version in the workspace Cargo.toml, write the matching section in CHANGELOG.md, and push to main.

That is the release. The workflow tags it, publishes the notes from the changelog, and builds every artifact.

Run the gate first — cargo test, cargo clippy --all-targets, and a release build — because the version bump is the trigger and there is no draft step to catch a failure behind.

Why the changelog and not generated notes#

Release notes generated from commit subjects say what was touched. The changelog says what changed for the person installing it. scripts/notes.sh extracts the section for a version and exits non-zero when there is none, so the workflow can fall back to generated notes with a warning rather than publishing an empty release body.

What the workflow does#

joboutput
check-versionreads app.env and the version; skips if the tag exists
create-releasetags, publishes the release with the changelog section
macos.app → notarize → .dmg → notarize → checksum → upload
linuxx86_64 and aarch64 matrix: .tar.gz, .deb, .AppImage, checksums
windows.zip, .msi, .nupkg
homebrewrewrites the cask in wess/homebrew-packages
scooprewrites packaging/scoop/app.json and commits it back

check-version decides by asking whether the tag already exists, not by diffing HEAD~1. A diff would miss a manual dispatch and a re-released version; this is idempotent for both.

The Scoop job retries its push with a rebase up to five times. It runs after the Windows build, by which point main has often moved — the release commit is rarely the last one pushed — and a plain push is rejected non-fast-forward, silently missing the release. The manifest is the only file it touches, so a rebase has nothing to conflict with.

Signing and notarization#

Signing credentials are optional. Without them every job still runs and produces ad-hoc-signed artifacts, with a warning in the log. That keeps a fork building.

With them, the macOS job imports the Developer ID into a temporary keychain (wiped when the job ends), signs with a hardened runtime and a timestamp, and notarizes and staples both the .app and the .dmg.

Repository secrets:

secretwhat it is
APPLE_SIGNING_IDENTITYthe identity string, e.g. Developer ID Application: … (TEAMID)
APPLE_CERT_P12the exported certificate, base64
APPLE_CERT_PASSWORDits export password
KEYCHAIN_PASSWORDany value; it protects the temporary keychain
APPLE_ID / APPLE_APP_PASSWORD / APPLE_TEAM_IDnotarytool credentials
HOMEBREW_TAP_TOKENwrite access to the tap repository

Verifying it actually notarized#

A signed-but-not-notarized build looks identical until someone else downloads it. Check the staple, not just the signature:

xcrun stapler validate dist/<Name>.dmg
spctl -a -vvv -t install dist/<Name>.dmg
codesign --verify --strict --verbose=2 dist/<Name>.app

spctl should say accepted and source=Notarized Developer ID.

Self-update#

Set the Developer ID team in crates/app/src/update.rs:

Update::github("Acme", env!("CARGO_PKG_VERSION"), "wess/acme").team_id("XXXXXXXXXX")

Without it guise refuses to execute a downloaded bundle and opens the release page instead — so an app that ships notarized builds and leaves this off has quietly lost its own update path.

The install is in place: on macOS the DMG is mounted and rsynced onto the installed .app, so the bundle's path and inode never change and LaunchServices' registration stays valid. On Linux the new AppImage is renamed over the running one. Everything else (a distro package, a dev build, Windows) opens the release page.

A release is only offered once it has published the asset this machine would install. Release hosts publish before CI uploads, so a newer tag can be visible for the length of a notarization run with nothing usable on it; prompting in that window yields an Update button that can only fail.

Type to search guides and package references.