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/, see
Compatibility and upgrades).
pnpm release does not push. It requires a clean tracked tree and matching versions
in packages/ and packages/ (the package
directory is the PACKAGE_DIR constant in scripts/). If no git identity is
configured, the release commit uses nid <release@nid.
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:
pnpm typecheckpnpm test(check-public, site, package suite)pnpm test:bunpnpm build:binfor all five targets, into a temporary directory (neverdist/, which may hold the binary a running daemon was started from)- 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>/ (inside .git, shared by worktrees, never
committed). The publisher (and its --dry-run preflight) reports "no passing pnpm verify for
<sha>" 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/:
winget/(ManifestVersion 1.6.0, portable installer, themanifests/ c/ CatsWithKeyboards/ nid/ <version>/ CatsWithKeyboards .nid{,.installer,.locale .en-US}.yaml nid-windows-x64release URL and SHA256).exe homebrew/(macOS and Linux, arm64 and x64 URLs and SHA256s)Formula/ nid .rb
To regenerate them from any SHA256SUMS (for example one downloaded from a published
release): pnpm release manifests --sums path/.
Nothing is submitted. To publish them, validate with winget validate <dir> and open a PR
against microsoft/, 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 | terminid-windows-x64 | pawmux-windows-x64 |
nid-linux-x64 | terminid-linux-x64 | pawmux-linux-x64 |
nid-linux-arm64 | terminid-linux-arm64 | pawmux-linux-arm64 |
nid-darwin-x64 | terminid-darwin-x64 | pawmux-darwin-x64 |
nid-darwin-arm64 | terminid-darwin-arm64 | pawmux-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/ 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/, and nid's updater and
installers read CatsWithKeyboards/. 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/ checks it). So the order matters, once, for 0.1.0-alpha.13:
- The human renames the GitHub repository
CatsWithKeyboards/toterminid CatsWithKeyboards/(Settings -> General -> Repository name).nid - Point
originat the new name:git remote set-url origin https:/./ github .com/ CatsWithKeyboards/ nid .git - Cut and publish as usual:
pnpm release 0,.1 .0-alpha .13 pnpm verify, then the Actions path below orpnpm release:publish. The release carries theterminid-*andpawmux-*aliases (above), which is whatterminid updateandpawmux updatedownload. - 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/ 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 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/ 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/, overridden by NID;
the old TERMINID and PAWMUX 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/.
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) when the NID_* name is unset.
| Environment variable | Meaning |
|---|---|
NID | Path to a PFX/PKCS#12 certificate and private key. Takes precedence over base64. |
NID | Base64 contents of that certificate; suitable for a CI secret. |
NID | PFX password (defaults to empty). |
NID | signtool, osslsigncode, or jsign. Default: signtool on Windows, osslsigncode elsewhere; jsign when its arguments/JAR are set. A CI variable, not a secret. |
NID | Local override for the selected tool executable. Use the JAR option for Windows jsign batch wrappers. |
NID | RFC 3161 server; defaults to http:/. A CI variable. |
NID_JSIGN_ARGS | JSON string array of jsign keystore arguments; enables cloud/HSM signing without a PFX. A CI secret because it may contain credentials. |
NID_JSIGN_JAR | Local all-in-one jsign JAR, run with java -jar. CI downloads pinned jsign 7.5 when needed. |
AZURE | Short-lived Azure access token, referenced by --storepass env:AZURE in jsign arguments (also wired as a CI secret). |
NID | Developer ID Application certificate + key in PKCS#12 format; preferred over base64. |
NID | Base64 PKCS#12; use this secret in Actions. |
NID | PKCS#12 password (defaults to empty). |
NID_RCODESIGN | Local path to rcodesign; otherwise found on PATH. CI installs apple-codesign 0.29.0. |
NID | Inline unified API key JSON or local path to that JSON. Takes precedence over component settings. Use JSON contents in CI. |
NID, NID_ASC_KEY_ID | App Store Connect team API key identifiers. |
NID_ASC_P8 | PEM text of the API key's .p8 private key, including newlines (CI secret). |
NID | Local .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/ 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 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 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 on any build OS.
- Join the Apple Developer Program and use an account authorized to create certificates.
- 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. - 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-listandnotary-log --api-key-file <json> <submission-id>. - macOS verification: rcodesign's current
verifycommand documents misleading results, so this pipeline uses Apple'scodesign --verify --stricton 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
SHA256SUMSagainst 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 ofpnpm 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 ofreport.mdandreport.json; keep the result with the release notes. It only stops or kills the processes it started.