Skip to content

Latest commit

 

History

History
68 lines (47 loc) · 18.1 KB

File metadata and controls

68 lines (47 loc) · 18.1 KB

Desktop Release & Packaging Runbook

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.

Release surface

  • 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 to master; it never tags or publishes.
    • .github/workflows/finalize-releases.yml ("Finalize Releases") — after a successful master CI, 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 the build-desktop matrix uses blacksmith-6vcpu-macos-26 (arm64/M4; Xcode 26 so actool >= 26 compiles mac.icon into Assets.car), blacksmith-4vcpu-windows-2025 (VS Build Tools, enough for NSIS), and blacksmith-4vcpu-ubuntu-2204 (older glibc for broader AppImage compatibility).

CI topology & merge gates

  • ci.yml triggers on push to master/release/*, pull_request (paths-ignore **.md), and workflow_dispatch. Jobs: typescript (pnpm format:check, pnpm lint, pnpm typecheck, build linkcode-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 use pnpm install --frozen-lockfile, so a drifted pnpm-lock.yaml fails CI at the install step. Markdown-only PRs skip CI (paths-ignore applies to the pull_request trigger only; pushes to master/release/* always run). Reproduce CI locally with --frozen-lockfile + the 4 GB heap.
  • No status check gates merges (GitHub-side settings, read via gh api in 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, not required_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.yml does not validate PRs — its pull_request: block is commented out (cost). Desktop packaging runs only via workflow_call (from release-desktop.yml) or gh workflow run build-desktop.yml --ref <branch>. Packaging regressions surface at release/dispatch, never on a PR.

Cutting a release

  • Never hand-edit a release version or push a release tag. release-please-config.json and .release-please-manifest.json bootstrap Desktop at the last published version; conventional fix / feat / breaking commits update a dedicated release PR, including apps/desktop/package.json and apps/desktop/CHANGELOG.md. Desktop is a root product component so daemon, sidecar, and shared-package changes count, while pure apps/mobile and apps/webview commits are excluded. Merge that PR when the accumulated notes and proposed version are ready to ship.
  • The release PR merge runs normal master CI. Every master SHA has its own CI concurrency group so later pushes cannot cancel or replace that exact validation. Only after All Green succeeds does finalize-releases.yml bind the pending release PR to its tested merge SHA, verify v${version} against apps/desktop/package.json, and ask release-please to create the lightweight tag plus a draft GitHub Release. Ordinary commits may advance master while that CI runs, but the release config, manifest, and automation script must remain byte-identical to the tested merge. force-tag-creation makes release-please push the tag even though the Release stays draft. The App token is load-bearing: a tag created with GITHUB_TOKEN would not start another workflow.
  • release-desktop.yml fires on the tag push (or workflow_dispatch with dry_run, default true). Its build job calls build-desktop.yml with sign: true + secrets: inherit; its release job (environment: release) syncs desktop-* artifacts to R2, reuses the release-please draft, uploads all assets, and then publishes it. A dispatch with dry_run: true builds + 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 to arcboxlabs/homebrew-tap, then that repo's bump-cask action for linkcode with the two DMG sha256s. WinGet: token scoped to the org fork arcboxlabs/winget-pkgs, then vedantmgoyal9/winget-releaser (komac update --submit underneath) opens a version PR on microsoft/winget-pkgs for ArcBox.LinkCode from the release's .exe assets. Both WinGet steps are continue-on-error so 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 is komac new, no manifests are kept in this repo), and an App installation token only covers installed repos, so if the upstream PR comes back resource not accessible by integration the fallback is a classic PAT with public_repo for 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.yml independently fails unless v${version} equals GITHUB_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.yml concurrency is cancel-in-progress: false — never cancel a release mid-flight.

Adding mobile releases

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.

Signing & notarization

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 via notarytool with an App Store Connect API key. electron-builder passes APPLE_API_KEY as a .p8 file path, not content, so a "Materialize App Store Connect API key (.p8)" step decodes APPLE_API_KEY_BASE64 to $RUNNER_TEMP/apple_api_key.p8. Other env: APPLE_API_KEY_ID, APPLE_API_ISSUER, APPLE_TEAM_ID. electron-builder.yml sets mac.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 via azure/login@v2 (federated subject repo:arcboxlabs/linkcode:environment:release, allow-no-subscriptions: true); the caller needs permissions: id-token: write, and there is deliberately no AZURE_* credential env so DefaultAzureCredential falls through to the Azure CLI. Identifiers (release secrets): AZURE_PUBLISHER_NAME (must equal the cert subject CN), AZURE_SIGN_ENDPOINT, AZURE_CODE_SIGNING_ACCOUNT, AZURE_CERTIFICATE_PROFILE. Debug it as OIDC + -c injection, not as a cert file.

Update feed & artifact naming

  • The auto-update feed is an electron-updater generic provider at https://releases.linkcode.ai/desktop (Cloudflare R2 bucket linkcode-releases), hardcoded in electron-builder.yml publish: 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.ts reads it from the baked publish block); auto-update is off in the dev shell. The /desktop prefix 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 contains process.arch and silently falls back to the first entry otherwise, handing some clients the wrong arch. All targets build per-arch [x64, arm64], never universal. mac ships dmg + zip (the zip is required for auto-update — the updater pulls the zip, not the dmg); win ships nsis (oneClick: false, perMachine: false, buildUniversalInstaller: false); linux ships AppImage (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.com with 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.

Packaging inputs (staging & version pins)

  • Single-importer staging (CODE-107). electron-builder never packs apps/desktop in place; apps/desktop/scripts/package-app.mts first pnpm --prod deploy --legacys the app's production closure into a self-contained dir outside the workspace, then points electron-builder's --projectDir at it. This is load-bearing on Windows: pnpm's hoisted layout puts runtime deps (better-sqlite3, js-yaml, …) in the repo-root node_modules, and electron-builder's Windows workspace-root probe (pnpm --workspace-root exec pwd — no pwd on Windows) mis-resolves to apps/desktop, so @electron/rebuild finds no native module and ships a plain-Node-ABI better-sqlite3 — the daemon then dies on require and every client shows "Unable to connect to the daemon" (this shipped from 0.1.0 through 0.2.1). In the staging dir appDir === 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 runs node scripts/package-app.mts <platform> --publish never … in place of a bare electron-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): files globs in electron-builder.yml exclude 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 runtime LINKCODE_PTY_SIDECAR_PATH contract is desktop-owned — see apps/desktop/AGENTS.md.
  • Electron version. electron-builder.yml pins electronVersion: 43.2.0 because electron-builder can't read pnpm's catalog: protocol and downloads per-platform Electron by exact version. Keep it in sync with the electron: ^43.2.0 catalog entry in pnpm-workspace.yaml, or a drift silently packages the wrong Electron. On an Electron bump, also refresh NODE_TARGET / CHROME_TARGET in apps/desktop/vite.shared.ts from the new binary (ELECTRON_RUN_AS_NODE=1 electron -p "process.versions").

Post-pack verification

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.

Traps (symptom → cause → fix)

  • ⨯ … not a file from electron-builder on mac → a GitHub Actions ${{ … || '' }} yields a set-but-empty CSC_LINK, which electron-builder still treats as a cert path and resolves against projectDir → carry the cert under MACOS_CSC_LINK and export CSC_LINK only inside a run step guarded by [ -n "$MACOS_CSC_LINK" ]; force CSC_IDENTITY_AUTO_DISCOVERY=false on unsigned mac builds. (This kept PR builds continuously red.)
  • EMFILE during packaging (historical) → electron-builder's node-module-collector used to exhaust file descriptors walking this whole monorepo's node_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 macOS ulimit bump and the former patches/app-builder-lib@26.15.3.patch (with its patchedDependencies entry) are gone. The .pnpmfile.cjs drizzle-orm↔expo-sqlite peer sever stays — it keeps the expo tree out of the desktop pnpm deploy closure.
  • Hunting for a mac.binaries option to sign the sidecar → it doesn't exist in electron-builder v26; osx-sign discovers Mach-O binaries in extraResources by file header and deep-signs them automatically (the PTY sidecar gets LinkCode's Developer ID + notarization on signed builds). (Team ID observed via codesign -dv on artifacts, not stored in the tree: LinkCode 422ACSY6Y5.)
  • A new MAIN_VITE_* value doesn't reach the bundle / turbo serves a stale build → turbo only threads through env declared in apps/desktop/turbo.json build.env → add it there. MAIN_VITE_SENTRY_DSN is 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 as LINKCODE_SENTRY_DSN and preloads out/daemon/instrument.mjs.
  • Webview / mobile Sentry DSNs — repo secrets SENTRY_DSN_WEBVIEW / SENTRY_DSN_MOBILE map to VITE_SENTRY_DSN (webview turbo build.env) and EXPO_PUBLIC_SENTRY_DSN (Expo inlines EXPO_PUBLIC_* at bundle time). There is no webview/mobile release workflow yet; set the env when building, and for EAS put EXPO_PUBLIC_SENTRY_DSN on the Expo project environment (preview / production) that eas.json selects.
  • Packaged build exits 0 silently, or throws ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING at launch (CODE-101; reproducible only when packaged) → the productName/userData lock-theft + workspace-TS-left-external pair — mechanism and fixes in apps/desktop/AGENTS.md; verify-artifacts plus 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 from linkcode-releases by hand; never add --delete (it breaks in-flight delta updates).