Documentation menu

Building, signing and publishing releases

Run these commands from the repository root with Node.js 22.13+, pnpm and git. Binaries use Bun 1.4.2 (npx fetches it, or set NID_BUN to its executable). All five targets can be built and signed from Windows, Linux or macOS. GitHub Actions is optional; the local publisher uses the same build and signing code.

Build and cut a release#

pnpm install --frozen-lockfile
pnpm --filter nid typecheck
pnpm --filter nid test
pnpm build:bin                          # all five targets, dist/ + SHA256SUMS
pnpm build:bin --target native          # host target + --version smoke check
pnpm build:bin --sign                   # optional signing, then checksums
pnpm release 0.2.0                      # bump version, commit and annotate v0.2.0
pnpm verify                             # full local verify of the release commit (required to publish)

After tagging, add the release's upgrade-compatibility fixture and commit it (node --import tsx scripts/compat-fixtures.ts v0.2.0, see Compatibility and upgrades).

pnpm release does not push. It requires a clean tracked tree and matching versions in packages/nid/package.json and packages/nid/src/version.ts (the package directory is the PACKAGE_DIR constant in scripts/release.ts). If no git identity is configured, the release commit uses nid <release@nid.dev>. Prereleases such as 0.2.0-rc.1 are supported.

Full local verify (required before publishing)#

There is no CI to stand behind a release, so pnpm release:publish refuses to publish a commit that has not passed pnpm verify on this machine. Run it on the clean release commit (after pnpm release <version>):

pnpm verify

It runs, in order, stopping at the first failure:

  1. pnpm typecheck
  2. pnpm test (check-public, site, package suite)
  3. pnpm test:bun
  4. pnpm build:bin for all five targets, into a temporary directory (never dist/, which may hold the binary a running daemon was started from)
  5. a smoke run of the native binary in a throwaway home: --version, daemon run, daemon status, daemon stop

Every step runs with temporary NID_HOME/TERMINID_HOME/PAWMUX_HOME and without NID_BIN/TERMINID_BIN/PAWMUX_BIN. The tree must be clean before and after (a step that leaves files behind fails the run). On success it writes a stamp for that exact commit and tree to <git common dir>/nid-verify/<sha>.json (inside .git, shared by worktrees, never committed). The publisher (and its --dry-run preflight) reports "no passing pnpm verify for &lt;sha&gt;" until a matching stamp with every step passing exists. Any new commit needs a new verify.

Package-manager manifests (winget, Homebrew)#

pnpm release:publish also writes a winget manifest set and a Homebrew formula for the assets it just built to dist/manifests/v<version>/:

  • winget/manifests/c/CatsWithKeyboards/nid/<version>/CatsWithKeyboards.nid{,.installer,.locale.en-US}.yaml (ManifestVersion 1.6.0, portable installer, the nid-windows-x64.exe release URL and SHA256)
  • homebrew/Formula/nid.rb (macOS and Linux, arm64 and x64 URLs and SHA256s)

To regenerate them from any SHA256SUMS (for example one downloaded from a published release): pnpm release manifests --sums path/to/SHA256SUMS [--version x.y.z] [--out dir]. Nothing is submitted. To publish them, validate with winget validate <dir> and open a PR against microsoft/winget-pkgs, and copy the formula into the Homebrew tap; both are manual steps for the human.

Release assets: three name families#

Every release publishes fifteen binaries, SHA256SUMS, install.sh and install.ps1:

nid name (installers, nid update)legacy alias (terminid alpha updaters)legacy alias (pawmux alpha updaters)
nid-windows-x64.exeterminid-windows-x64.exepawmux-windows-x64.exe
nid-linux-x64terminid-linux-x64pawmux-linux-x64
nid-linux-arm64terminid-linux-arm64pawmux-linux-arm64
nid-darwin-x64terminid-darwin-x64pawmux-darwin-x64
nid-darwin-arm64terminid-darwin-arm64pawmux-darwin-arm64

The aliases exist because the updaters already installed look for their own names: terminid update in terminid 0.1.0-alpha.10 to alpha.12 downloads terminid-<os>-<arch>[.exe], and pawmux update in the pawmux alphas (up to 0.1.0-alpha.9) downloads pawmux-<os>-<arch>[.exe]. Each refuses the file unless SHA256SUMS contains exactly one line for that name. The new updater and installers look for nid-*. So scripts/build.ts signs the nid-* binary first, then copies it to the terminid-* and pawmux-* names: all three carry the same bytes and checksum. SHA256SUMS has exactly one line per asset name, every family included. The build checks all of this before it finishes (verifyReleaseDir), and the release workflow runs sha256sum --check --strict again before uploading. Never sign or replace one name of a set by hand.

