Skip to main content

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.json and wails.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 .sha256 and a .sig beside every artifact;
  • uploads all of it onto the Release that release.yml already 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

PlatformInstallSelf-updates
macOS.dmg → drag to Applicationsyes
Windows-setup.exe (installer)yes, via the .zip
Windows.zip → unpack anywhere (portable)yes
Linux.AppImageyes
Linux.debno — use apt
Linux.tar.gzno

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:

GatesNeeded for updates?
Update signing (RSA, this repo's key)every install after the firstyes, mandatory
OS code signing (Apple / Authenticode)the first install, by handno

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:

SecretFor
APPLE_CERTIFICATEbase64 of the .p12
APPLE_CERTIFICATE_PASSWORDits password
APPLE_SIGNING_IDENTITYe.g. Developer ID Application: Name (TEAMID)
APPLE_ID, APPLE_APP_PASSWORD, APPLE_TEAM_IDnotarization
WINDOWS_CERTIFICATEbase64 of the .pfx
WINDOWS_CERTIFICATE_PASSWORDits 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.