How a native version bump becomes a signed and notarized GitHub Release.
Release pipeline
CodeVetter ships one native macOS application through GitHub Releases. Sparkle
consumes the release’s signed appcast.xml.
Chain
apps/macos/Config/Shared.xcconfig version changes on main
-> auto-release.yml creates the v<version> tag and a DRAFT release,
dispatches release.yml, and waits for that run
-> release.yml calls native-production-qualification.yml at that exact tag
-> Xcode Release build + Rust/ccusage companions
-> Developer ID signing + hardened runtime + notarization + stapling
-> isolated incumbent-to-native upgrade, relaunch, data, and rollback proof
-> Sparkle archive and EdDSA appcast verification
-> DMG, ZIP, and appcast.xml uploaded to the draft GitHub Release
-> verify-release-manifest.mjs qualifies the published manifest
-> the draft is flipped to published and marked latest
-> auto-release re-verifies the manifest before it reports success
The explicit dispatch is required because a release created with GitHub’s workflow token does not recursively trigger another workflow. Both auto-release and release jobs are idempotent for an existing tag.
Asset gate
The release is a draft for the whole of the build. A draft is absent from
/releases/latest, from the release feed the download page reads, and from
Sparkle’s update feed, so a failed build leaves nothing user-visible behind.
scripts/verify-release-manifest.mjs --tag v<version> is the required gate that
flips it. It fails the run when any contract asset is missing, empty, still
uploading, or attached under a name that is not on the contract, and it runs
twice: in release.yml before the draft is published, and again in
auto-release.yml after the watched build finishes. A green auto-release.yml
run therefore means a downloadable release exists.
This gate did not exist before #253. auto-release.yml created the release
before any build ran and exited without waiting, so six consecutive releases
(v1.12.0 through v1.13.2) went public with zero assets while all eight runs
reported success. verify-release-manifest.mjs was referenced by no workflow
and no package script.
Required production gates
- the tag equals
MARKETING_VERSIONinShared.xcconfig; - the app and every executable companion use the production identity/version;
- Developer ID signatures, one Team ID, Hardened Runtime, and Library Validation pass;
- Apple’s notarization accepts the final archive and the app carries a valid stapled ticket;
- the Sparkle appcast is HTTPS, EdDSA-signed, and bound to the exact qualified archive;
- the isolated installed-upgrade proof preserves durable local data across native relaunch and incumbent rollback;
release-readiness.jsonreports every check passed andshipping_ready: true.
Credentials are required only inside the protected ephemeral runner. They are never printed, persisted in artifacts, or used by local read-only preflight.
Migration incumbent
The installed-upgrade proof needs a prior Tauri build to upgrade from, pinned
in native-production-qualification.yml. That incumbent has to satisfy two
constraints at once:
- it still carries
CodeVetter_aarch64.app.tar.gz— only Tauri releases do, andv1.11.1is the newest retained Tauri tag carrying that archive; and - its bundled
codevetterCLI understandsrubrics, because that is how the proof seeds the durable record whose survival across upgrade and rollback is the thing being proven.
The pin is v1.11.1. It was v1.11.0, which fails the second constraint:
that CLI predates rubrics and exposes only check and trex, so the gate
could never pass and no release published assets (#252). rubrics landed in
44390309 (2026-09-02), one day before v1.11.1 was tagged.
Only a Tauri release can serve here, so this pin cannot advance past v1.11.1.
When cross-shell migration stops being worth proving, narrow the proof to data
the candidate writes itself rather than repointing the pin.
Launching the proof’s applications
Both shells are started through LaunchServices (open -n -F --env CODEVETTER_APP_DATA_DIR=… -a <bundle>), never by executing
Contents/MacOS/<executable> directly, because a directly spawned bundle
executable is not a registered GUI application and nothing about its windows can
then be observed.
The launched process is resolved from the process table by exact executable
path, then polled with backoff: System Events window count first, and, only
when accessibility never answers within the timeout, the LaunchServices
visible-process record. Each launch entry in the proof names the signal that
proved it in observed_via.
The window query must bind its filter to the process, not to the windows:
set matches to (every process whose unix id is targetPid)
return (count of windows of item 1 of matches) as text
The one-line form count windows of first process whose unix id is N binds
whose to windows instead, so every poll fails with Can't get unix id of window (-1728) even against an application showing a window. That, together
with the direct spawn, is why the proof failed at its first launch (#252).
Assets
Exactly these three, and nothing else:
CodeVetter-<version>-arm64.dmgCodeVetter-<version>-arm64.zipappcast.xml
The published v1.13.7 release carries these three contract assets. Its
qualified ZIP digest matches the public asset, and hosted evidence confirms
upgrade from Tauri v1.11.1, relaunch, rollback, and custom-rubric preservation.
The download page reads the filename and tag from the release feed at build
time; redeploy it after publishing a new release. See
apps/landing-page-astro/src/data/release.ts.
Appcast signature verification and manual installed-upgrade qualification do not exercise an in-app Sparkle update. That remaining behavior is tracked in issue #253.
Key files
.github/workflows/auto-release.yml.github/workflows/release.yml.github/workflows/native-production-qualification.ymlapps/macos/Config/Shared.xcconfigscripts/qualify-native-package.mjsscripts/inspect-native-release-readiness.mjsscripts/verify-release-manifest.mjs- runbooks/cut-a-release.md