Keep publishing the terminid-* aliases for at least three releases after the first nid release, and until alpha installs have had a chance to update; the pawmux-* aliases follow the same rule they had since the terminid rename. Removing them strands those installs on their current version; see upgrading from terminid and upgrading from pawmux.

The first nid release: rename the repository first#

The terminid installs out there update from CatsWithKeyboards/terminid, and nid's updater and installers read CatsWithKeyboards/nid. GitHub keeps both working through the redirect it serves for a renamed repository (the API answers 301 for the old path; the updater follows redirects, test/review-13.test.ts checks it). So the order matters, once, for 0.1.0-alpha.13:

  1. The human renames the GitHub repository CatsWithKeyboards/terminid to CatsWithKeyboards/nid (Settings -> General -> Repository name).
  2. Point origin at the new name: git remote set-url origin https://github.com/CatsWithKeyboards/nid.git.
  3. Cut and publish as usual: pnpm release 0.1.0-alpha.13, pnpm verify, then the Actions path below or pnpm release:publish. The release carries the terminid-* and pawmux-* aliases (above), which is what terminid update and pawmux update download.
  4. Point nid.dev (site, /install.sh, /install.ps1) at the new release and set up the terminid.dev and terminid.com redirects to nid.dev.

Never create a new repository named CatsWithKeyboards/terminid afterwards, not even an empty placeholder: it replaces GitHub's redirect, and every terminid alpha would then look for updates in the empty repository and never find nid. nid update refuses releases older than 0.1.0-alpha.13 (FIRST_NID_VERSION in src/update.ts), so a rollback to terminid is the manual one in upgrading from terminid.

GitHub Actions path#

Push the release commit to the appropriate branch, then its tag:

git push origin main
git push origin v0.2.0

.github/workflows/release.yml checks that the tag matches both version files, installs signing tools only when their credentials are present, builds with --sign, verifies SHA256SUMS, then publishes the release nid v<version> with every binary family, SHA256SUMS, install.sh and install.ps1. Set the optional secrets below in the repository's Settings → Secrets and variables → Actions. No signing secret is required for the workflow to succeed. Secrets and variables still stored under their old TERMINID_* or PAWMUX_* names keep working: the workflow falls back to each TERMINID_*, then PAWMUX_* name when the NID_* one is unset. Rename them at your convenience.

Local path when Actions is unavailable#

Install GitHub CLI and authenticate with gh auth login (or GH_TOKEN/GITHUB_TOKEN with repository contents write permission), then:

pnpm release:publish --dry-run
pnpm release:publish --version 0.2.0 --draft
# Or publish immediately:
pnpm release:publish --version 0.2.0

These commands work in PowerShell and Git Bash as well as Unix shells. The version defaults to the package version; specifying one checks it, without bumping it. The real publisher requires a clean checkout, including untracked files, the matching v<version> tag at HEAD, and a passing pnpm verify stamp for HEAD (see Full local verify). origin must point at the destination GitHub repository (CatsWithKeyboards/nid, overridden by NID_RELEASE_REPO=owner/repo; the old TERMINID_RELEASE_REPO and PAWMUX_RELEASE_REPO names still work).

The publisher validates GitHub access and the remote tag, builds all five binaries in a fresh temporary output directory, signs what it can, and hashes the final bytes. It pushes the tag only if missing remotely, then creates the release nid v<version> with every asset family, generated notes and automatic prerelease classification. An existing release receives gh release upload --clobber; its notes, title and draft status stay unchanged. --draft refuses to replace assets of an already public release. Run one publisher at a time: when using this fallback, ensure the Actions release job is disabled or unavailable so the tag push does not also start a competing release build.

--dry-run is offline: no build, signing, gh, push or file writes. It prints a plan even on a dirty/untagged checkout, with preflight warnings. Both version files must still agree. Real publishing removes its temporary output on success or failure; ordinary dist/ builds remain untouched. A failed build/signature/notarization stops publication before a tag push. Rerun the publisher to retry a failed upload.

Signing behavior and settings#

--sign (or NID_SIGN=1) enables optional signing in scripts/build.ts. The local publisher always enables it. Missing platform credentials produce signing skipped: <reason> and leave the binary usable. A missing signing tool, malformed certificate, rejected signature or failed notarization with configured credentials fails the build. No checksum manifest is written after such a failure. Linux binaries are not code-signed by this path.

Temporary decoded credentials and password files are deleted in finally, including on tool failure. Files use mode 0600 on Unix; Windows uses the user's temporary directory ACLs. Use a private build account and keep source credential files outside the checkout. Signing subprocess output and arguments are not printed, even in errors. Signtool requires its PFX password in process arguments, so other processes running as the build user may inspect it; jsign/osslsigncode/rcodesign use password files. Prefer environment/file references for cloud secrets inside jsign arguments.

