How to cut, sign, notarize, and publish the Electron desktop app, plus the packaging trap catalog. Desktop-scoped packaging invariants (asar layout, preload, the extraResources runtime contract, electron-builder.yml structure) live in apps/desktop/AGENTS.md; local dev/test/E2E in docs/DEVELOPMENT.md. Only the desktop app has a delivery pipeline — release-please is manifest-based so mobile can become a separate release component later, but no webview, mobile, or daemon publish workflow exists yet.
- Five GitHub Actions workflows, one script module, and one composite action own the release path:
.github/workflows/ci.yml("CI") — runs on every PR..github/workflows/release-please.yml("Release Please") — maintains release PRs after pushes tomaster; it never tags or publishes..github/workflows/finalize-releases.yml("Finalize Releases") — after a successfulmasterCI, turns a merged release PR into a draft Release and pushes its validated tag..github/workflows/build-desktop.yml("Build Desktop") — reusable packaging workflow; not PR-triggered..github/workflows/release-desktop.yml("Release Desktop") — tag-triggered publish..github/scripts/release-automation.cjs— tested Octokit policy for candidate resolution, recovery, and Release preflight checks..github/actions/build-sidecar— composite action that builds the PTY sidecar per arch.
- All jobs run on Blacksmith runners, not stock GitHub:
blacksmith-2vcpu-ubuntu-2404(CI + the publish job), and thebuild-desktopmatrix usesblacksmith-6vcpu-macos-26(arm64/M4; Xcode 26 soactool >= 26compilesmac.iconintoAssets.car),blacksmith-4vcpu-windows-2025(VS Build Tools, enough for NSIS), andblacksmith-4vcpu-ubuntu-2204(older glibc for broader AppImage compatibility).
ci.ymltriggers on push tomaster/release/*,pull_request(paths-ignore**.md), andworkflow_dispatch. Jobs: typescript (pnpm format:check,pnpm lint,pnpm typecheck, buildlinkcode-pty, required-sidecar Vitest, then compiled-daemon process acceptance), desktop (unpackaged Electron entry, window-state persistence, plus unsigned packaged devshell), webview (production bundle plus Chromium browser smoke), mobile (Android and iOS production Expo exports), rust (cargo fmt --check,cargo clippy --all-targets --locked -- -D warnings,cargo test --locked), and All Green (needs: [typescript, desktop, webview, mobile, rust],if: always(), fails unless all five required jobs pass).NODE_OPTIONS=--max-old-space-size=4096; installs usepnpm install --frozen-lockfile, so a driftedpnpm-lock.yamlfails CI at the install step. Markdown-only PRs skip CI (paths-ignoreapplies to thepull_requesttrigger only; pushes tomaster/release/*always run). Reproduce CI locally with--frozen-lockfile+ the 4 GB heap.- No status check gates merges (GitHub-side settings, read via
gh apiin 2026-07 — re-check there; none of this lives in the tree). There is no FOSSA/Snyk/Codecov/Sonar/Renovate/Dependabot config in the tree; the default-branch ruleset gates on code-owner review + review-thread resolution + Copilot review, notrequired_status_checks. Code-review apps (greptile, pullfrog) are advisory and block nothing. "All Green" is the aggregate check intended for branch protection if one is ever required — require it rather than the individual jobs (brittle). build-desktop.ymldoes not validate PRs — itspull_request:block is commented out (cost). Desktop packaging runs only viaworkflow_call(fromrelease-desktop.yml) orgh workflow run build-desktop.yml --ref <branch>. Packaging regressions surface at release/dispatch, never on a PR.
- Never hand-edit a release version or push a release tag.
release-please-config.jsonand.release-please-manifest.jsonbootstrap Desktop at the last published version; conventionalfix/feat/ breaking commits update a dedicated release PR, includingapps/desktop/package.jsonandapps/desktop/CHANGELOG.md. Desktop is a root product component so daemon, sidecar, and shared-package changes count, while pureapps/mobileandapps/webviewcommits are excluded. Merge that PR when the accumulated notes and proposed version are ready to ship. - The release PR merge runs normal
masterCI. EverymasterSHA has its own CI concurrency group so later pushes cannot cancel or replace that exact validation. Only after All Green succeeds doesfinalize-releases.ymlbind the pending release PR to its tested merge SHA, verifyv${version}againstapps/desktop/package.json, and ask release-please to create the lightweight tag plus a draft GitHub Release. Ordinary commits may advancemasterwhile that CI runs, but the release config, manifest, and automation script must remain byte-identical to the tested merge.force-tag-creationmakes release-please push the tag even though the Release stays draft. The App token is load-bearing: a tag created withGITHUB_TOKENwould not start another workflow. release-desktop.ymlfires on the tag push (orworkflow_dispatchwithdry_run, defaulttrue). Its build job callsbuild-desktop.ymlwithsign: true+secrets: inherit; its release job (environment: release) syncsdesktop-*artifacts to R2, reuses the release-please draft, uploads all assets, and then publishes it. A dispatch withdry_run: truebuilds + signs but publishes nothing.- Package-manager bumps close the release job, on stable
v*.*.*tags only (no-prerelease suffix), each with a short-lived token minted from the org GitHub App (BOT_APP_ID/BOT_APP_PRIVATE_KEY) — absent secrets make them self-skip. Homebrew: token scoped toarcboxlabs/homebrew-tap, then that repo'sbump-caskaction forlinkcodewith the two DMG sha256s. WinGet: token scoped to the org forkarcboxlabs/winget-pkgs, thenvedantmgoyal9/winget-releaser(komac update --submitunderneath) opens a version PR onmicrosoft/winget-pkgsforArcBox.LinkCodefrom the release's.exeassets. Both WinGet steps arecontinue-on-errorso packaging never fails a release — check the step status in the release job, not the release itself. Two constraints: the package must already exist upstream (the action only updates; a from-scratch submission iskomac new, no manifests are kept in this repo), and an App installation token only covers installed repos, so if the upstream PR comes backresource not accessible by integrationthe fallback is a classic PAT withpublic_repofor a bot user that can push to the fork. - electron-builder derives artifact names and the updater feed from package.json, not the tag, so
build-desktop.ymlindependently fails unlessv${version}equalsGITHUB_REF_NAME. The GitHub Release is for human downloads only — the updater reads the R2 feed. Tags containing-(e.g.v1.2.3-beta.1) publish as prerelease.release-desktop.ymlconcurrency iscancel-in-progress: false— never cancel a release mid-flight.
Desktop deliberately remains the only active package in release-please-config.json until the EAS build/submit workflow and store credentials exist. Activating mobile is then a separate atomic change: add apps/mobile to the manifest at the current expo.version, configure release-please's expo strategy with component tags (mobile-v*.*.*), and teach finalize-releases.yml to start only the matching Mobile workflow. Keep v*.*.* unprefixed tags reserved for Desktop so existing updater and GitHub download links remain stable.
The Expo strategy updates both apps/mobile/package.json and apps/mobile/app.json: align their starting versions when activating it. Marketing SemVer belongs in expo.version; iOS buildNumber and Android versionCode are monotonically increasing store build identifiers and should be remote EAS-owned rather than release-please-owned. Native store builds and expo-updates production-channel OTA publishes remain separate delivery actions.
All signing and R2 secrets live in the repo's GitHub release Environment (scoped to that environment, not org-wide). build-desktop.yml sets environment: ${{ inputs.sign && 'release' || '' }}, so unsigned PR/dispatch builds see no secrets and skip signing instead of failing; release-desktop.yml's publish job also runs environment: release or its R2 creds resolve empty.
- macOS — team Developer ID cert (
MACOS_CSC_LINK/MACOS_CSC_KEY_PASSWORD); notarization vianotarytoolwith an App Store Connect API key. electron-builder passesAPPLE_API_KEYas a.p8file path, not content, so a "Materialize App Store Connect API key (.p8)" step decodesAPPLE_API_KEY_BASE64to$RUNNER_TEMP/apple_api_key.p8. Other env:APPLE_API_KEY_ID,APPLE_API_ISSUER,APPLE_TEAM_ID.electron-builder.ymlsetsmac.notarize: true,hardenedRuntime: true,entitlements: build-resources/entitlements.mac.plist. - Windows — Azure Trusted Signing, with no client secret and no declaration in
electron-builder.yml: injected at package time via-c.win.azureSignOptions.*on signed Windows builds only. Auth is OIDC viaazure/login@v2(federated subjectrepo:arcboxlabs/linkcode:environment:release,allow-no-subscriptions: true); the caller needspermissions: id-token: write, and there is deliberately noAZURE_*credential env soDefaultAzureCredentialfalls through to the Azure CLI. Identifiers (releasesecrets):AZURE_PUBLISHER_NAME(must equal the cert subject CN),AZURE_SIGN_ENDPOINT,AZURE_CODE_SIGNING_ACCOUNT,AZURE_CERTIFICATE_PROFILE. Debug it as OIDC +-cinjection, not as a cert file.
- The auto-update feed is an electron-updater
genericprovider athttps://releases.linkcode.ai/desktop(Cloudflare R2 bucketlinkcode-releases), hardcoded inelectron-builder.ymlpublish:and baked into every shipped app — never change it (changing it strands installed clients). The app carries the URL only in packaging config, never in code (apps/desktop/src/main/updater.tsreads it from the baked publish block); auto-update is off in the dev shell. The/desktopprefix namespaces the bucket for future artifact families. artifactName: ${productName}-${version}-${arch}.${ext}— the${arch}suffix is load-bearing: electron-updater picks the feed entry whose filename containsprocess.archand silently falls back to the first entry otherwise, handing some clients the wrong arch. All targets build per-arch[x64, arm64], never universal. mac shipsdmg+zip(thezipis required for auto-update — the updater pulls the zip, not the dmg); win shipsnsis(oneClick: false,perMachine: false,buildUniversalInstaller: false); linux shipsAppImage(the only self-updating Linux format) +deb,executableName: linkcode. rpm/snap deliberately off.- R2 publish (
release-desktop.yml):aws s3 sync artifacts/ s3://linkcode-releases/desktop/ --endpoint-url https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.comwith no--delete(prior versions stay for delta updates). Mandatory env:AWS_REGION=auto(R2 ignores it but the CLI requires one),AWS_REQUEST_CHECKSUM_CALCULATION=WHEN_REQUIRED+AWS_RESPONSE_CHECKSUM_VALIDATION=WHEN_REQUIRED(R2 doesn't implement the CRC32 upload checksums recent aws-cli sends). Creds:R2_ACCESS_KEY_ID,R2_SECRET_ACCESS_KEY,R2_ACCOUNT_ID.
- Single-importer staging (CODE-107). electron-builder never packs
apps/desktopin place;apps/desktop/scripts/package-app.mtsfirstpnpm --prod deploy --legacys the app's production closure into a self-contained dir outside the workspace, then points electron-builder's--projectDirat it. This is load-bearing on Windows: pnpm's hoisted layout puts runtime deps (better-sqlite3, js-yaml, …) in the repo-rootnode_modules, and electron-builder's Windows workspace-root probe (pnpm --workspace-root exec pwd— nopwdon Windows) mis-resolves toapps/desktop, so@electron/rebuildfinds no native module and ships a plain-Node-ABIbetter-sqlite3— the daemon then dies onrequireand every client shows "Unable to connect to the daemon" (this shipped from 0.1.0 through 0.2.1). In the staging dirappDir === projectDir === workspaceRoot, so the rebuild walker finds better-sqlite3 in step one on every platform and the module collector sees exactly one importer (falling through to the filesystem-traversal collector). CI runsnode scripts/package-app.mts <platform> --publish never …in place of a bareelectron-builder. - Cross-arch staging. Each platform build stages both arches' sidecar trees before packaging so
extraResources: sidecar/${arch}resolves: "Build PTY sidecar (both arches)" (.github/actions/build-sidecar). Agent CLI binaries do not ship (CODE-114):filesglobs inelectron-builder.ymlexclude the SDK platform packages from the asar, and the daemon spawns a detected user install or a managed download from its own asset store (@linkcode/assets, CODE-111). The runtimeLINKCODE_PTY_SIDECAR_PATHcontract is desktop-owned — seeapps/desktop/AGENTS.md. - Electron version.
electron-builder.ymlpinselectronVersion: 43.2.0because electron-builder can't read pnpm'scatalog:protocol and downloads per-platform Electron by exact version. Keep it in sync with theelectron: ^43.2.0catalog entry inpnpm-workspace.yaml, or a drift silently packages the wrong Electron. On an Electron bump, also refreshNODE_TARGET/CHROME_TARGETinapps/desktop/vite.shared.tsfrom the new binary (ELECTRON_RUN_AS_NODE=1 electron -p "process.versions").
node apps/desktop/scripts/verify-artifacts.mts <mac|win|linux> runs after every packaging (in CI, and locally from apps/desktop) and fails on packaging regressions. It asserts: the per-arch artifact set is complete and every artifact stays under the 200 MB ceiling (a reintroduced agent binary adds ~66 MB compressed and trips it — CODE-114); every name carries its arch marker; every feed manifest (latest-mac.yml / latest.yml / latest-linux.yml / latest-linux-arm64.yml) points at an on-disk file whose sha512 matches; each unpacked app.asar carries the bundled host runtime (out/daemon/index.mjs + out/drizzle/meta/_journal.json) plus the PTY sidecar (linkcode-pty[.exe]) in Resources; the smartUnpacked better-sqlite3 binding is present and its Mach-O/PE/ELF header targets the app's arch (the tripwire for the CODE-107 Windows rebuild miss — a right-arch/wrong-ABI binding is not header-detectable and is covered by the boot E2E instead); and no agent CLI binaries ship — no Resources/agent-bin, no SDK platform packages in the asar or app.asar.unpacked. It normalizes inner asar paths with inner.replaceAll('/', sep) because @electron/asar splits internal paths by path.sep, so a forward-slash path never matches on Windows.
⨯ … not a filefrom electron-builder on mac → a GitHub Actions${{ … || '' }}yields a set-but-emptyCSC_LINK, which electron-builder still treats as a cert path and resolves againstprojectDir→ carry the cert underMACOS_CSC_LINKandexport CSC_LINKonly inside a run step guarded by[ -n "$MACOS_CSC_LINK" ]; forceCSC_IDENTITY_AUTO_DISCOVERY=falseon unsigned mac builds. (This kept PR builds continuously red.)EMFILEduring packaging (historical) → electron-builder's node-module-collector used to exhaust file descriptors walking this whole monorepo'snode_modules, and pnpm's cross-importer dedup could silently drop a uniquely-placed transitive dep out of the asar (js-yaml → boot crash). Both were multi-importer symptoms; the single-importer staging flow above eliminated them, so the macOSulimitbump and the formerpatches/app-builder-lib@26.15.3.patch(with itspatchedDependenciesentry) are gone. The.pnpmfile.cjsdrizzle-orm↔expo-sqlite peer sever stays — it keeps the expo tree out of the desktoppnpm deployclosure.- Hunting for a
mac.binariesoption to sign the sidecar → it doesn't exist in electron-builder v26;osx-signdiscovers Mach-O binaries inextraResourcesby file header and deep-signs them automatically (the PTY sidecar gets LinkCode's Developer ID + notarization on signed builds). (Team ID observed viacodesign -dvon artifacts, not stored in the tree: LinkCode422ACSY6Y5.) - A new
MAIN_VITE_*value doesn't reach the bundle / turbo serves a stale build → turbo only threads through env declared inapps/desktop/turbo.jsonbuild.env→ add it there.MAIN_VITE_SENTRY_DSNis inlined into the main bundle only on signed builds (inputs.sign && secrets.SENTRY_DSN_DESKTOP || ''; a DSN is a publishable id, not a secret). The packaged daemon supervisor forwards that same value asLINKCODE_SENTRY_DSNand preloadsout/daemon/instrument.mjs. - Webview / mobile Sentry DSNs — repo secrets
SENTRY_DSN_WEBVIEW/SENTRY_DSN_MOBILEmap toVITE_SENTRY_DSN(webview turbobuild.env) andEXPO_PUBLIC_SENTRY_DSN(Expo inlinesEXPO_PUBLIC_*at bundle time). There is no webview/mobile release workflow yet; set the env when building, and for EAS putEXPO_PUBLIC_SENTRY_DSNon the Expo project environment (preview/production) thateas.jsonselects. - Packaged build exits 0 silently, or throws
ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPINGat launch (CODE-101; reproducible only when packaged) → the productName/userData lock-theft + workspace-TS-left-external pair — mechanism and fixes inapps/desktop/AGENTS.md;verify-artifactsplus actually launching the packaged app are the guards. - A yanked version keeps auto-updating / the R2 bucket grows unbounded → the R2 sync runs without
--delete, so prior artifacts persist (delta updates need them) → prune stale objects fromlinkcode-releasesby hand; never add--delete(it breaks in-flight delta updates).