Part of #6.
Everything that can only work on a Mac is discovered broken at release time, on a Mac, by the one person who can cut a release. The bundle build, the loopback API, launchd, the Keychain and AppleScript all first execute when a maintainer runs uplink:release, so a regression in any of them surfaces after the tag exists rather than on the pull request that caused it.
What is there today
- All three CI jobs pin the runner to Linux: lint at
.github/workflows/ci.yml#L23, typecheck at.github/workflows/ci.yml#L58, test at.github/workflows/ci.yml#L94. - The uplink unit tests do run there, through
bun run testat.github/workflows/ci.yml#L130, which is./buddy test(package.json#L65) over the suite root set atbunfig.toml#L31. They cover the handle filter (tests/unit/uplink/messages.test.ts#L3), the chat.db reader (tests/unit/uplink/messages.test.ts#L5), the typedstream decoder (tests/unit/uplink/messages.test.ts#L6), the run queue (tests/unit/uplink/uplink.test.ts#L135) and the engine against a stubclaudethe test writes itself (tests/unit/uplink/engine.test.ts#L18). - Nothing anywhere exercises
app/Uplink/desktop-agent.ts: not the single instance lock atapp/Uplink/desktop-agent.ts#L52, not the LaunchAgent write atapp/Uplink/desktop-agent.ts#L105, not the loopback server atapp/Uplink/desktop-agent.ts#L277, not the status route atapp/Uplink/desktop-agent.ts#L291. - Nothing exercises
app/Uplink/service.ts, whose whole job is shelling out tocodesign(app/Uplink/service.ts#L120) andlaunchctl(app/Uplink/service.ts#L136), norrunChecksatapp/Uplink/doctor.ts#L84, nor either launcher (app/Desktop/launcher.ts#L46,app/Uplink/launcher.ts#L39). app/Uplink/sender.tsis imported by the suite as a type only (tests/unit/uplink/fixtures.ts#L2), soAppleScriptSenderatapp/Uplink/sender.ts#L43and itsosascriptcall atapp/Uplink/sender.ts#L46never run.- The bundle build only ever runs by hand, from
uplink:release:app/Commands/Uplink.ts#L208runsbuddy build:desktop, andapp/Commands/Uplink.ts#L209runsbuddy build:dmgwith the signing identity and notary profile.
Proposal
- Add a fourth job to
.github/workflows/ci.ymlonmacos-latest, with the same checkout, pantry andbun install --frozen-lockfilesteps as the existingtestjob. - Run the uplink unit tests in that job, so the SQLite, typedstream and engine code is checked against Apple's SQLite and the macOS runner's Bun rather than only Linux.
- Run
buddy build:desktopas an unsigned smoke check. It needs the Craft runtime declared atdeps.yml#L9, so the pantry step has to install rather than passinstall: 'false'the way the existing jobs do (.github/workflows/ci.yml#L35), orCRAFT_BINhas to be set the wayapp/Commands/Uplink.ts#L208sets it. Assert the result:Contents/MacOS/Uplinkexists and is Mach-O, withcraft-runtimebeside it, which is whatcraftRuntime()atapp/Desktop/launcher.ts#L36looks for. - Boot the agent and assert the loopback API answers. Add
tests/unit/uplink/desktop-agent.test.tsthat callsstartDesktopAgent(app/Uplink/desktop-agent.ts#L151), fetches/api/status(app/Uplink/desktop-agent.ts#L291), then callsagent.stop(). The server binds port 0 (app/Uplink/desktop-agent.ts#L279), so the port has to come from the returned agent rather than a fixed number.
Requirements
- No signing secrets. The job has to run on a pull request from a fork with no certificate and no notarytool profile, so an unsigned
build:desktopmust be enough to fail on a broken bundle. Signing and notarization stay inbuild:dmgatapp/Commands/Uplink.ts#L209and stay out of CI. - Point HOME at a scratch directory.
DATA_DIRcomes fromhomedir()(app/Uplink/settings.ts#L16), so doesLAUNCH_AGENT(app/Uplink/desktop-agent.ts#L40), andstartDesktopAgentcreates the data directory on boot (app/Uplink/desktop-agent.ts#L152). Run the boot step withHOMEset to a temporary directory so the job cannot leave state in the runner's home or collide with a concurrent run. - Assert that the API answers, not that the checks pass. A runner has no Messages database, no Keychain entry and no
claudebinary, so/api/statuswill report failing checks. The test asserts a 200 with the expected keys and a clean shutdown throughagent.stop(). - Skip the new test off darwin. The Linux
testjob runs the whole suite, so the agent test has to be gated onprocess.platform === 'darwin'rather than break that job. - Say what the job still does not cover.
setOpenAtLoginandretireOtherInstallreturn early unless the executable sits inside a bundle (IN_APP_BUNDLEatapp/Uplink/desktop-agent.ts#L38, checked atapp/Uplink/desktop-agent.ts#L88andapp/Uplink/desktop-agent.ts#L106), so booting from source leaves the LaunchAgent path untested. Either run the built bundle's own executable for that step, or record in the workflow that launchd,codesignand AppleScript stay manual.
Acceptance
.github/workflows/ci.ymlhas amacos-latestjob that runs on pull requests without any repository secret.- Breaking the app bundle, for example by renaming the launcher entry or dropping a required
Info.plistkey, fails that job. - Breaking the loopback API, for example by changing the
/api/statuspath or its response shape, fails that job. - The Linux jobs are unchanged, and the new agent test is skipped there rather than failing.