Each setting below also accepts its old TERMINID_* or PAWMUX_* name (for example TERMINID_WIN_CERT_BASE64) when the NID_* name is unset.

Environment variableMeaning
NID_WIN_CERT_FILEPath to a PFX/PKCS#12 certificate and private key. Takes precedence over base64.
NID_WIN_CERT_BASE64Base64 contents of that certificate; suitable for a CI secret.
NID_WIN_CERT_PASSWORDPFX password (defaults to empty).
NID_WIN_SIGN_TOOLsigntool, osslsigncode, or jsign. Default: signtool on Windows, osslsigncode elsewhere; jsign when its arguments/JAR are set. A CI variable, not a secret.
NID_WIN_SIGN_TOOL_PATHLocal override for the selected tool executable. Use the JAR option for Windows jsign batch wrappers.
NID_WIN_TIMESTAMP_URLRFC 3161 server; defaults to http://timestamp.digicert.com. A CI variable.
NID_JSIGN_ARGSJSON string array of jsign keystore arguments; enables cloud/HSM signing without a PFX. A CI secret because it may contain credentials.
NID_JSIGN_JARLocal all-in-one jsign JAR, run with java -jar. CI downloads pinned jsign 7.5 when needed.
AZURE_ACCESS_TOKENShort-lived Azure access token, referenced by --storepass env:AZURE_ACCESS_TOKEN in jsign arguments (also wired as a CI secret).
NID_MAC_P12_FILEDeveloper ID Application certificate + key in PKCS#12 format; preferred over base64.
NID_MAC_P12_BASE64Base64 PKCS#12; use this secret in Actions.
NID_MAC_P12_PASSWORDPKCS#12 password (defaults to empty).
NID_RCODESIGNLocal path to rcodesign; otherwise found on PATH. CI installs apple-codesign 0.29.0.
NID_ASC_API_KEY_JSONInline unified API key JSON or local path to that JSON. Takes precedence over component settings. Use JSON contents in CI.
NID_ASC_ISSUER_ID, NID_ASC_KEY_IDApp Store Connect team API key identifiers.
NID_ASC_P8PEM text of the API key's .p8 private key, including newlines (CI secret).
NID_ASC_P8_FILELocal .p8 path; used when NID_ASC_P8 is absent.

To base64-encode a certificate for a secret without shell-specific tools:

[Convert]::ToBase64String([IO.File]::ReadAllBytes('C:\secure\developer-id.p12'))

For local use, point at files instead:

$env:NID_MAC_P12_FILE = 'C:\secure\developer-id.p12'
$env:NID_ASC_API_KEY_JSON = 'C:\secure\app-store-key.json'
# Set the password through your secret manager / private environment.
pnpm release:publish --draft

Windows Authenticode#

Install the Windows SDK's SignTool; the script searches PATH and Windows Kits 10 version directories. On Linux install osslsigncode (Ubuntu: sudo apt-get install osslsigncode); macOS can use Homebrew. Alternatively install Java and download the jsign all-in-one JAR. Signtool and osslsigncode verify after signing. jsign 7.x has no verify command, so the script uses signtool or osslsigncode when available, and explicitly logs when verification is unavailable. Signing uses SHA-256 and RFC 3161 timestamps. See Microsoft's SignTool reference.

Obtain an OV or EV code-signing certificate from a public CA and complete its identity checks. Ask for a supported hardware token, HSM or cloud signing service: since June 2023, newly issued publicly trusted code-signing keys must use compliant hardware protection, including OV keys. An ordinary exportable PFX is therefore mostly useful for existing, private or test certificates. Follow the CA's token setup instructions and configure its jsign keystore type. See the CA/Browser Forum requirements.

Azure Artifact Signing (formerly Azure Trusted Signing) offers a managed, lower-cost option without buying a USB token. Create an Azure subscription and Artifact Signing account, complete public-trust identity validation, create a public-trust certificate profile, and grant the signing identity the certificate profile signer role. Availability depends on supported regions and identity types; follow the Azure setup guide.

Use jsign's TRUSTEDSIGNING keystore type, with the service endpoint and account/profile alias. The pinned jsign 7.5 requires an access token; newer jsign documentation also describes service-principal authentication, which is not supported by 7.5. Obtain a fresh token with Azure CLI after az login; no PFX is needed:

$env:NID_JSIGN_JAR = 'C:\tools\jsign-7.5.jar'
$env:AZURE_ACCESS_TOKEN = az account get-access-token --resource https://codesigning.azure.net --query accessToken -o tsv
$env:NID_JSIGN_ARGS = '["--storetype","TRUSTEDSIGNING","--keystore","https://eus.codesigning.azure.net","--alias","my-account/my-profile","--storepass","env:AZURE_ACCESS_TOKEN"]'
$env:NID_WIN_TIMESTAMP_URL = 'http://timestamp.acs.microsoft.com'
pnpm release:publish --draft

