The normal path is CI: push a v* tag, .github/workflows/release.yml builds
both platforms, signs and notarizes the macOS dmg, and publishes the GitHub
release. Reach for the local path below when CI is blocked rather than broken
— GitHub's macOS runner queue and Apple's notary queue have each stalled for
hours in practice, and neither is fixable from this repository.
scripts/package-macos.sh does the packaging itself and refuses to run
without credentials; the local path is the same script, run by hand, with no
job time limit.
| What | Where |
|---|---|
| Signing identity | Developer ID Application: Tian Deng (V5KP6ZYMDT), login keychain |
| Notary API key | ~/Downloads/AuthKey_492MX6SWPC.p8 |
| Key ID and Issuer ID | scripts/notary.local (gitignored, see below) |
Confirm the identity and its private key are present before anything else:
security find-identity -v -p codesigning | grep "Developer ID"Exactly one identity must print. An Apple Development certificate cannot
sign for distribution. If the identity is missing, the certificate has to be
created in the Apple Developer portal — no script can do that step.
scripts/notary.local holds the three values the script needs:
export NOTARY_KEY=~/Downloads/AuthKey_492MX6SWPC.p8
export NOTARY_KEY_ID=492MX6SWPC
export NOTARY_ISSUER=<issuer UUID from App Store Connect -> Users and Access -> Integrations -> Keys>The Key ID is not a secret (it is in the key's filename); the .p8 is, and
stays where it is.
-
Check disk space. Not only for a release:
target/reaches 14 GB on an ordinary full build — 11 GB for the desktop debug build and its tests, 3.3 GB for the wasm demo (measured 2026-09-24, with duckdb's bundledjsonandparquetextensions). A release build is on top of that.df -h /System/Volumes/Data # want > 15Gi available9 GiB free is not enough: a cold
cargo testfrom that gets most of the way through duckdb and then dies. The usual culprits are this and sibling Rust projects'target/directories —cargo cleanin one of those is usually the fastest 20 GB you will find.On a full disk the failure is not a clean "out of space" —
libduckdb-sysdies insidear cqwith no explanation beyonderrno=28, and a build that gets further reports "failed to link or copy" on the final artifact. Both mean the disk, not the code. -
Build from a worktree of the tag, never from the working tree. The maintainer usually has uncommitted work, and it must not end up in a release:
git worktree add /tmp/cloudbridge-vX.Y.Z vX.Y.Z cd /tmp/cloudbridge-vX.Y.Z && cargo build --release
-
Package with the script from
main, invoked by path so a tag that predates a packaging fix still gets the fix:source /Users/admin/side-proj/cloudbridge/scripts/notary.local cd /tmp/cloudbridge-vX.Y.Z /Users/admin/side-proj/cloudbridge/scripts/package-macos.sh \ target/release/cloudbridge cloudbridge-macos-arm64.dmg X.Y.Z
The script signs the app and the dmg, submits the dmg for notarization, waits, staples, then runs
stapler validateand twospctlassessments. It must be run from the repository root — it readsassets/andthemes/from the working directory.macOS will raise a GUI keychain prompt the first time
codesignuses the key. Someone has to click Always Allow; an agent cannot. -
Expect to wait, and know that waiting is not failure. Apple's notary service has taken anywhere from minutes to over 12 hours per submission for this team. Two facts follow:
-
The 5-hour
--timeoutin the script is a client-side wait, not a cancellation. When it expires the submission keeps processing on Apple's side, and the ticket can still be collected later. -
A ticket binds to the exact bytes submitted. Do not rebuild after a timeout — a rebuild re-signs with a fresh timestamp, producing different bytes and no ticket. Staple the file that was submitted:
xcrun notarytool history --key "$NOTARY_KEY" --key-id "$NOTARY_KEY_ID" --issuer "$NOTARY_ISSUER" xcrun notarytool info <submission-id> --key "$NOTARY_KEY" --key-id "$NOTARY_KEY_ID" --issuer "$NOTARY_ISSUER" # once the dmg's submission reads Accepted: xcrun stapler staple cloudbridge-macos-arm64.dmg
-
-
Verify the artifact as a user's Mac would, not just as the script does:
xcrun stapler validate cloudbridge-macos-arm64.dmg spctl --assess --type open --context context:primary-signature --verbose=2 cloudbridge-macos-arm64.dmg MNT=$(hdiutil attach -nobrowse -readonly cloudbridge-macos-arm64.dmg | grep -o '/Volumes/.*' | head -1) spctl --assess --type execute --verbose=2 "$MNT/CloudBridge.app" hdiutil detach "$MNT" -quiet
All four must report
acceptedwithsource=Notarized Developer ID. -
Publish. Pushing a tag runs CI, which builds and publishes the release; creating a tag through the GitHub release UI also triggers that workflow, so a hand-uploaded asset gets overwritten by CI's. Either push the tag and let CI own the release, or upload by hand and accept that CI will refresh the assets.
| Symptom | Cause |
|---|---|
...: rejected source=no usable signature on the dmg, app accepted |
The dmg itself was never signed. Signing the app inside is not enough; spctl's open assessment reads the dmg's own signature |
error: -25294 importing the .cer |
The keychain dropdown in the import dialog was left on "Local Items"; pick "login" |
.p12 greyed out in the export dialog |
Only the certificate was selected. Expand it and select certificate and private key |
security export: The contents of this item cannot be retrieved |
security export without a filter exports every identity and aborts if any one fails. Export the single identity from the GUI instead |
ar: ... libduckdb.a failing with no message |
Disk full |
Notarization In Progress for hours |
Apple's queue, not a configuration problem. Verified by notarytool history showing sibling submissions Accepted |
The same crate builds a browser version:
./scripts/build-web.sh # debug; add --release for the shipped one
python3 -m http.server 8000 --directory web/sitesrc/lib.rs chooses a data backend per target with cfg: DuckDB, the provider
APIs and the OS keyring on the desktop; an in-memory ledger seeded with demo
data in the browser (src/web/). Everything above that — src/ui/, app.rs,
alerts.rs, model.rs — is shared and compiles for both, so a change to a
page must keep building for wasm, not just for the desktop.
The two backends select and group charges their own way, but stop there.
Anything computed from those groups is src/analytics.rs: the run-rate
forecast and its bands, the period comparison, the cost-change decomposition,
the trailing daily average, balance burn and the data-quality findings, plus
the billing-period arithmetic. It is pure — now is an argument, nothing
opens a store — and it carries the tests for all of it. A new statistic goes
there, not into a query.rs; what belongs in a query.rs is the read that
feeds it. The demo bill is the same arrangement: src/demo_data.rs builds the
rows, each target's ledger::demo writes them.
.github/workflows/ci.yml builds the web demo as a required job — it is
the only thing that notices when the two backends' signatures drift apart, or
when a page stops compiling for wasm. The MSRV check beside it stays advisory.
The MSRV job reads package.rust-version from Cargo.toml (currently 1.95).
The locked gpui-pre dependency uses std::hint::cold_path, stabilized in
Rust 1.95; cargo +1.94.0 check --locked fails on this API on macOS ARM64.
Keep the job non-blocking until the Ubuntu CI check is verified; local macOS
validation does not establish compatibility on other platforms. Revalidate
the minimum when updating Cargo.lock. The wasm build still needs nightly.
The Ubuntu jobs compile the desktop application for Linux, which needs four
system libraries installed first — see "Install Linux system libraries" in
ci.yml. libfontconfig1-dev is the one that bites: yeslogic-fontconfig-sys
probes fontconfig from its build script and panics instead of falling back,
so its absence fails cargo check before any of this project's code is read.
The other three cover the freetype2 probe and the X11 backend's direct
-lxkbcommon/-lxcb links. A dependency that links a C library on Linux has
to be added to every job that compiles the tree — each runs on its own runner,
and nothing here needs these libraries on macOS or Windows.
Things that will bite you:
- Nightly is required for the wasm build only.
gpui-pre-webpulls Zed'swasm_thread, which uses astdarch_wasm_atomic_waitfeature stable does not have. The desktop stays on stable. The nightly is pinned by date —WEB_TOOLCHAINinscripts/build-web.sh, and the same date in the three workflows that build it (ci.yml,gh-pages.yml,web-demo.yml) — because an unstable feature can be renamed overnight and a floating+nightlyturns that into a build that broke with nobody having changed anything. To build against a nightly you already have:WEB_TOOLCHAIN=nightly ./scripts/build-web.sh. Bumping the pin means changing it in all four places. wasm-bindgenCLI must match thewasm-bindgencrate version.scripts/locked-version.py <package>prints whatCargo.lockresolved and fails when the answer is not a single version; the build script and the workflows read it from there rather than guessing.- The browser has no system fonts.
gpui-pre-webstarts with an empty font database, and GPUI resolves.SystemUIFontto IBM Plex Sans there; without that family registered the text system panics rather than falling back. The four fonts are inweb/site/fonts/(OFL) and registered insrc/wasm_entry.rs; they areinclude_bytes!d, so they must exist to compile. - Icons are fetched, not embedded. gpui-kit's wasm asset source requests
<endpoint>/assets/icons/<name>.svgon demand, so the build script copies the catalog out of thegpui-kit-assetsversionCargo.locknames intoweb/site/assets/(gitignored). The endpoint must be an absolute URL — the fetch goes through reqwest, which rejects a relative one with a bare "builder error" and leaves every icon blank.main.jspasses the page's own directory torun(), so the same module works at a site root and under/demo/; anything added toweb/site/index.htmlhas to stay relative for the same reason. - The demo is published for the website. The site lives in
JetSquirrel/cloudbridge-site
and is built by Cloudflare, which cannot install the pinned nightly.
.github/workflows/web-demo.ymlruns a release web build on every push tomainand uploadsweb/site/ascloudbridge-web-demo.tar.gzto a rollingweb-demoprerelease; the site's build downloads it into/demo/. Until the domain moves to the new site,gh-pages.ymlstill publishes the olddocs/with the demo dropped intodocs/demo/. smol::unblockis forwarded byweb/smol-bridge. Cargo refuses one dependency name with two sources, so the desktop'ssmolis reached through that crate instead of beside it. The pages callsmol::unblockunchanged; on wasm it runs the closure inline, because there is no second thread.cargo build --releaseat the root still means the desktop application.[workspace] default-members = ["."]keeps it that way; the wasm build iscargo rustc --lib --target wasm32-unknown-unknown --crate-type cdylib. Thecdylibis asked for there and not inCargo.toml, so desktop builds skip linking a library they never load. A release wasm build is heavy and the disk advice above applies to it too.