Releasing tv
This is the operator runbook for issue #12's standalone signed-installer pipeline. Every script referenced here lives under scripts/release/*.mjs, is real and tested (tests/integration/release-*.test.mjs, tests/unit/macos-sign-identity.test.mjs), and is exercised in this document with exact commands. The steps this document cannot itself perform -- creating the public releases repository, generating and safeguarding the production signing key, obtaining a Developer ID certificate, and publishing an actual tag -- are each called out explicitly as operator action.
One-time: create the releases repository (operator action)
fetchReleaseManifest/downloadArtifact (packages/cli/src/release/manifest-fetch.ts, download.ts) send no credentials -- a production install fetches public, unauthenticated GitHub Releases URLs, which requires the releases repository itself to be public (a private repo's release assets need an authenticated request). It is deliberately a separate, source-free repository from thalix-vault itself, so cloning it never exposes application source:
gh repo create thalixinc/thalix-vault-releases --public \
--description "Signed standalone tv CLI release artifacts. No source code; see thalixinc/thalix-vault."
TV_RELEASES_REPO (default thalixinc/thalix-vault-releases, packages/cli/src/release/manifest-fetch.ts's DEFAULT_RELEASES_REPO) controls which repository every script and the CLI's own update engine target; override it everywhere consistently if a different name is ever used.
Key generation and rotation
Generate the Ed25519 release-signing keypair once, on a machine the operator controls (never in CI, never committed):
bun run build:node # scripts/release/*.mjs read the compiled contracts from dist/node
node scripts/release/generate-signing-key.mjs --out ~/.thalix-vault/release-signing-key.pem
This writes the private key to that path (mode 600) and prints its keyId, its public key, and a ready-to-paste EMBEDDED_RELEASE_TRUST_ROOTS entry. Operator action: paste that entry into packages/cli/src/release/trust.ts and commit it -- every compiled standalone binary trusts only what is baked into that array, which is why the array starts empty (no production key exists yet) and why this is the one step in the whole pipeline that must land as a real source commit before a release can ever verify. Keep the private key file only where TV_RELEASE_SIGNING_KEY points; every signing run below reads it from there and refuses if the file is world- or group-readable.
To rotate: run generate-signing-key.mjs --out <new path> again, flip the prior EMBEDDED_RELEASE_TRUST_ROOTS entry's status from "active" to "retired" (never delete it -- manifests signed before the rotation must keep verifying against binaries already in the field), add the new entry as "active", and switch TV_RELEASE_SIGNING_KEY to the new file for future signing runs.
Build
node scripts/release/build-artifacts.mjs \
--version 0.1.0 --channel stable \
--targets darwin-arm64,linux-x64,win32-x64 \
--out-dir dist/release \
--smoke-test
Compiles each target with bun build --compile (Bun cross-compiles every target from any host, no matching OS required) with the version/channel/build-kind baked in via --define (packages/cli/src/release/build-info.ts), and embeds the thalix-vault Agent Skill payload directly into each binary so tv skill * (#11) works without a linked checkout -- see packages/cli/src/skill/embedded-bundle.generated.ts's doc comment for exactly how (transient regeneration, restored to its committed null default afterward; never left dirty in the working tree). --smoke-test runs tv version --json and tv skill doctor --json against whichever built target matches the host OS -- pass only that platform in --targets for it to apply. Only the host platform's artifact is _runnable_ here; a cross-compiled Linux or Windows artifact needs smoke-testing on that OS (a matching CI runner, or .github/workflows/release.yml's build matrix).
macOS code signing and notarization
node scripts/release/macos-sign.mjs dist/release/tv-darwin-arm64 [--json]
Auto-detects the first installed "Developer ID Application: ..." identity (security find-identity, the same pattern ~/development/whisperclone/scripts/make-app.sh uses), or honors an explicit --identity/TV_MACOS_SIGN_IDENTITY override, then runs a plain, direct codesign --force --options runtime --timestamp --sign "<identity>" and verifies the result. When no identity is available, this does not fail the build: it prints a loud UNSIGNED warning and exits 0, so the rest of the pipeline stays fully exercisable before a certificate exists.
Standard flow on this machine: the signing identity lives in a dedicated keychain that stays locked outside a build (~/Library/Keychains/thalix-developer-id.keychain-db, overridable via TV_SIGNING_KEYCHAIN), whose password lives at ~/.config/thalix-signing/keychain-password (overridable via TV_SIGNING_PASSWORD_FILE) -- mirroring ~/development/whisperclone/scripts/build-tauri.sh's identical convention. That keychain and password file were set up 2026-07-29 by the Thalix Voice tooling: a randomly generated, machine-held password, the file at mode 600, readable only by the operator's own account. macos-sign.mjs unlocks _only_ that one keychain, and only when both files already exist -- it never creates a keychain, never writes a password file, and never touches login.keychain or any other keychain. Run it from your own terminal and it just works, no prompts.
On any other machine (a fresh clone, CI, no dedicated keychain configured), the unlock step is skipped entirely and signing either just works (an already-unlocked keychain, or an interactive session where the OS can show its own approval prompt) or fails with errSecInternalComponent; see Troubleshooting below for that fallback path.
Current state: Thalix does not yet own a Developer ID Application certificate. macOS artifacts ship unsigned (Gatekeeper-quarantined; see Install for the xattr -d com.apple.quarantine workaround) until an operator enrolls in the Apple Developer Program as Thalix and creates one (Xcode > Settings > Accounts > Manage Certificates > + > "Developer ID Application", or developer.apple.com/account/resources/certificates).
A personal Developer ID Application certificate (Developer ID Application: Christopher McKenna (K97MTURBRJ)) is approved for signing Thalix Vault releases in the interim.
Live evidence (this machine, dist/release/tv-darwin-arm64, a real bun build --compile artifact from build-artifacts.mjs above, signed via the standard keychain-unlock flow):
Signing dist/release/tv-darwin-arm64 as "Developer ID Application: Christopher McKenna (K97MTURBRJ)"...
Verified: Authority=Developer ID Application: Christopher McKenna (K97MTURBRJ)
$ codesign -dv --verbose=4 dist/release/tv-darwin-arm64
CodeDirectory v=20500 ... flags=0x10000(runtime) ...
Authority=Developer ID Application: Christopher McKenna (K97MTURBRJ)
Authority=Developer ID Certification Authority
Authority=Apple Root CA
TeamIdentifier=K97MTURBRJ
Runtime Version=15.2.0
flags=0x10000(runtime) confirms the hardened runtime is enabled (required for notarization); the full Authority= chain resolves to Apple's root, confirming a genuine Developer ID signature, not a self-signed or ad-hoc one.
Troubleshooting: errSecInternalComponent
codesign gates a Developer ID private key's use behind a Window Server UI approval prompt. On a machine without the dedicated keychain configured above (or if it's absent for any other reason), a fully non-interactive shell session (CI, SSH, an automation harness) sees errSecInternalComponent instead of that prompt and cannot sign; macos-sign.mjs recognizes this specific failure and reports it directly: the keychain holding "<identity>" is locked -- unlock it in Keychain Access and re-run. As a manual fallback, open Keychain Access, unlock the keychain that holds the certificate yourself, and re-run the command from that same interactive session -- or unlock it from the command line first: security unlock-keychain <keychain path> (prompts for its password interactively).
Notarization
Apple must scan and notarize the signed binary before Gatekeeper trusts it on another machine without a manual override. macos-sign.mjs submits automatically once a notarytool keychain profile exists (default name tv-notary, override with --notary-profile/TV_MACOS_NOTARY_PROFILE); until then it prints this operator action and continues with a loud UNNOTARIZED warning:
xcrun notarytool store-credentials tv-notary \
--apple-id "release@thalixinc.ai" \
--team-id "<TEAMID>" \
--password "<app-specific-password-from-appleid.apple.com>"
The tv-notary profile does not exist on this machine yet -- confirmed via xcrun notarytool history --keychain-profile tv-notary returning "No Keychain password item found for profile: tv-notary". Once submitted and accepted, macos-sign.mjs attempts xcrun stapler staple and expects it to fail: Apple's stapler only staples a ticket to a .app/.pkg/.dmg, never a bare executable, so a notarized-but-unstapled CLI binary is the correct, expected end state -- Gatekeeper verifies it online on first launch instead.
Manifest signing
TV_RELEASE_SIGNING_KEY=~/.thalix-vault/release-signing-key.pem \
node scripts/release/sign-and-manifest.mjs \
--version 0.1.0 --channel stable \
--artifacts-dir dist/release \
--repo thalixinc/thalix-vault-releases
Secret-scans dist/release/ with the exact same checker bun run test:credential-leaks runs (tests/helpers/mvp004c-credential-scan.mjs's scanPaths, evidence written to secret-scan-report.json), refuses to sign over a failed scan, then builds and signs the release manifest reusing packages/contracts/src/release.ts's exact canonicalization -- the same code the CLI's update engine verifies against, so signer and verifier can never silently disagree about what bytes were signed. Writes manifest.json (the full signed manifest), a detached signature.json, a standalone provenance.json (repo, commit, builder), and SHA256SUMS alongside the artifacts.
Publishing
node scripts/release/publish.mjs \
--version 0.1.0 --channel stable \
--artifacts-dir dist/release \
--repo thalixinc/thalix-vault-releases \
--dry-run # drop this flag to actually publish; requires `gh` auth with write access to the repo
Re-verifies every artifact's bytes against the signed manifest immediately before upload (refuses a stale or corrupted build directory), then enforces immutability: a stable tag (v<version>) that already exists on the releases repo is refused outright -- ship a new patch version instead, never overwrite a published release. --dry-run performs every validation step and prints exactly what would be tagged, uploaded, and marked prerelease, without ever calling gh -- fully offline-testable, no GitHub auth required (tests/integration/release-publish-dry-run.test.mjs).
Channels
stable (default) publishes to the immutable vX.Y.Z git tag; GitHub's own releases/latest alias -- the one mutable pointer in this scheme, maintained by GitHub itself, never by this tooling -- always resolves to whichever non-prerelease tag was most recently published. beta publishes to a single channel-beta tag whose assets publish.mjs intentionally _replaces_ on every beta build (deletes and recreates the release under that tag); this is the one documented exception to the otherwise-immutable-release convention, kept fully separate from the immutable vX.Y.Z stable tags and marked --prerelease so it can never become releases/latest.
Tag-triggered CI (.github/workflows/release.yml)
Fires only on a v* tag push (never a branch push or pull request), with a build matrix (macos-14/ubuntu-latest/windows-latest) building and smoke-testing each platform's artifact before a final job signs the manifest and publishes -- so every target is genuinely built and smoke-tested on its own OS before anything ships (Gate 2). Operator action, each one-time: enabling GitHub Actions for this workflow at all (Actions may be organization-disabled for cost control, #30); a TV_MACOS_SIGN_IDENTITY secret if macOS signing should run in CI (optional -- the workflow signs unsigned-with-a-warning without it, same as running macos-sign.mjs locally with no identity); a TV_RELEASE_SIGNING_KEY_PEM secret holding the production private key's PEM contents; and a RELEASES_REPO_TOKEN secret (a fine-grained PAT with contents: write on thalixinc/thalix-vault-releases specifically -- the default GITHUB_TOKEN only has write access to the repository the workflow runs in, not to the separate releases repository).
Cutting the first release (operator action, requires every step above first)
git tag v0.1.0
git push origin v0.1.0
Pushing this tag is the actual trigger. Locally (or if CI is not yet enabled), run Build, macOS signing, Manifest signing, and Publishing above in that order instead.
Rollback
An end user rolls back their own install: tv update --rollback [--json] restores the one retained previous version and re-verifies it starts up correctly (see Command reference). A _published_ release is never edited or overwritten (immutability guard above) -- if a stable release turns out to be bad, cut a new patch version through this same pipeline; only the mutable beta channel supports replacing an existing tag's assets directly.
Known gaps and limitations
- macOS: signs with an individually-held Developer ID Application certificate (approved for interim use, live evidence above) until Thalix owns its own (see macOS code signing and notarization above); notarization is unexercised until the
tv-notarynotarytoolprofile is created. - Windows: always unsigned -- Thalix has no Authenticode code-signing certificate, and none is currently planned. Windows SmartScreen will warn on first run; documented for users in Install.
- Linux/Windows artifacts:
build-artifacts.mjscross-compiles both from any host via Bun's--target=bun-<platform>, but this repository's development machine can only _run_ (smoke-test) thedarwin-arm64artifact directly; Linux/Windows artifacts are smoke-tested only by.github/workflows/release.yml's matrix, on their native OS.