See jsign's cloud and HSM examples. For CI, refresh the AZURE_ACCESS_TOKEN secret before signing; expired tokens fail the build. Service-principal or OIDC login and token acquisition can be added in a private preparatory step, setting AZURE_ACCESS_TOKEN for the signing step. Authenticode establishes publisher identity; it does not promise immediate SmartScreen reputation or eliminate warnings for every new download. Test the distributed binary on a clean Windows machine as part of release acceptance.

macOS Developer ID and notarization#

Bun already adds an ad-hoc signature, including when cross-compiling. That signature is not a Developer ID signature and does not imply notarization. rcodesign sign replaces it, enables hardened runtime and timestamping, and adds Bun's documented JavaScript runtime entitlements. Install rcodesign from apple-codesign releases or cargo install apple-codesign --version 0.29.0 --locked on any build OS.

  1. Join the Apple Developer Program and use an account authorized to create certificates.
  2. Create a Developer ID Application certificate in the developer portal. On a Mac, generate its CSR using Keychain Access; import the issued certificate, then export it with its private key as a password-protected .p12.
  3. Without a Mac, generate an RSA key and CSR locally, upload the CSR in Apple's certificate portal, select Developer ID Application and download the issued .cer. Convert the issued certificate and key into PKCS#12 with OpenSSL.
openssl genrsa -out private.pem 2048
rcodesign generate-certificate-signing-request --pem-file private.pem --csr-pem-file request.pem
# After downloading the Apple-issued certificate:
openssl x509 -inform DER -in developer-id.cer -out developer-id.pem
openssl pkcs12 -export -keypbe PBE-SHA1-3DES -certpbe PBE-SHA1-3DES -macalg sha1 -inkey private.pem -in developer-id.pem -out developer-id.p12

Protect the private key and .p12; OpenSSL prompts for the export password. These PKCS#12 container algorithms support rcodesign 0.29's parser; the executable's code signature still uses modern digests and is independent of the container encryption. Detailed certificate procedures: apple-codesign certificate management.

For notarization, create an App Store Connect team API key, with Developer access, under Users and Access → Integrations → App Store Connect API. Record the issuer ID and key ID, and download the .p8 once. This key is separate from the signing certificate. Set the three component variables above, or encode a unified JSON file:

rcodesign encode-app-store-connect-api-key -o app-store-key.json ISSUER_ID KEY_ID AuthKey_KEY_ID.p8

See App Store Connect key setup. When all key components are available, the build submits a temporary ZIP containing the signed binary and waits for Apple's result. Bare Mach-O executables cannot carry a stapled ticket; the released binary stays byte-for-byte as signed, and Gatekeeper can retrieve its ticket online. Without an API key, Developer ID signing still runs and notarization logs a skip. See notarization commands.

Troubleshooting and release acceptance#

  • Signing failed, output suppressed: verify the certificate/password and tool installation in a private terminal. The build intentionally avoids printing tool output, which can contain credentials. For Apple submission details use rcodesign notary-list and notary-log --api-key-file <json> <submission-id>.
  • macOS verification: rcodesign's current verify command documents misleading results, so this pipeline uses Apple's codesign --verify --strict on macOS; other hosts rely on the notarization result when credentials are supplied. Before a production release, smoke-test both macOS architectures on actual Macs and validate with Apple's tools. Cross-compilation alone does not verify execution.
  • Unsigned release: no certificates are required for development builds or the fallback publisher. To require a signed production release, check CI secrets and review the signing/notarization success messages before making a draft public.
  • Checksums: installers verify SHA256SUMS against the downloaded binary. Re-signing changes the hash; rebuild checksums and upload the full matching asset set together, both name families included. Never manually sign a binary after generating its manifest.
  • Testing scope: automated tests mock signing tools and GitHub; they cover argument construction, skip/failure handling, cleanup, ZIP layout, preflight and publication ordering. They do not replace a first credentialed signing/notarization run and OS smoke test. Those require the release owner's certificates and accounts.
  • Soak before a beta or stable release: pnpm soak --minutes 30 (not part of pnpm test) runs a mock-agent team (lead + 3 workers) in throwaway homes against one daemon that is shut down and hard-killed in turn every --restart-minutes (default 10), while workers claim, finish and abandon tasks, message the lead and crash. It samples daemon RSS, handle/fd count, child processes, db size and the board every --sample-seconds (30), then fails on leaked processes, memory growth over 60 MB/h or handle growth over 400/h within a daemon run (median of the last third minus the first, past a 180 s warm-up, since RSS climbs for about two minutes after each start and then levels off), a db over 256 MB, or a task claimed longer than --stuck-minutes (5) or still claimed after a 60 s settle. It prints the path of report.md and report.json; keep the result with the release notes. It only stops or kills the processes it started.