
Putting the Release in CI, Twice
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.
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.jsonfields 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=falsein 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
productinput. The dispatch takes a dropdown —geekbyeorpavleur— so the same workflow ships either brand. The white-label seam runs all the way out to the release button. - Two parallel jobs.
build-maconmacos-latest,build-winonwindows-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 <product> --publishand 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
- 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.
- Automate the work, not necessarily the trigger. The first attempt died because tag-triggered releases auto-spent 10x-billed runner minutes. Manual
workflow_dispatchkeeps the automation and removes the part that was costing money — sometimes the human in the loop is the feature. - 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 (v1.8.3); for the next, the two failure modes of a click-through overlay (v1.8.5); and for the whole arc, the anatomy of shipping software to perfection.