--- title: 'Putting the Release in CI, Twice' excerpt: 'GeekBye v1.8.4 moved release builds to CI for macOS and Windows. What the changelog does not say is that we tried this once before, deleted it a month later over runner costs, and only made it stick the second time — because by then the release script already worked by hand.' date: '2026-07-17' author: 'Steven' authorAvatar: '/images/blog/authors/steven.jpg' coverImage: '/images/blog/covers/putting-the-release-in-ci-twice.svg' tags: ['Engineering', 'CI/CD', 'Desktop', 'GeekBye Releases'] keywords: - 'electron release github actions macos windows' - 'code signing notarization ci electron-builder' - 'macos github actions runner cost 10x minutes' - 'workflow_dispatch manual release electron app' - 'reduce llm request payload faster time to first token' - 'thin ci wrapper over release script' lastModified: '2026-07-17' tldr: "GeekBye v1.8.4 added a 66-line GitHub Actions workflow that builds, signs, notarizes, and publishes the app for macOS and Windows in two parallel jobs. The interesting part is the history: the team had already put releases in CI once, back in October 2025 — a tag-triggered, macOS-only workflow with hand-written keychain-import steps — and deleted it a month later because macOS runners bill at a 10x multiplier and every tag was auto-spending them. For three and a half months releases went back to a developer's Mac, and that is where all the hard packaging problems actually got solved: white-label asset restore, dual-arch Windows installers, the unsigned-Windows env workaround, notarization auto-detection. v1.8.4 is the second attempt, and it stuck precisely because it is thin: it delegates everything to the already-hardened `scripts/release.js`, has zero bespoke signing logic in the YAML, and runs only on a manual `workflow_dispatch` so a human gates every paid minute. The workflow then needed no hotfixes for four months. The release also shipped a small latency win: the Assist Me feature trimmed its request payload (transcript history 30→15 entries, context files capped at 4,000 chars)." keyTakeaways: - "CI should be a thin orchestrator over a script you can already run by hand: v1.8.4's 66-line workflow has no signing logic of its own — it calls `node scripts/release.js` and lets the script do the work that three months of local releases had already debugged" - 'The first CI-release attempt (Oct 2025) died over money, not correctness: macOS runners bill at a 10x multiplier, and a tag-triggered workflow auto-spent them on every release — the fix was a manual `workflow_dispatch` so humans decide when to burn runner minutes' - 'Move packaging off your Mac only after the Mac stops surprising you: the white-label package.json restore, dual-arch Windows installers, and the unsigned-Windows env workaround were all solved locally first, which is why the CI workflow needed zero signing hotfixes for four months' - 'macOS and Windows want opposite defaults: the Mac job builds signed + notarized under a hardened runtime from repository secrets; the Windows job builds unsigned NSIS installers for x64 and arm64 — one workflow, two very different jobs' - 'A committed-but-recompiled native binary hides a coupling: the Swift binaries are checked into git yet rebuilt on the runner, and a Swift-version check hard-fails the release if the runner image drifts from the pinned version — the one place this thin pipeline can still break' --- The v1.8.4 changelog has a line that sounds like pure housekeeping: "Release builds now come from CI for both macOS and Windows." It reads like the kind of thing that happens once, cleanly, and is never thought about again. It was not. The team had already put releases in CI once — and torn it out. This release is the second attempt, and the reason it stuck is the actual engineering lesson, so let's tell it in order. ## The first attempt, and why it died Back in October 2025 there was a `release.yml`. It was triggered on tag push — shove a `v*.*.*` tag and a macOS runner would spin up, build, and publish. It did its own signing setup by hand: a step that decoded the signing certificate, created and unlocked a temporary keychain, imported the cert so `codesign` could find it, and a matching cleanup step at the end. It worked. It was also macOS-only — Windows releases weren't in CI at all. On November 4th, it was deleted. The commit message is unusually candid about why: _"remove GitHub Actions release workflow to conserve minutes / macOS runners cost 10x multiplier (100 billed minutes per 10-minute release). Releases will be done locally."_ That's the whole story of the first attempt in one sentence. GitHub bills macOS runner time at ten times the rate of Linux, and a tag-triggered release means every single tag — including the throwaway ones, the re-tags, the "oops, forgot the version bump" ones — quietly spends a hundred billed minutes. Automation you don't control is automation that spends money while you sleep. So releases went back to a developer's Mac for the next three and a half months. ## Where the hard problems actually got solved Here's the part that makes the second attempt work, and it happened entirely _outside_ CI. During those three and a half months of local releases, `scripts/release.js` — the plain Node script a developer runs by hand — absorbed every packaging problem the app had, one fix at a time: - **White-label restore.** This is a white-label codebase that builds both GeekBye and Pavleur from one source, which means a release swaps `package.json` fields and icon assets for the target product. Two fixes taught the script to put everything back afterward, so building Pavleur didn't leave your git tree dirty with Pavleur's identity. - **Dual-arch Windows.** A fix to build _both_ x64 and arm64 Windows installers instead of one. - **The unsigned-Windows workaround.** Newer electron-builder ignored the config flag meant to disable Windows signing, so the script learned to force an unsigned build by setting `CSC_IDENTITY_AUTO_DISCOVERY=false` in the environment instead — a wrinkle you only find by hitting it. - **Notarization auto-detection.** The script learned to look at its environment and decide: signing credentials present? Sign. Apple notarization credentials also present? Notarize. Neither? Build unsigned. No flags to remember; the presence of the secrets _is_ the configuration. None of that is glamorous. All of it is the kind of thing that, if it surprises you inside a CI runner, costs you an hour of push-wait-fail-read-logs per attempt — at 10x billing. Solved on a Mac you're sitting in front of, each one costs a minute. ## The second attempt: sixty-six lines When CI came back in v1.8.4, the workflow was 66 lines, and its defining quality is how little it does. The commit describes it plainly: _"Manual workflow_dispatch trigger that builds both platforms in parallel, reusing existing release.js script. macOS builds are signed and notarized, Windows builds are unsigned."_ Every design decision in it is a scar from the first attempt: - **`workflow_dispatch`, not tag-triggered.** You start a release by clicking "Run workflow." A human gates every paid macOS minute. The cost problem that killed the first version is solved by simply not automating the trigger — the one place automation was actively harmful. - **A `product` input.** The dispatch takes a dropdown — `geekbye` or `pavleur` — so the same workflow ships either brand. The white-label seam runs all the way out to the release button. - **Two parallel jobs.** `build-mac` on `macos-latest`, `build-win` on `windows-latest`, running at the same time. Windows in CI is genuinely new here; the October workflow never built it. - **No signing logic in the YAML.** This is the whole point. There's no keychain juggling, no cert import step, no cleanup. The Mac job runs `node scripts/release.js --publish` and the Windows job runs the same with `--no-sign`. Everything the first workflow did by hand in YAML now lives in the script that already works. The workflow is an orchestrator, not an implementation. The two platforms want opposite things, and the jobs reflect it honestly. macOS builds under a hardened runtime, signed and notarized through electron-builder's built-in path (`@electron/notarize`), with the credentials supplied from repository secrets and auto-detected by the script. Windows builds unsigned NSIS installers for x64 and arm64. One workflow, two jobs, two entirely different notions of "done." ## The proof is in what didn't happen Here's how you know the sequencing was right: after the two same-day tweaks that named the runs and fixed a token secret's name, `release.yml` was not touched again for **four months**. No signing-failure hotfix. No notarization-rejection scramble. No "the native binary is missing on the runner" panic. For a code-signing-and-notarization pipeline — the genre of CI most infamous for thrashing — four months of silence is close to unheard of. It was quiet because the noise had already happened somewhere cheaper. The fights that usually play out in a CI logs tab, at 10x billing, one force-push at a time, had played out on developers' Macs during the local era. CI didn't have to be where the packaging got debugged, because the packaging was already debugged. That's the thesis in one line: **move the build off your Mac only after your Mac has stopped surprising you.** ## The one coupling that can still bite It's not entirely without a soft spot, and it's worth naming because it's subtle. The Swift binaries the macOS app depends on — OCR, screen capture, the transcribers — are committed to git. But the release doesn't ship the committed copies; the build recompiles them on the runner. And a `check-swift-version.js` gate hard-fails the whole release if the compiler isn't the pinned version. At this release that pin was Swift 6.2.x, and nothing in the workflow installs it — the job simply trusts that `macos-latest`'s default Swift matches. The day GitHub bumps its runner image past the pin, the release stops, not because anything is wrong with the app, but because the pipeline is quietly coupled to a machine image it doesn't control. It's the one implementation detail this thin workflow didn't push down into something it owns. ## The other half of the release: smaller payloads The release carried one unrelated feature worth a mention, because it's a clean example of a latency fix by subtraction. The Assist Me action was sending the backend more context than it needed on every request. One commit trimmed two things: the transcript history sent along dropped from the last 30 entries to the last 15, and the user's profile context files — injected into the system prompt on _every_ call — got hard-capped at 4,000 characters with a truncation marker. Less to serialize, less to upload, less for the model to read before it starts answering, which is the metric the commit optimizes for: time-to-first-token. In the interest of honesty: the commit claims the speedup but doesn't measure it — there's no before/after number in the history, so treat it as a well-reasoned trim rather than a benchmarked win. ## Three things this release taught us 1. **CI is a thin wrapper over a script that already works.** The 66-line workflow has no signing logic of its own; it calls a script that three months of local releases had already debugged. Put the mechanism in a thing you can run by hand, and let CI just decide _when_ to run it. 2. **Automate the work, not necessarily the trigger.** The first attempt died because tag-triggered releases auto-spent 10x-billed runner minutes. Manual `workflow_dispatch` keeps the automation and removes the part that was costing money — sometimes the human in the loop is the feature. 3. **Debug where iteration is cheap.** Every packaging fight resolved on a developer's Mac is a fight that never happens in a CI logs tab at ten times the price. Move off the Mac last, not first. For the previous chapter in the v1 story, [shipping thirty languages without a safety net](/blog/shipping-thirty-languages-without-a-safety-net) (v1.8.3); for the next, [the two failure modes of a click-through overlay](/blog/the-two-failure-modes-of-a-click-through-overlay) (v1.8.5); and for the whole arc, [the anatomy of shipping software to perfection](/blog/anatomy-of-perfection).