Releasing yv
How a release is cut, and the two things about it that cannot be undone.
Read this before your first release
Two secrets and one file decide whether updates work at all.
YV_UPDATE_PRIVATE_KEY signs every release artifact. The matching public key is
compiled into the app, in updatePublicKeyPEM in
internal/updater/signature.go. An installed
copy trusts that key and nothing else.
Two consequences, both permanent:
- Losing the private key means no installed copy can ever be updated again. Not "until we fix it" — there is no fix. Every machine already out there will refuse anything signed by a replacement key, because it has never heard of it. Keep an offline copy somewhere you would keep a password manager's recovery code.
- Rotating the key takes two releases, in order. A build carrying the new public key has to reach people before CI starts signing with the new private key. Sign first and every installed copy refuses the very update that would have taught it the new key.
Until the key exists, the app builds and runs perfectly and simply reports that it cannot install updates. That is deliberate: no key means no way to tell a real release from anything else, and the honest answer is better than a hopeful one.
One-time setup
1. Generate the signing keypair
make update-keys
Writes yv-update-private.pem (mode 0600) and yv-update-public.pem. Neither is
committed — .gitignore covers both, as a backstop rather than as the reason.
The target refuses to overwrite an existing private key. A second run would replace the key every installed copy trusts, and nothing would reveal that until updates started being refused in the field.
2. Compile in the public key
make embed-update-key
Writes yv-update-public.pem into updatePublicKeyPEM in
internal/updater/signature.go; commit that file. The app reports
ErrNoTrustedKey while it is empty.
Scripted rather than pasted by hand because a stray blank line or a lost trailing
newline in a Go backtick string still compiles, still looks right in review, and
then refuses every update in the field. The script refuses a PEM containing a
PRIVATE key (the two files sit side by side, one tab-completion apart), checks the
key through openssl pkey before writing, rewrites only the value so the comments
above it survive, and is idempotent — re-run it to confirm the file matches the
key you hold.
3. Store the private key
gh secret set YV_UPDATE_PRIVATE_KEY < yv-update-private.pem
Or paste it at Settings → Secrets and variables → Actions. Then put a copy somewhere offline, and delete the local file once both are done — the offline copy is the one that matters, since this key cannot be regenerated.
4. Required: RELEASE_TOKEN
A PAT with contents: write and pull-requests: write. Not optional — it is
the mechanism, not a convenience.
A tag pushed with the default GITHUB_TOKEN does not start another workflow;
GitHub blocks that so a workflow cannot loop on itself. So a tag pushed by
github-actions[bot] is one build.yml never sees, which is exactly how v0.2.0
came to exist as a bare tag with every installer built and none attached. The job
that pushed it exited 0.
It goes on actions/checkout as well as on the action, because
changesets/action defaults to commitMode: git-cli and therefore pushes through
the remote checkout configured. A token given only to the action would leave the
tag being pushed by the bot again.
Cutting a release
1. Every PR carries a changeset
bun changeset
Pick patch / minor / major and write a line describing the change for someone deciding whether to install it. That line is not just changelog: it is the release body, and the release body is what the update dialog shows.
2. Version
Nothing to run. changesets/action keeps one version PR open and up to date on
every push to main while anything is pending — consuming the changesets, bumping
package.json, writing CHANGELOG.md and mirroring the number into wails.json.
Or locally:
export GITHUB_TOKEN=… # a PAT with read access to this repo
bun run version # changeset version && node scripts/sync-version.mjs
The token is not optional. CHANGELOG.md is generated by
@changesets/changelog-github, which resolves every changeset to its PR and
author over the API, so without one this fails on the first entry — and it asks
for a token by scope, which reads like a broken checkout rather than a missing
export. It has to be a PAT; there is no github.token outside Actions.
Both files must move together. version_test.go fails the build if they drift,
because a mismatch ships a .deb whose package version disagrees with the binary
inside it — and the updater then compares the wrong number.
3. Merge, and the tag follows
Merging the version PR leaves nothing pending, so the action runs its publish
command (bunx changeset tag) instead of opening another one. Despite the name it
is not optional: the action does no tagging outside runPublish, and skips
straight past with an info log if no publish command is set. That writes
v<version> — the
v prefix is not configured anywhere, it is what changesets uses for a
single-package repo — and the action pushes it and opens the GitHub Release with
that version's CHANGELOG.md entry as the body.
The root package.json is private: false, so tagging needs no further opt-in.
privatePackages: { version: true, tag: true } is set in .changeset/config.json
anyway: changesets skips private packages by default, so if private is ever set
back to true, those flags are the only thing keeping this step alive — without
them changeset tag prints nothing and exits 0, and the release quietly does not
happen.
4. Build publishes
The tag starts build.yml, which:
- checks the tag matches
package.jsonandwails.json(in the test job, so a mismatch costs two minutes rather than three platform builds); - builds macOS, Windows and Linux, with
-ldflags -X main.version=<version>; - packages a DMG, a Windows
-setup.exe, a.zip, a.deb, a tarball and an AppImage; - writes a
.sha256and a.sigbeside every artifact; - uploads all of it onto the Release that
release.ymlalready opened.
Upload rather than create, because the Release exists minutes before any binary
does — it is opened on ubuntu seconds after the tag is pushed. That is also why
build.yml no longer extracts the changelog section itself: changesets/action
does the same extraction from the same file when it opens the Release.
Two packaging tools are resolved by the workflow rather than assumed: create-dmg
(installed with brew; the DMG script falls back to a plain image without it) and
makensis, which is not in the Windows runner image and gets installed with
choco. The NSIS one is checked explicitly because wails build -nsis treats a
missing makensis as a warning and exits 0 — a green build with no installer in
the release.
The signing step fails the build if YV_UPDATE_PRIVATE_KEY is unset. That is
on purpose. An unsigned release looks complete, downloads fine, and is refused by
every machine it reaches — failing loudly beats publishing that.
Which artifact updates itself
| Platform | Install | Self-updates |
|---|---|---|
| macOS | .dmg → drag to Applications | yes |
| Windows | -setup.exe (installer) | yes, via the .zip |
| Windows | .zip → unpack anywhere (portable) | yes |
| Linux | .AppImage | yes |
| Linux | .deb | no — use apt |
| Linux | .tar.gz | no |
Both Windows rows depend on the install directory being writable without
elevation, which is why the installer is per-user
($LOCALAPPDATA\Programs\yv, set in build/windows/installer/project.nsi).
Move an installed copy into Program Files by hand and InstallCheck reports it
cannot replace itself there, and the dialog goes back to offering the releases
page. That is the correct answer rather than a bug — it is also the only reason
Windows was showing that page before the installer became per-user.
The Windows .zip has to be published whether or not anyone downloads it by
hand: pickAsset matches -windows-amd64- and a .zip suffix, and the
updater unpacks an archive because a running .exe cannot be overwritten in
place. Dropping it would leave every installed copy with "no download for this
platform", permanently. The installer is deliberately not matched — applying it
would mean a UAC prompt in the middle of an update the user already approved.
Neither Windows download touches the firewall
An earlier revision had the installer add inbound Windows Firewall rules for
yv.exe. That was reverted along with the rest of the change it shipped in — see
the discovery-regression note in CLAUDE.md. Both Windows downloads now behave
identically: no rule is added, and none is removed on uninstall.
If a Windows machine turns out to need one, it is a per-program rule (the node
binds tcp/0 and udp/0, so the ports are ephemeral) on private,domain only,
and it must be re-introduced with the Windows pair actually measured first — not
inferred from a matrix.
The .deb installs to root-owned /usr/bin, so replacing the binary would mean a
password prompt on every update and going behind dpkg's back, leaving apt
convinced the old version is installed. The tarball is a loose binary with nothing
identifying it as ours. Both are told to use their package manager rather than
offered a download that would not apply.
macOS additionally refuses to self-update when running from a disk image or from the read-only copy Gatekeeper makes of a quarantined app. The dialog says which, and what to do about it.
Code signing
Not set up yet — there is no Apple Distribution certificate and no Windows
Authenticode certificate. The steps exist in build.yml behind the repository
variables SIGNING and NOTARIZE, which are unset, so they never run.
They were workflow_dispatch inputs, which made signing a property of how a run
was started — no use to a release, which starts from a tag push and cannot carry
inputs. A variable is the same off-by-default switch with nothing manual about it.
Keep the two kinds of signing straight:
| Gates | Needed for updates? | |
|---|---|---|
| Update signing (RSA, this repo's key) | every install after the first | yes, mandatory |
| OS code signing (Apple / Authenticode) | the first install, by hand | no |
They are independent, which is why the updater ships today. What OS signing changes is only the warning a new user sees downloading the app themselves: Gatekeeper's "cannot be verified" on macOS, SmartScreen on Windows. Neither recurs on an auto-update — the updater replaces files in place and never goes through the browser download path that applies the quarantine attribute.
When the certificates arrive, add these secrets and set SIGNING (and NOTARIZE)
to true under Settings → Secrets and variables → Actions → Variables. Both are
compared against the string 'true', since vars.* is always a string and any
non-empty value — 'false' included — would otherwise read as on:
| Secret | For |
|---|---|
APPLE_CERTIFICATE | base64 of the .p12 |
APPLE_CERTIFICATE_PASSWORD | its password |
APPLE_SIGNING_IDENTITY | e.g. Developer ID Application: Name (TEAMID) |
APPLE_ID, APPLE_APP_PASSWORD, APPLE_TEAM_ID | notarization |
WINDOWS_CERTIFICATE | base64 of the .pfx |
WINDOWS_CERTIFICATE_PASSWORD | its password |
Verifying a download by hand
shasum -a 256 -c yv-macos-arm64-v0.1.0.dmg.sha256
The .sig is an RSA-4096 PKCS#1 v1.5 signature over the SHA-256 of the decoded
bytes of that hash — a digest of a digest, matching the hot-updater bundle
signing scheme so one key and one tool cover both. internal/updatesign is the
only implementation; see SigningDigest for why the "decoded bytes" part matters.
If something goes wrong
The release published without sidecars. Sign locally and upload them; nothing else needs rebuilding.
gh release download v0.1.0 --dir dist --pattern '*'
YV_UPDATE_PRIVATE_KEY="$(cat yv-update-private.pem)" go run ./cmd/sign-artifact dist/*
gh release upload v0.1.0 dist/*.sha256 dist/*.sig
The tag was pushed but nothing built. RELEASE_TOKEN is missing or expired, so
the tag went up as github-actions[bot] and raised no event. Confirm with
gh run list — a healthy release shows a run whose ref is the tag; check the actor
on it, which should be a person and never the bot. Fix the token, then re-push the
tag from a local checkout: git push --force origin v0.1.0.
A Release exists but has no installers. The tag triggered nothing (as above),
or build.yml failed before its upload step. The Release itself comes from
release.yml and is created before any binary exists, so an empty one is normal
for the few minutes the builds take — and permanent if they fail. Re-run the build
for that tag; the upload uses --clobber and is safe to repeat.
The version PR stopped being updated, or the release did not tag. Check the
root package.json still says private: false or .changeset/config.json
still has privatePackages.tag: true. If neither holds, changeset tag is a no-op
that exits 0 and the whole chain goes quiet with nothing red.
A PR is red on "Changeset present". It changes something releasable and carries
no .changeset/*.md. Add one with bun changeset, or bunx changeset add --empty
if it genuinely should ship no release (tests, CI, tooling).
A user reports "cannot verify updates". They are on a build made before the public key was compiled in. They need to download once by hand; every release after that updates normally.