Development guide
Development guide
The engineer-facing onramp. This page is about how you hack on outl — clone, build, test, debug, ship.
If you’re looking for what reviewers measure your PR against, that’s Contributing & code review. The two pages are deliberately split: this one is workflow, that one is policy. Read this first; read that before opening a PR.
If anything here is wrong or out of date, that’s a bug — open an issue or fix it in the same PR that drifted the behavior.
1. Quick start
git clone https://github.com/outlmd/outl.git
cd outl
cargo build --workspace
cargo test --workspace
You need Rust 1.99+.
rust-toolchain.toml pins the exact version, so rustup installs it on the first build.
No other system dependency is needed for the core, CLI, TUI, or MCP server.
To smoke-test that the build actually does something, generate a fixture workspace and open it:
# Creates ./playground with a few pages + journals
just init-playground # or: invoke /init-playground inside Claude Code
cargo run -p outl-tui -- --workspace ./playground
Press ? inside the TUI for the keymap.
q quits.
Optional toolchains by area
| You’re touching… | You also need |
|---|---|
outl-mobile (iOS app) | macOS + Xcode 15+ + Bun (curl -fsSL https://bun.sh/install | bash) |
outl-desktop (Tauri 2) | Bun + the Tauri prerequisites for your OS (Linux: webkit2gtk-4.1, libgtk-3-dev; Windows: WebView2 runtime) |
Frontend tests (crates/outl-mobile/src/**, crates/outl-desktop/src/**, crates/outl-frontend-shared/**) | Bun + bun run test |
| Bench job locally | cargo install hyperfine --version 1.21.0 --locked for the CLI side; criterion ships with cargo bench |
The CI containers don’t install GTK, so outl-mobile and outl-desktop are excluded from the workspace cargo clippy/test/doc runs (see CI walkthrough).
That means a clean cargo test --workspace does not exercise those two crates.
If you change them, run their crate-specific commands and let CI’s mobile.yml / desktop.yml matrices cover the rest.
2. Repository tour
The workspace lives in crates/.
Each crate has its own CLAUDE.md — read it before editing.
| Crate | What it owns |
|---|---|
outl-core | Tree CRDT, op log, HLC, Storage trait. Never imports UI or CLI. |
outl-md | Markdown parse / render, sidecar (.outl), 3-level matching, inline tokens, workspace index. |
outl-actions | UI-agnostic workspace operations. Every client calls into here. |
outl-exec | Code-block runtime (desktop + mobile; mobile opts out of lang-rust). |
outl-tauri-shared | Shared Tauri backend — command bodies, wire DTOs, plugin thread, AppHost / StorageRootProvider traits. Both outl-desktop and outl-mobile are thin wrappers over this; a new command body always goes here first. |
outl-cli | The outl binary (subcommands + JSON envelope). |
outl-tui | The outl-tui binary (terminal editor). |
outl-mobile | Tauri 2 mobile (iOS today) — thin #[tauri::command] wrappers, iOS-native bridges (NSMetadataQuery, BGTaskScheduler), Solid frontend. |
outl-desktop | Tauri 2 desktop (macOS/Linux/Windows) — thin #[tauri::command] wrappers, FS watcher, settings IO, Solid frontend. |
outl-frontend-shared | @outl/shared — Solid + TS lib mobile + desktop both consume. |
outl-config, outl-theme, outl-shortcuts | Shared config / palette / chord catalog across TUI + desktop. |
Desktop crate layout
crates/outl-desktop/
├── package.json / tsconfig*.json / vite.config.ts / vitest.config.ts / index.html
├── src/ # frontend (Solid)
│ ├── index.tsx App.tsx (Onboarding/AppShell gate) styles.css setup.test.ts
│ ├── components/ # AppShell, Sidebar, OutlineView (impl BlockCallbacks),
│ │ # BlockRow (chrome) → BlockBody (read) / BlockEditor
│ │ # (textarea) / block-suggest / block-callbacks /
│ │ # CodeFenceView / SuggestPopups, BacklinksPanel, Picker,
│ │ # SettingsModal, ChromeToggleBar, SyncIndicator,
│ │ # PropertyEditor (key:: value chips + create/delete),
│ │ # Onboarding, WorkspacePicker
│ └── lib/ # api.ts (desktop-only cmds), code-block.ts, events.ts,
│ # shortcuts.ts, action-handlers.ts, store.ts
└── src-tauri/
├── Cargo.toml build.rs tauri.conf.json (app.outl.desktop) capabilities/ icons/
└── src/ # main.rs, lib.rs (run() registers all commands),
# settings.rs, state.rs, helpers.rs, workspace_open.rs,
# plugin_service.rs, fs_watcher.rs,
# commands/ (thin shims over outl_tauri_shared::commands)
Entry points by intent
When you want to make a change, don’t start from the client — start from the layer that owns the concept.
| You want to… | Start here |
|---|---|
| Fix or extend the CRDT algorithm | crates/outl-core/src/tree/mod.rs (then run the paper-verifier agent) |
Add a new Op variant | /new-op skill — it lists every place that needs to change |
Change how .md is parsed or rendered | crates/outl-md/src/{parse,render}.rs |
| Change how a block survives external edits | crates/outl-md/src/{matching,diff,reconcile}.rs |
| Add a shared workspace mutation (TODO toggle, indent, etc.) | crates/outl-actions/src/{block,collapsed,todo,page}.rs |
| Add a CLI subcommand | crates/outl-cli/src/cmd/ (mirror an existing one for the JSON envelope) |
| Add a TUI shortcut, mode, or overlay | crates/outl-tui/src/ (and update docs/tui.md + docs/shortcuts.md) |
| Add an MCP tool | crates/outl-cli/src/mcp/ (mirror an existing tool’s shape) |
| Add a theme preset | crates/outl-theme/src/presets/ |
| Touch the iCloud watcher or sync engine | crates/outl-actions/src/sync.rs; mobile-side is crates/outl-mobile/src-tauri/ |
If you can’t tell where something belongs, grep the Shared primitives catalog.
Its three parts — core, markdown, actions — are the canonical map of “who owns this concept”.
One grep covers all of them: grep -n 'symbol' docs/shared-primitives.md docs/primitives-*.md.
3. Running outl locally
CLI / TUI
# From a fresh build, no install needed:
cargo run -p outl-cli -- init ~/playground-notes
cargo run -p outl-tui -- --workspace ~/playground-notes
# Or build once, run many:
cargo build --release
./target/release/outl init ~/playground-notes
./target/release/outl --workspace ~/playground-notes # TUI
./target/release/outl --workspace ~/playground-notes page list --json
MCP server (Claude Desktop, Cursor)
cargo run -p outl-cli -- mcp --workspace ~/playground-notes
For wiring into Claude Desktop / Cursor, see docs/mcp.md.
Every MCP tool has a outl_* name; the source lives in crates/outl-cli/src/mcp/.
Mobile (iOS simulator)
cd crates/outl-mobile
bun install # only once
bun run tauri ios dev # boots the iOS simulator with hot reload
crates/outl-mobile/CLAUDE.md covers the versioning + TestFlight contract.
Do not touch tauri.conf.json’s version field — the version is read from Cargo.toml at build time on purpose.
The commands below spell this cargo tauri rather than bun run tauri.
Both drive the same Tauri CLI and either works: bun run tauri goes through the @tauri-apps/cli dev-dependency pinned in package.json (nothing to install beyond bun install), while cargo tauri uses a globally installed cargo-tauri.
CI uses the cargo spelling, so that is what the release-shaped commands show.
Simulator, device, and release archive
cd crates/outl-mobile
# iOS simulator
cargo tauri ios dev "iPhone 17 Pro outl"
# Physical device (Mac + iPhone on the same WiFi)
cargo tauri ios dev "<device-name>" --host
# Release archive for TestFlight (local smoke test only — CI ships)
cargo tauri ios build
After the first run, the iCloud capability must be confirmed in Xcode (Signing & Capabilities → iCloud → Containers → iCloud.app.outl.mobile-app).
Why the mobile crate has native Swift / ObjC code
Tauri 2 gives you a WebView + a JS ↔ Rust bridge. What it does not give you is direct access to the iOS platform APIs that outl actually needs to function as a multi-device app:
| Native surface | Why we need it |
|---|---|
NSMetadataQuery + NSFileCoordinator + startDownloadingUbiquitousItemAtURL (in main.mm) | iCloud syncs file metadata aggressively and file content lazily. Without forcing materialisation before a read, the Rust side opens an empty placeholder for a peer’s ops-<actor>.jsonl, the merge is wrong, and the projection writes a broken .md back. This is the iCloud catch — see ios-platform.md. |
BGTaskScheduler (OutlBackgroundRefresh.swift) | Drain peer ops while the app is backgrounded so the user doesn’t open to a stale tree. |
UIInputAccessoryView (OutlToolbar.swift) | The formatting toolbar must be UIKit — a WebView toolbar has input-focus latency and the keyboard re-anchors when the toolbar mounts. |
Native suggest overlay (OutlSuggestOverlay.swift, OutlSuggestView.swift) | Autocomplete chips anchored to the caret without the keyboard jumping or the WebView reflowing. |
Method swizzle + brand chrome (OutlSwizzle.swift, OutlBrandChrome.swift) | Splash, status-bar, and a few UIKit hooks the WebView doesn’t expose. |
The native code is split into two tiers with different test contracts:
crates/outl-mobile/swift/OutlKit/— pure Swift package (SPM). Logic that’s testable in isolation: brand color, autocomplete chip parsing, toolbar action enum + MFU ordering, peer-file predicates, JS string escaping. Has unit tests underswift/OutlKit/Tests/OutlKitTests/. Run withswift testfromswift/OutlKit/or viamobile.ymlin CI.crates/outl-mobile/src-tauri/gen/apple/Sources/outl-mobile/— Tauri-generated iOS shell + outl-specific UIKit / Foundation bridges. Files:main.mm,OutlOpsWatcher.swift,OutlBackgroundRefresh.swift,OutlToolbar.swift,OutlSuggestOverlay.swift,OutlSuggestView.swift,OutlSwizzle.swift,OutlBrandChrome.swift. No unit tests — observed viaNSLogprobes printed on app boot. Run on a real device or simulator to exercise.
Rule of thumb: if the helper can be tested in a vacuum, it goes in OutlKit.
If it has to bind to a UIKit / Foundation API that needs the iOS runtime, it goes in gen/apple/.../*.swift and ships with diagnostic logs instead of tests.
Desktop (Tauri 2)
cd crates/outl-desktop
bun install # only once
bun run tauri dev # dev window with hot reload
A release dmg is built only in CI (release.yml’s build_desktop job, universal arm64 + x86_64).
Linux bundles come from build_desktop_linux (AppImage / deb / rpm, one leg per architecture: x86_64 and aarch64, each on a runner of that architecture) and the Windows installer from build_desktop_windows (NSIS .exe; not msi — WiX rejects the non-numeric pre-release identifier every beta carries).
Local bun run tauri build is fine for smoke-testing your own arch.
The dmg’s install window is artwork, and CI has to be told to apply it.
crates/outl-desktop/src-tauri/dmg/background.svg is the source of truth.
background.png beside it (660x400, Tauri’s default dmg window size) is what the bundler actually consumes, and tauri.conf.json’s bundle.macOS.dmg places the app and the Applications alias on it.
Regenerate with nix run nixpkgs#resvg -- background.svg background.png -w 660.
The card’s fill is tuned to a specific luminance so that both system label colours clear WCAG AA on it, because the Finder draws those labels in a colour that flips with the OS appearance.
That reasoning and its numbers live in the SVG’s own comment; lightening or darkening the band under the labels breaks one appearance.
Two things about it are silent, which is why they are written down:
- tauri-bundler turns the whole thing off under CI.
It passes
--skip-jenkinsto its vendoredbundle_dmg.shwheneverCI == "true", and that flag skips the AppleScript applying the background and the icon positions. The result is a stock window and no error.release.yml’s dmg step setsTAURI_BUNDLER_DMG_IGNORE_CI: "true"to opt back in, and retries once without it if that AppleScript fails, because it drives the Finder over Apple Events and a runner can refuse it Automation permission. The dmg is arequiredasset, so without that retry a background image could hold back the whole macOS release. - Don’t build a release dmg locally under a tiling window manager.
The AppleScript sizes the Finder window, and the Finder then bakes whatever frame it ended up with into the dmg’s
.DS_Store. AeroSpace (or any tiler) snaps that window first, so the snapped size is what ships. CI has no window manager, so the configured size sticks there.
There is no retina variant: tauri-bundler copies the single background file verbatim and has no @2x or tiffutil path, which is why the art is drawn with flat shapes and thick strokes.
desktop.yml has no aarch64 leg, on purpose.
Its check job compiles the whole desktop tree on Linux, so a compile error reaches a PR there whatever the architecture; what is specific to aarch64 is the bundling — the runner’s webkit2gtk, linuxdeploy’s aarch64 AppImage tooling, the per-arch AppRun seed — and none of that is exercised by a --no-bundle debug build.
The thing that does exercise it is release.yml dispatched with dry_run: true, which builds and verifies every artifact on the branch and publishes nothing.
Run that on any PR touching the release pipeline; adding a second bundling leg to every desktop PR buys the same signal at a recurring cost.
Adding a platform starts in scripts/release_assets.py.
That file is the list of what a release publishes: one entry per build-matrix leg, naming its assets, whether it is required, and which package manager points at it.
Then the matrix leg in release.yml, its entries in update_tap’s loops, and the url / sha256 / bottle anchors in the manifest that serves it.
Every way those can disagree is silent at release time — a sed that matches no line exits 0, update_tap finishes on git diff --quiet, which only asks whether something changed — so somebody’s brew install or scoop install is the first thing to notice, one release later.
scripts/check-release-assets.py --manifest runs in hygiene.yml and fails on the mismatch instead; it parses rather than builds, so it takes a second and needs nothing installed.
A release publishes on what is on the draft, not on whether every job went green.
The gate used to be needs.<job>.result == 'success' per build job, which made each job a single point of failure for every other job’s assets: one architecture’s bundler, or one flaky runner, and a release with 25 finished artifacts published none of them.
The repo’s own workaround for build_android was the opposite extreme — absent from the conditions, so a missing APK published in silence, and the same shape produced a v0.12.0 draft with zero assets that nothing complained about.
So required lives in that table and the pipeline reads it in three places:
- a leg marked optional carries
continue-on-error: ${{ matrix.optional || false }}, which keeps the workflow run from going red over a platform still in bring-up; publish_releasedoes not read job results at all, which is the part that actually keeps the release moving — a half-failed matrix reports whatever it reports toneeds, and the gate never asks;- what it reads instead is the draft’s asset list, against the table: a missing required asset fails the publish, and a missing optional one publishes with the gap named in that release’s own notes and in the run summary;
update_tapmoves a manifest only when every asset that manifest points at is present, because a manifest carrying a new version and the previous release’s digest is a hash mismatch on install — worse than one still pointing at a release that exists.
Promoting a platform out of bring-up is flipping required in the table, and that flip is the record that it has been green.
Nothing about this is a reason to leave a platform optional: the gap is loud in three places, and a leg that keeps failing is a bug with a name.
Asset names use two architecture spellings, and both are deliberate.
The CLI / TUI tarballs are outl-linux-x64 / outl-linux-arm64 (and outl-macos-x64 / outl-macos-arm64), the desktop bundles are outl-desktop-linux-x86_64 / outl-desktop-linux-aarch64.
The desktop set matches uname -m and Linux packaging convention, which is what a .deb / .rpm / AppImage user expects; the CLI set is older and is what the Homebrew anchors and every published release URL already say.
A release asset URL does not redirect, so renaming either family breaks links that are already in the wild — the spellings stay split, and the docs name both.
# from the repo root
bun install # hoists workspace deps
# dev (Tauri opens a native window with the Vite dev server inside)
cd crates/outl-desktop
cargo tauri dev
# production bundle (.dmg / .AppImage / .msi depending on host OS)
cargo tauri build
The Vite dev server runs on port 1421 so it can coexist with outl-mobile (port 1420) when both are running side by side.
Testing P2P sync from a source build
A binary cargo launches is a different device from the installed app.
The repo’s .cargo/config.toml exports OUTL_DEVICE_DIR = .dev-device-store so the test suite stays off your real ~/.config/outl and ~/.outl.
A suite that used them would mint keys in your home directory and arbitrate the endpoint lease against your running desktop app.
Cargo exports that variable to everything it launches, cargo run -p outl-desktop included, so a source build gets its own actor id and its own iroh identity — a node id no peer has ever heard of.
Two things follow, and both look like a transport bug when you hit them:
- Devices paired with the installed app store that app’s node id, so the source build reads as offline to them forever.
- The identity is not under
target/, and that is deliberate. It was, andcargo cleandeletestarget/— which the size hooks run automatically — so every clean minted a new node id and voided every pairing made with the old one. The peer then answerspeer refused: this device is not paired with it, because pairing is bidirectional and the phone still lists an id that no longer exists. Three node ids in one day before the store moved to.dev-device-store/, which survives a clean.
default_device_dir() logs one WARN naming the directory at startup, so a source build always says which device it is.
To exercise sync against real peers (a phone on TestFlight, another machine), clear the variable for that command so the build uses the machine’s real identity:
OUTL_DEVICE_DIR= cargo run --release -p outl-desktop
Only do that for a run you want on the wire — it takes the endpoint lease from any installed client that is already running, and writes to your real device store.
A source build is now stably paired by default.
Pair the phone against .dev-device-store’s identity once and it stays paired across rebuilds and cleans; deleting that directory is the one thing that breaks it again.
It does grow, and pruning it is not the same as deleting it.
Every test opens a temp workspace and leaves an actor binding behind, and cargo clean no longer sweeps them: one session left 935 records, all pointing at TempDir paths that were already gone.
./scripts/gc-dev-device-store.sh --dry-run # count first
./scripts/gc-dev-device-store.sh # drop bindings whose workspace is gone
It only touches actors/, and only records whose root= no longer exists.
A binding is regenerated on the next open, so losing one costs nothing; iroh/identity.key is the node id, so losing that voids every pairing.
That asymmetry is the whole reason this is a script and not rm -rf.
Symptoms and network-side causes are in sync.md → Troubleshooting sync.
Playground workspace
Manual smoke tests share a fixture workspace at ./playground/.
Generate it with the /init-playground slash command (Claude Code) or by hand:
mkdir -p playground
cargo run -p outl-cli -- init ./playground
# Seed a few pages / journal entries with the `outl page create` / `outl daily append` CLI.
./playground/ is gitignored — feel free to nuke and regenerate.
4. The dev loop
The expected per-edit cycle:
- Edit the relevant
.rs/.md/.ts. - Run
/check(orcargo fmt --all && cargo clippy --workspace --all-targets -- -D warnings && cargo test --workspace). - If you touched the CRDT four (
do_op,undo_op,apply_op,creates_cycle), run/check-invariants+/coverage outl-core. - If you touched
outl-md, run/roundtrip. - Commit using Conventional Commits.
Slash commands
| Command | What it does |
|---|---|
/check | Full gate, both halves: cargo fmt --check + cargo clippy -D warnings + cargo test --workspace + cargo doc, then bun run test (vitest) + bun run typecheck (tsc). Run before reporting done. |
/check-invariants | Faster than /check. Runs only the tree CRDT invariant test battery in outl-core. |
/roundtrip | outl-md md ↔ ops ↔ md roundtrip suite. Also invokes the markdown-roundtrip-tester agent for extra validation. |
/coverage [crate] | Uses cargo-llvm-cov. Flags uncovered branches in the four critical CRDT functions (the 100% rule). |
/new-op <Variant> | Walkthrough for adding a new Op variant. Lists every file that needs to move. |
/init-playground | Regenerates ./playground/ with fixture pages + journals. |
Hooks (run automatically)
.claude/settings.json wires these PostToolUse hooks on every Edit / Write:
-
fmt + clippy on the touched crate (faster than
/check, runs per save). -
file-size-guard.sh— informational at 400–600 lines, warns at 600–900, blocks at 900+, for.rs,.tsand.tsxalike. When it fires, invoke therefactor-architectagent to propose a split. It covered only.rsuntil 2026-09, which is why the largest files in the repo are frontend ones it never looked at.A hook only runs when Claude Code is the editor. A human in an editor, a Copilot PR and a dependabot bump all bypass it, so the same limit is enforced for everyone in CI by
scripts/check-file-size.sh(thehygienejob inhygiene.yml). That runs as a ratchet, not a cliff: the files already past the threshold are frozen with their current line counts in.github/file-size-baseline.txt, and the job fails only when a file not in the baseline crosses 600 lines, or one in it grows past its recorded number. Large files therefore stay editable and the ceiling only moves down. After a split lowers a count, re-record it withscripts/check-file-size.sh --update; the script refuses to write a baseline from a scan that matched nothing, so a broken checkout cannot silently empty it. -
scripts/check-docs-index.sh— every RFC indocs/rfcs/is reachable fromdocs/SUMMARY.md. It exists because the index was silently wrong five times in a row: one branch added five RFCs — including the two documenting the changes most capable of destroying data — and listed none of them. The index was complete each time it was last edited; five separate changes each forgot the same step, and nothing failed when they did. It checks reachability, not wording: the summary line is the RFC author’s to write, and a script that generated it would become a second owner of that fact. -
file-size-sweep.sh— the same ratchet, wired as aStophook so it runs once at the end of a turn. Every hook above it isPostToolUseonEdit|Write, and that matcher does not include the Bash tool: a file created withpython3 - <<'PY',sed -i,cat >orgit mvlands on disk having passed no guard at all. The per-edit hook answers “Claude used Edit/Write on a big file”; this one answers “something got big, however it arrived”. It exits 2 once with the failure, then reports without blocking if it fires again (stop_hook_active), so a condition Claude cannot clear does not loop. -
single-declaration-guard.sh— catches code written where a cross-client surface used to live. Three surfaces are declared once and consumed everywhere, each having replaced N hand-maintained copies. The Tauri command surface (outl-tauri-shared/src/wrappers/catalog.rs), the post-mutation commit sequence (outl_actions::commit_page), and the Rust ↔ TypeScript wire contract (tests/wire_types.rs,tests/wire_enums.rs,tests/wire_mirrors.rs). Each already has a test that fails when a client drifts; this hook solves a different problem. A model working from the shape of the old code writes the hand-rolled version again, because the git history is full of it. The test catches that in CI; this catches it at the keystroke, with the file the change belongs in. Every check is deliberately narrow, because a hook that fires on correct code teaches the reader to ignore it. It compares command wrappers per function signature, soopen_ref— which needs anAppHandle— stays silent while a plain duplicate does not. It flags a bareapply_page_md_with_sidecar_guardedonly in a file that also mutates. And it carries a frozen baseline of the CLI / TUI call sites issue 264 already tracks. That baseline may only get shorter: migrating a file tocommit_pagedeletes its row. -
section-ref-guard.sh— flags a quoted section title that no longer exists.doc-sync-guard.shreasons about files touched, so renaming a heading passes it clean while leaving the old title quoted in every file that pointed at it. This one resolves`path.md` → "Title"andsee "Title"against the target’s headings, bold labels and table rows. Editing a.mdalso checks the references aimed at it, which is the direction a rename breaks. Full sweep on demand:.claude/hooks/section-ref-guard.sh --all. Its deliberate blind spots are listed in the script header — most importantly, a rename that only appends to the old title stays silent, because this repo quotes shortened titles on purpose.
If you’re not using Claude Code, run cargo fmt -p <crate> && cargo clippy -p <crate> -- -D warnings manually after edits.
Agents (specialised reviewers)
| Agent | Fires after edits in… |
|---|---|
crdt-invariant-checker | outl-core/src/{tree,log,op}.rs |
paper-verifier | do_op / undo_op / apply_op / creates_cycle |
markdown-roundtrip-tester | outl-md/{parse,render,sidecar,matching}.rs |
refactor-architect | Any file that crosses the 600-line warn threshold |
doc-keeper | Run at the end of every feature that changes public API, markdown syntax, TUI shortcut, slash command, sidecar / op-log format, CI workflow, dev loop, or user-observable behavior |
The agents are in .claude/agents/ if you want to inspect or extend them.
cargo doc gotcha
CI runs RUSTDOCFLAGS="-D warnings" cargo doc --workspace --exclude outl-mobile --exclude outl-desktop --no-deps and blocks on rustdoc::private_intra_doc_links.
The workspace is mostly pub(crate), so almost every [Foo] link in a doc comment to an internal type breaks the build.
Fix: drop the brackets, keep the backticks.
// Bad — fails CI:
/// See [`MyInternalThing`] for details.
// Good:
/// See `MyInternalThing` for details.
/check runs cargo doc as step 4, so a broken intra-doc link fails there before CI sees it.
5. Testing strategy
Where each test type lives
| Type | Location | What it asserts |
|---|---|---|
| Unit | crates/*/src/**/*.rs #[cfg(test)] | Single function behavior. Cheap. |
| Integration | crates/*/tests/*.rs | Public surface of one crate. Real MemoryStorage (or tempfile + JsonlStorage). |
| CRDT invariants | crates/outl-core/tests/crdt_*.rs | Convergence, idempotency, cycle no-op, replay determinism. |
| Roundtrip | crates/outl-md/tests/roundtrip_*.rs | md → parse → render → md is byte-stable. md → ops → md preserves ids via sidecar. |
| Bench | crates/outl-md/benches/*.rs (criterion) + xtask/src/bin/gen-10k.rs for CLI hyperfine | Hot-path regression detection. Run weekly + per PR via bench.yml. |
| Frontend | crates/outl-mobile/src/**/*.test.ts, crates/outl-desktop/src/**/*.test.ts, crates/outl-frontend-shared/**/*.test.ts (bun run test) | Pure helpers and DTO conversions. |
Swift (OutlKit) | crates/outl-mobile/swift/OutlKit/Tests/OutlKitTests/ (swift test) | Pure native helpers — brand color, suggester chip parser, toolbar MFU, peer-file predicates, JS escape. Required for new pure Swift logic. |
Native iOS bridges (gen/apple/.../*.swift, main.mm) | None (yet) — observed via NSLog probes on boot | UIKit / Foundation glue that needs the iOS runtime. If you add a piece that can be tested without UIKit, extract it into OutlKit first. |
Per-client test suites
Two layers cover each GUI client.
outl-mobile:
| Layer | Tool | What it covers |
|---|---|---|
| Rust commands + storage | cargo test -p outl-mobile | command shims, page model glue, capability parity |
| Frontend pure logic | bun run test (Vitest + happy-dom) | textarea/native-suggester helpers, future helpers (outline walks are tested in @outl/shared/outline) |
Every bug fixed in a pure helper (the tokenize duplicate, refs/tags extraction, fuzzy matching) must land with a unit test before merge so it never regresses.
Native bits (main.mm swizzle, BGTaskScheduler, NSMetadataQuery) are not covered by unit tests yet — they’re observed via the NSLog probes shown on app boot.
If we add Swift Tests later they belong next to main.mm in gen/apple/Tests/.
outl-desktop:
| Layer | Tool | What it covers |
|---|---|---|
| Rust commands | cargo test -p outl-desktop | command shims, settings IO, fs_watcher, surgical undo invalidation across a peer reload (helpers::invalidate_changed_history — only pages whose projection changed lose their stacks) |
| Frontend logic | bun --filter outl-desktop test | scaffold smoke, components + helpers |
Frontend suites today: src/setup.test.ts (scaffold smoke — @outl/shared alias resolves),
src/lib/chord-format.test.ts,
src/lib/markdown-wrap.test.ts,
and src/lib/action-handlers.test.ts — OpenRefUnderCursor regression (Enter edits the block; backlink rows open the source; pins #70).
Same file smoke-tests the block clipboard (cut arms blockClipboard; paste routes cut → moveBlockAfter, copy → pasteBlockAfter).
The 100% rule
do_op / undo_op (outl-core/src/tree/op.rs), apply_op (tree/apply.rs) and creates_cycle (tree/cycle.rs) carry a 100% line and branch coverage rule.
Any new branch needs a new test.
# Coverage report for outl-core specifically (uses cargo-llvm-cov):
/coverage outl-core
The crdt-invariant-checker agent runs the same gate from CI on PRs.
Proptest budget: PROPTEST_CASES
The property suites (outl-core/tests/property_based.rs, outl-md/tests/roundtrip.rs, and any future outl-sync-iroh convergence proptests) bake a low default case count via ProptestConfig::with_cases(200) so local runs stay fast.
PROPTEST_CASES is proptest’s built-in override of that number; set it to explore harder without touching the test files.
# Dev default (fast): 200 cases baked into the suites.
cargo test -p outl-core
# Explore the convergence space harder, locally:
PROPTEST_CASES=1024 cargo test -p outl-core -p outl-sync-iroh --all-targets
CI’s dedicated sync job (in ci.yml) sets PROPTEST_CASES=1024 for exactly this pair of crates.
That way the probabilistic convergence bugs (op reordering, cycle no-ops, concurrent moves) actually get generated cases on every PR.
That job is a required status check: a red convergence run blocks merge.
Keep the high budget on the sync job only — running 1024 across the whole test matrix burns runner minutes for no extra signal.
TDD for bug fixes
Bug → reproduce as a test that fails on main → patch turns it green.
# Find an existing similar test:
rg 'fn it_' crates/outl-core/tests/
# Add yours, run only that file:
cargo test --test crdt_convergence -- --nocapture
A bug fix without a regression test is a blocker in review.
What to mock and what not to
- Real
JsonlStoragewhen the test is about persistence, sync, or anything anops-*.jsonlwould touch. Usetempfile::TempDirfor the workspace root. MemoryStoragewhen the test is about the algorithm and the storage is incidental noise.- No mocks for the Tree CRDT.
Always replay through the real
do_op/undo_op. Mocking those is how you ship a sync bug. - HLC: prefer the real generator with a known actor id (
ActorId::from_u128(1)). Hard-coded timestamps creep into test assertions and break when you change the encoding.
Frontend tests
# From repo root:
bun install # first time
bun run test # vitest across every package that declares the script
bun run typecheck # tsc --noEmit across the same set
# Or per-package:
cd crates/outl-mobile && bun run test
cd crates/outl-frontend-shared && bun run test
Most shared helpers (looksLikeOutline, utf16OffsetToCharOffset, detectRefContext) have direct unit tests under crates/outl-frontend-shared/src/**.
New helpers go there, not under a client.
6. Cookbooks
Concrete walkthroughs for the changes contributors hit most often.
Add a new Op variant
- Run
/new-op <Variant>for the checklist. - Touch order:
Openum (outl-core/src/op.rs) →apply_op+undo_op(tree/mod.rs) → sidecar projection (if it carries metadata) → markdown rendering (if it’s visible) → unit + invariant tests → per-crate docs. - Invariants: the inverse must be exact (
apply_opthenundo_opis identity); cycle-creating moves remain a no-op on the tree but are still appended to the log; new variant carries an HLC + actor id. - Run
/check-invariants+/coverage outl-core. - Update
docs/crdt.mdif the op changes how the algorithm is described.
Add a TUI shortcut
- Add the chord to
crates/outl-shortcuts/src/. - Wire the handler in
crates/outl-tui/src/. - Add a test that asserts the action ran (against the workspace, not the internal handler).
- Update
docs/tui.md(key table) +docs/shortcuts.md(canonical chord list). - If desktop should mirror it, wire it on the
outl-desktopside too — the chord catalog is shared.
Add a shared workspace action
The rule from the root CLAUDE.md is: any operation more than one client needs lives in outl-actions before its first use.
- Add the function to the right module in
outl-actions/src/{block,collapsed,todo,page,journal}.rs. - Signature:
(&mut Workspace, &HlcGenerator, ...) -> Result<...>. - Routes every mutation through
Workspace::apply— no direct storage writes. - Add an integration test in
crates/outl-actions/tests/. - Wire the TUI / mobile / desktop calls in their respective crates.
- Add the function to the Shared primitives catalog (
docs/primitives-*.md+ mirror in.github/instructions/shared-primitives.instructions.md).
Add an MCP tool
- Add the handler in
crates/outl-cli/src/cmd/*.rsreturningResult<Value, ApiError>, then register it inmcp/tools/registry.rs(schema) andmcp/tools/dispatch.rs(dispatch) — mirror an existing tool. - Tool name:
outl_<verb>_<noun>(e.g.outl_block_append,outl_page_create). - Wire the underlying logic through
outl-actionsif it mutates state; throughoutl-mdindices if it’s a read. - Return the handler’s
Valueand letmcp/tools/payload.rswrap it. MCP does not use the CLI’s{ ok, data, error }envelope: a success reply is content-only (compact JSON incontent[].text, nostructuredContent), and only an error keeps the envelope instructuredContent(RFC 0276). Do not build the result shape in the dispatcher, and do not add anoutputSchemato the tool def — declaring one obliges the server to sendstructuredContenton success. Only add a tool topayload.rs’smarkdown_fieldif the caller already holds every field the flattening drops;crates/outl-cli/CLAUDE.md→ MCP has the test and the two ways it was got wrong. - Update
docs/mcp.mdwith the tool’s purpose, params, and an example invocation.
Add a theme
- Add the palette to
crates/outl-theme/src/presets/. - Register it in the preset enum.
- Update
docs/theming.md. - The TUI and desktop pick it up automatically — both read the same palette catalog.
Add a CLI subcommand
- New module under
crates/outl-cli/src/cmd/, mirroring an existing one. - Both human-readable and
--jsonoutput paths — the JSON envelope is documented indocs/cli.md. - Hook it into
clapin the main command dispatcher. - Update
docs/cli.md.
Touch the iOS native bridge (Swift / ObjC)
Decide which tier before opening the file:
- Can the logic be tested without UIKit? (string parsing, predicate, MFU ordering, color math, escape) → goes in
crates/outl-mobile/swift/OutlKit/Sources/OutlKit/<Module>/. Add a unit test alongside inswift/OutlKit/Tests/OutlKitTests/. Runswift testfromswift/OutlKit/. - Does it need a UIKit / Foundation runtime? (
UIView,BGTaskScheduler,NSMetadataQuery,NSFileCoordinator, method swizzle) → goes incrates/outl-mobile/src-tauri/gen/apple/Sources/outl-mobile/. AddNSLogprobes on entry / exit / error so the behavior is observable on the device console. No unit test today.
If you find yourself writing UIKit-shaped code inside OutlKit, stop — extract the pure part into OutlKit and keep the UIKit binding in gen/apple/.
The iCloud peer-file watcher is the canonical pattern: predicate logic lives in OutlKit/Watcher/OpsFilePredicate.swift (tested), the NSMetadataQuery driver lives in gen/apple/.../OutlOpsWatcher.swift (boot-time logged).
Always re-read crates/outl-mobile/CLAUDE.md § “Peer-file materialisation” before touching main.mm or the watcher — that section spells out the iCloud race that two lines of code prevent.
Add a code-block runtime (outl-exec)
- Add a
lang-<name>feature incrates/outl-exec/Cargo.toml. - Create
crates/outl-exec/src/runtimes/<name>.rs— one struct +impl Runtime. Useecho.rsas the simplest template, orruntimes/query/for the full pattern (workspace access + embed output + auto-run). - Register in
RuntimeRegistry::with_builtinsbehind the feature. - Add aliases to
KNOWN_ALIASESincrates/outl-md/src/lang.rsand the TS mirrorcrates/outl-frontend-shared/src/highlight/aliases.ts(same commit — thedoc-sync-guardhook checks this). - If the runtime returns
OutputFormat::Embeds, the orchestrator already handles it — no UI change needed. - If the runtime should auto-run, override
auto_run()to returntrue. - Document the DSL in
docs/markdown-format.md§ Query code blocks (or create a new section for non-query runtimes). - Read
crates/outl-exec/CLAUDE.mdfor the full surface and “what this crate does NOT own”.
7. Debugging
Common failure modes
| Symptom | Likely cause | Fix |
|---|---|---|
error[rustdoc::private_intra_doc_links]: public documentation for X links to private item Y | A [Foo] link in a doc comment to a pub(crate) type | Drop the brackets, keep the backticks |
cargo doc works locally but fails CI | RUSTDOCFLAGS="-D warnings" is only set in CI | Run RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps locally |
Sidecar { version: V1, ... } parse error | Old workspace, sidecar pre-v2 | The reader is backward-compatible; if it isn’t, that’s a regression — open an issue |
TUI shows blocks the .md doesn’t | Sidecar / .md desync; orphan log will tell you which | cat .outl/orphans.log for the unmatched block ids |
| Two devices show different trees after sync | HLC tiebreak missed, or an op was dropped from a log replay | Replay the ops in actor order with a custom binary (see below); also run /check-invariants |
Dump the op log
Each device’s log is a plain JSONL file under ops/:
cat ops/ops-<actor-uuid>.jsonl | jq .
cat ops/ops-<actor-uuid>.jsonl | jq -r '.timestamp + " " + .op.type' | head
To replay a log into a fresh MemoryStorage and inspect the materialized tree, write a tiny binary under xtask/ — there’s already a pattern in xtask/src/bin/gen-10k.rs.
Tracing
The libraries emit tracing spans at debug.
Run any binary with RUST_LOG to enable them:
RUST_LOG=outl_actions=debug,outl_core=debug cargo run -p outl-cli -- --workspace ./playground page list
RUST_LOG=outl_tui=debug,outl_md=info cargo run -p outl-tui -- --workspace ./playground
Spans of interest:
outl_actions::sync—SyncEnginework loop, peer detection.outl_core::log— replay, append, lock acquisition.outl_md::reconcile— 3-level matching decisions.
Doctor
cargo run -p outl-cli -- workspace doctor --workspace ./playground --json
Walks the workspace and reports: corrupted sidecars, orphan blocks, mismatched hashes, missing op log entries. Run this before assuming the bug is in your patch.
8. Performance
Hot paths (from contributing.md)
outl_core::tree— every op apply, every tree walk.outl_core::log— every append, every replay.outl_md::parse/render— every.mdread/write, every TUI buffer refresh.outl_md::index— backlink rebuild; scales with workspace size.outl_tuirender loop — runs on every keystroke.outl_actions::SyncEnginework loop — every file event.
Anything outside those is a correctness conversation, not a perf one.
Running benches locally
The criterion suite lives under crates/outl-md/benches/:
# Whole suite (small + medium + large fixtures, sub-second each):
cargo bench -p outl-md
# Single bench:
cargo bench -p outl-md --bench parse
cargo bench -p outl-md --bench index -- "medium_"
# 10k-file xlarge (slow — minutes, not seconds):
cargo bench -p outl-md --bench index -- \
--warm-up-time 2 --measurement-time 10 --sample-size 10 "xlarge_"
Criterion writes target/criterion/<bench>/report/index.html.
Open it in a browser to compare a baseline against your change.
End-to-end CLI bench
xtask/src/bin/gen-10k.rs builds a 10k-page payload; hyperfine measures CLI wall-clock.
The exact recipe lives in .github/workflows/bench.yml bench-cli-xlarge if you want to reproduce locally.
Composite-write diagnostic bench
crates/outl-actions/tests/composite_write_bench.rs is an #[ignore]d, non-criterion test.
It times the two write shapes the MCP / batch surface uses most (page create + forest + properties, full page replace) with and without an outer Workspace::begin_batch().
It’s a diagnostic baseline, not a CI gate — run it manually when touching the batch persistence path:
cargo test -p outl-actions --release --test composite_write_bench -- --ignored --nocapture
9. CI walkthrough
| Workflow | Triggers | What it runs | Blocks merge? |
|---|---|---|---|
ci.yml | Push / PR to main (skipped on docs-only) | cargo fmt --check, cargo clippy -D warnings, cargo test, cargo doc -D warnings, an advisories job (cargo deny --locked check advisories against deny.toml: any RustSec vulnerability fails, the whole graph is checked for unmaintained and unsound crates too, and every ignore carries a reason), plus a dedicated sync job (outl-core + outl-sync-iroh with PROPTEST_CASES=1024), and a frontend job (bun run test + bun run typecheck over every bun workspace package). Excludes outl-mobile + outl-desktop from the Rust jobs. Test matrix: test (linux) + test (macos). | Yes |
hygiene.yml | Push / PR to main, including docs-only changes | scripts/check-file-size.sh (the file size ratchet), scripts/check-docs-index.sh (every RFC reachable from docs/SUMMARY.md), scripts/tests/test_check_artifact_stripped.py (the strip gate’s own suite), and scripts/check-release-assets.py --manifest plus its suite (the three build matrices, update_tap’s loops, the Homebrew formula, the cask and the Scoop manifest all name the platforms scripts/release_assets.py names). Kept out of ci.yml on purpose: that workflow skips docs/**, which is exactly the change set the index check has to see. | Yes |
mobile.yml | Push / PR touching mobile paths | Frontend tests, Swift tests, Rust mobile crate, iOS archive + sign on push | Mobile changes only |
desktop.yml | Push / PR touching desktop paths | Tauri build matrix (macOS/Linux/Windows) | Desktop changes only |
bench.yml | Push / PR touching outl-md, plus weekly cron | Criterion (small/medium/large) on every PR; xlarge + CLI hyperfine on cron / manual dispatch. Artifacts retained 14–30 days. | No (informational) |
release.yml | Push to main (beta), v* tag (GA), manual | Computes version from Cargo.toml, builds the CLI + TUI matrix (macOS arm64/x86_64, Linux x86_64/aarch64, Windows x86_64/aarch64), builds the desktop bundles (universal macOS dmg, Linux AppImage/deb/rpm and a Windows NSIS installer per architecture), the Android APK, drafts release, uploads assets, gates the publish on scripts/release_assets.py, bumps the Homebrew formula + cask and the Scoop manifest (bucket/outl-beta.json), publishes @outl/plugin-sdk to npm and the embedder lib crates to crates.io. | n/a |
testflight.yml | Mobile workflow completing successfully | Downloads the signed .ipa, uploads to App Store Connect via xcrun altool, sets “What to Test” notes via App Store Connect API. | n/a |
docker.yml | Push to main / v* tag, PR touching Dockerfile / docker/**, manual | Builds ghcr.io/<owner>/outl-server once per architecture on a runner of that architecture (linux/amd64, linux/arm64), pushes each by digest, merges them into one manifest list per tag, and attests that list through Sigstore (gh attestation verify oci://…). A PR builds and pushes nothing. | No |
cleanup-tags.yml | Cron | Garbage-collects stale beta tags. | n/a |
Action pinning
Every uses: points at a full commit SHA with the release tag in a comment (actions/checkout@<sha> # v7.0.1), so a moved or hijacked tag can’t change what runs next to the release secrets.
Don’t hand-edit a SHA back to a tag: .github/dependabot.yml bumps the GitHub Actions pins weekly in one grouped PR, along with Cargo and Bun.
To pin a new action, resolve the tag with gh api repos/<owner>/<repo>/git/ref/tags/<tag> and, when that returns a tag object, follow it to the commit.
Runner sizing
Every workflow runs on Blacksmith runners (runs-on: blacksmith-<n>vcpu-<image>, with -arm appended for ARM64 Linux), never on GitHub-hosted ones.
The one exception is release.yml::publish_npm, which runs on ubuntu-24.04: npm trusted publishing (OIDC) does not accept self-hosted runners yet, and Blacksmith registers as self-hosted.
Moving it back to Blacksmith breaks the npm publish.
Its trusted publisher on npmjs.com (@outl/plugin-sdk → Settings → Trusted publishing) must stay configured as owner outlmd, repository outl, workflow release.yml, environment release, with publishing allowed.
Billing is per vCPU-minute, with a platform multiplier on top: ARM 0.625x, x64 Linux 1x, Windows 2x, macOS 6.67x.
That multiplier is why job placement matters more than job speed: a 29s macOS job costs more than a 5min Linux one.
Two rules follow from it, and both are already applied in the workflows:
- Only platform-specific work runs on macOS or Windows.
Anything that produces the same result on Linux runs on Linux, once.
mobile.yml::rust-checkbuildsoutl-mobileonly for this reason —outl-actions/outl-core/outl-mdare covered byci.yml::test, and running their suites again on macOS was 55% of a full run’s bill. - Size the SKU from measured CPU and memory, not from intuition.
blacksmith jobs stats <job_id>prints the per-job CPU timeseries, memory peak and OOM count;blacksmith usage --breakdown-by workflow_jobprints what each job costs.
Sizes as of the Blacksmith migration, from a full run of every workflow:
| Job | SKU | Why |
|---|---|---|
ci.yml::fmt, ci.yml::advisories | 2 vCPU | rustfmt and cargo-deny compile nothing (16s for fmt) |
mobile.yml::frontend, ci.yml::frontend, hygiene.yml::hygiene | 2 vCPU | vitest + Vite build, 12s, never saturated 4 vCPU; the other two are ~4s of vitest and a line-count scan, with no compilation at all |
release.yml orchestration (prepare, tag, create_release, publish_* except publish_npm, update_tap), cleanup-tags.yml | 2 vCPU | shell + gh calls, no compilation |
ci.yml::docs, ci.yml::sync, bench.yml | 4 vCPU | doc/proptest jobs already finish in ~100s; bench stays fixed so numbers remain comparable run over run |
ci.yml::clippy, ci.yml::test (linux), desktop.yml::check, release builds | 8 vCPU | measured 71–76% average CPU on 4 vCPU with 2.6–4.7 GB of 16 GB used and zero OOM: CPU-bound, so more cores cut wall clock at roughly flat billing |
| macOS jobs | 6 vCPU | smallest macOS tier Blacksmith offers |
release.yml Linux legs (CLI x86_64/aarch64, desktop x86_64/aarch64), docker.yml | 8 vCPU | same CPU-bound release build on either architecture; the ARM legs use the -arm suffix of the same SKU, billed at 0.625x |
The Linux release legs are pinned to ubuntu-2204, not 2404, and that is not a cost decision.
A glibc-linked binary runs on the glibc it was built against or newer, never older, so the image the artifact is built on is its compatibility floor: 22.04 is glibc 2.35 (Debian 12, every supported Ubuntu LTS), 24.04 is 2.39 (and nothing older than itself).
build_desktop_linux was pinned for this from the start and said so; the CLI leg was built on 24.04 until #411, which made outl-linux-x64.tar.gz die with version GLIBC_2.38 not found on exactly the distros where someone downloads a tarball instead of using a package manager.
Bumping either to 24.04 raises that floor for everyone; do it on purpose, or not at all.
Check names must not contain the runner SKU.
Branch protection matches required checks by name, so test (ubuntu-latest) becoming test (blacksmith-8vcpu-ubuntu-2404) leaves main blocked on a check that will never report again.
Matrix jobs therefore carry a stable label (linux, macos, macOS arm64, …) and the SKU lives only in matrix.os.
What blocks merge
The ci.yml jobs are the merge gate.
The PR template and the policy in docs/contributing.md describe everything else reviewers look at on top of CI.
Debugging a red CI
- Read the failing job’s “Annotations” at the top of the PR check page — it usually points at the file + line.
- Reproduce locally with the exact command the workflow ran (the YAML is the source of truth).
- If
cargo docfails withrustdoc::private_intra_doc_links, see the doc gotcha above. - If clippy fails on a target you don’t have (e.g. you’re on Linux and it’s the macOS leg), check that the failure isn’t environmental before suspecting your patch.
outl-mobileandoutl-desktopare explicitly excluded fromci.yml’s clippy job; if you somehow re-included them, that’s the bug.
Flakes
Treat a flaky test as a real bug. The CRDT and parser have no inherent flakiness — if a test fails twice and passes the third time, it’s hiding a race or a non-deterministic order in something we control. Don’t add retries; find the cause.
10. Release process
Version source of truth
[workspace.package].version in the root Cargo.toml.
Crate manifests inherit via version.workspace = true.
Bumping the workspace bumps everything.
crates/outl-mobile/src-tauri/tauri.conf.json deliberately omits version; CI reads Cargo.toml and injects the value into cargo tauri ios build via --config.
This is non-negotiable — see crates/outl-mobile/CLAUDE.md § “Versioning + TestFlight release”.
Beta cadence
Every push to main produces a beta release automatically:
- Tag:
v<workspace.version>-beta.<run_number>(e.g.v0.6.0-beta.48). - Binary: reports the full beta version (the workflow
sedsCargo.tomlin-place beforecargo build; the change is local to the runner). - GitHub: published as a prerelease with auto-generated release notes.
- Homebrew tap (
Formula/outl-beta.rb,Casks/outl-desktop-beta.rb) bumped automatically with[skip ci]commit.
GA
Bump workspace.package.version (e.g. 0.6.0 → 0.7.0), merge to main, then push a v0.7.0 tag by hand.
The tags: ["v*"] trigger in release.yml picks it up.
TestFlight (iOS)
mobile.yml builds + signs the IPA on every push.
testflight.yml runs after mobile.yml completes, downloads the IPA artifact, uploads to App Store Connect.
Release notes (“What to Test”) come from conventional-changelog-cli (preset conventionalcommits) reading the commit log since the last tag.
Use Conventional Commits.
A commit without a feat: / fix: / chore: prefix lands under a generic “Other changes” bucket — the user loses context.
iOS version propagation and TestFlight
Single source of truth: Cargo.toml workspace version.
To bump the app version, edit [workspace.package].version at the repo root — everywhere else inherits:
| Field | Where it lives | How it’s resolved |
|---|---|---|
| Rust crate version | crates/outl-mobile/src-tauri/Cargo.toml | version.workspace = true |
| Tauri config version | crates/outl-mobile/src-tauri/tauri.conf.json | Field intentionally omitted in the source; CI injects it via cargo tauri ios build --config '{"version": "<short>"}' |
CFBundleShortVersionString | iOS Info.plist | Tauri propagates from --config during cargo tauri ios build |
MARKETING_VERSION / CURRENT_PROJECT_VERSION | gen/apple/.../project.pbxproj | Same — Tauri regenerates from the merged config every build |
Why --config and not Tauri’s Cargo.toml fallback?
The iOS code path doesn’t honor that fallback — it uses 1.0.0 instead.
So CI reads the workspace version (awk against Cargo.toml in Compute build metadata) and passes it via --config; the Patch archive CFBundleVersion step aborts if the propagated short version mismatches.
Never put "version": "x.y.z" back in tauri.conf.json — Tauri would then use the static value over the --config override and the two drift on the next bump.
CI release flow
A push to main triggers in parallel:
Release(release.yml) — auto-bumpsCargo.tomllocally to<base>-beta.<run_number>, cuts av<...>-beta.<N>tag, builds desktop binaries, ships the Homebrew formula (never commits the bump back).Mobile(mobile.yml) — builds the signed IPA from the unbumpedCargo.toml, uploads it asoutl-ios-release, triggersTestFlight.TestFlight(testflight.yml) — downloads the artifact, uploads to App Store Connect (xcrun altool).
CFBundleVersion (build number) scheme
Apple needs CFBundleVersion strictly monotonic across every IPA, but the marketing version (0.5.1) repeats across beta builds.
Scheme: CFBundleShortVersionString = <SHORT_VERSION> (e.g. 0.5.1); CFBundleVersion = <SHORT_VERSION><BETA_PAD> (e.g. 0.5.1027, beta number zero-padded to 3 digits).
BETA comes from the latest v<SHORT_VERSION>-beta.<N> git tag (set by Release), falling back to Mobile’s github.run_number; re-runs append .<run_attempt> as a 4th component to dodge Apple’s duplicate guard.
The build number is patched into the .xcarchive’s embedded Info.plist after cargo tauri ios build but before xcodebuild -exportArchive — the only injection point that survives, since Tauri exposes a single version field.
What goes wrong if you forget this
- Patching
gen/apple/.../Info.plistpre-build is a no-op (Tauri regenerates it);xcrun altoolexits 0 even on 409, sotestflight.ymlgreps forERROR:, don’t simplify it.
Homebrew
The tap is at the root of this same repo (Formula/, Casks/).
The update_tap job in release.yml patches version + sha256 anchors for both the CLI formula and the desktop cask after every beta release.
The desktop dmg is unsigned today (Apple Developer account pending).
The cask carries a caveats block with the xattr -dr com.apple.quarantine workaround.
crates.io (lib crates for embedders)
The publish_crates job in release.yml publishes outl-core, outl-md, outl-actions, outl-ws and outl-exec to crates.io on every release, beta and GA, at the same version the binaries report.
The first four are the embedding contract; outl-exec ships only because outl-actions references it and cargo refuses to publish a crate whose dependencies aren’t in the registry.
Every other crate in the workspace carries publish = false; flipping one later is a one-line change (plus adding it to the job’s publish loop).
SemVer keeps consumers safe: a plain outl-core = "0.11" requirement never resolves a -beta.N prerelease, so crates.io consumers only see GA versions unless they opt in with a "0.11.0-beta"-style requirement.
The job is idempotent (it checks crates.io before each publish, so re-runs resume instead of failing) and self-gating (cargo publish verifies each crate by building it).
It authenticates with the CARGO_REGISTRY_TOKEN secret in the release environment.
11. Where to ask
- Bugs: GitHub issues with the bug report template.
- Feature requests: GitHub issues with the feature template.
- Security: SECURITY.md. Do not open a public issue.
- Design discussion: open an issue with the
discussionlabel, or a draft PR labeledRFC. - Direct contact: the project maintainer is @avelino.
Welcome aboard. Read contributing.md before opening a PR — it’s the policy this dev guide is the workflow for.