Releasing mbr

Releasing mbr

Pushing a vX.Y.Z tag builds, signs, notarizes and publishes mbr’s macOS DMG (and the Linux/Windows tarballs, the crate, and the Homebrew cask). .github/workflows/release.yml is the whole pipeline; this document is the part that cannot live in a comment — the one-time Apple setup, and what to do when notarization says no.

What ships, and where signing happens

One DMG, mbr-macos-arm64.dmg (mbr is Apple Silicon-only — see the build job’s comment on why the Intel leg was dropped), built by scripts/make-macos- dmg.sh in the package-dmg job and attached to the GitHub Release alongside the Linux/Windows tarballs and the raw mbr-macos-arm64.tar.gz/mbr-cli-macos- arm64.tar.gz archives.

Only the DMG is really signed. MBR.app is ad-hoc signed inside the Nix sandbox at build time (flake.nix, both the mbr-quicklook/mbr derivations and the release derivation) — that signing is hermetic and secret-free by design, and stays that way; see docs/reference/development.md’s “Code signing: never strip an ad-hoc signature” section for why it must never be removed. scripts/make-macos-dmg.sh re-signs the bundle on the CI runner (outside the Nix sandbox, where codesign can actually reach its daemon) immediately before wrapping it in the DMG, and that re-sign is where a real Developer ID identity, hardened runtime, notarization and stapling happen — for both the .app and the .dmg. The .tar.gz app bundle is never touched by that script and always ships ad-hoc signed only; a user who prefers the tarball over the DMG still needs the xattr -dr com.apple.quarantine / right-click-Open workaround.

Why the .app is signed and notarized before the DMG is built

Stapling the DMG alone (the common shortcut) leaves the app itself ticketless, so a user who drags it to /Applications and launches it offline gets a Gatekeeper failure the first time. Two notarization round trips — the app, then the DMG — is the price of the app working without a network.

Signing order is innermost-out

scripts/make-macos-dmg.sh signs Contents/Frameworks/libpdfium.dylib, then Contents/PlugIns/MBRPreview.appex (with its entitlements — apple/quicklook/MBRPreview/MBRPreview.entitlements), then MBR.app itself. A signature over the bundle seals its contents, so signing a nested binary afterwards would invalidate the outer one. --deep is deprecated for signing on macOS 13+ (it silently applies the same options to nested code and misses anything it does not recognise) so each Mach-O is signed explicitly; a future addition to the bundle has to be added to that script by name, which is the right failure mode — an unsigned nested binary fails notarization with a report that names it, rather than shipping quietly unsigned.

Ad-hoc fallback, and the dry_run input

scripts/make-macos-dmg.sh signs for real only when both IDENTITY and KEYCHAIN are set in its environment. Empty (the default) reproduces the script’s original, secret-free behavior exactly: ad-hoc signing only, no notarization, no network calls — a DMG that opens with Gatekeeper’s recoverable “Open Anyway” flow rather than notarized-and-silent.

The release workflow’s workflow_dispatch trigger has a dry_run boolean input, default true, mirroring the same input on ledgeline’s release workflow (the sibling project this pipeline was ported from). With dry_run true — or omitted — the whole pipeline builds and packages exactly as a real release does, using the ad-hoc fallback above, and stops before the GitHub Release and crates.io publish steps (validate-version’s sign output gates package-dmg’s signing steps and the release/publish-crate jobs; see the comments on that output and on those jobs). Setting dry_run: false on a manual dispatch signs for real, exactly like a tag push does — useful for re-issuing a release without cutting a new tag, or for a full signing dry run before trusting a real one.

Use a dry_run dispatch whenever the packaging changes. It is the only way to exercise the DMG layout, and the ad-hoc-signing fallback path, without holding the signing secrets or spending a tag.

Cutting a release

./scripts/bump-version.sh 0.7.0   # rewrites the root Cargo.toml, updates Cargo.lock, runs benchmarks
git diff                          # review
git add -A && git commit -m "Release v0.7.0"
git tag v0.7.0
git push origin main v0.7.0       # pushing the tag starts the release

validate-version (the workflow’s first real job) fails the run if the tag does not match Cargo.toml’s version, so a mistagged push fails loudly there rather than shipping a DMG whose About panel disagrees with its own filename.

mbr is a Cargo workspace, and the version lives once, in the root Cargo.toml’s [workspace.package], which every crate inherits. That table is the first thing in the file on purpose: both bump-version.sh and validate-version read the first ^version = line. The internal crates are also pinned to each other (mbr-core = { path = …, version = "=X.Y.Z" } in [workspace.dependencies]), and bump-version.sh rewrites those pins in the same pass. Nothing else carries the version: flake.nix reads workspace.package.version, and mbr --version is CARGO_PKG_VERSION.

One-time Apple setup

Five repository secrets, added under Settings → Secrets and variables → Actions on zmre/mbr-markdown-browser. You need an Apple Developer Program membership ($99/yr) — reuse the same Developer ID certificate and App Store Connect API key already provisioned for ledgeline; nothing here requires a second enrollment.

The signing certificate

MACOS_CERTIFICATE_P12, MACOS_CERTIFICATE_PASSWORD

You want a Developer ID Application certificate — not “Mac App Distribution”, which only works for the App Store and will fail notarization for a directly distributed app.

  1. In Xcode: Settings → Accounts → your team → Manage Certificates → + → Developer ID Application. (Or create the CSR by hand at https://developer.apple.com/account/resources/certificates.)

  2. In Keychain Access, find the certificate, expand it so both the cert and its private key are selected, right-click → Export 2 items…, save as .p12, and set a strong password. Both halves matter: a .p12 exported without the private key imports fine and then fails at codesign with “no identity found”.

  3. Base64 it, and put the result in MACOS_CERTIFICATE_P12:

    base64 -i DeveloperID.p12 | pbcopy
    
  4. Put the export password in MACOS_CERTIFICATE_PASSWORD.

The workflow derives the signing identity string from the certificate itself (security find-identity -v -p codesigning), so there is no sixth secret to keep in sync — a hand-typed “Developer ID Application: …” string would drift from the actual cert and fail with an unhelpful “no identity found”.

The notarization key

APPLE_API_KEY_P8, APPLE_API_KEY_ID, APPLE_API_ISSUER_ID

An App Store Connect API key, rather than an Apple ID and app-specific password: it does not break when 2FA prompts, it is scoped, and it is revocable on its own.

  1. At https://appstoreconnect.apple.com/access/integrations/api, create a key with the Developer role (that is sufficient for notarization).
  2. Download AuthKey_XXXXXXXXXX.p8. Apple lets you download it exactly once.
  3. APPLE_API_KEY_P8 — base64 -i AuthKey_XXXXXXXXXX.p8 | pbcopy
  4. APPLE_API_KEY_ID — the ten-character Key ID (the XXXXXXXXXX above).
  5. APPLE_API_ISSUER_ID — the UUID shown above the key list on that page. It is per-team, not per-key.

Verifying by hand

scripts/make-macos-dmg.sh asserts all of this itself before finishing (when signing for real), but when something looks wrong on a downloaded DMG:

# The ticket is stapled and valid offline.
xcrun stapler validate mbr-macos-arm64.dmg

# The string that matters is `source=Notarized Developer ID`. Anything else
# means a user gets a Gatekeeper prompt.
spctl --assess --type exec -vvv /Applications/MBR.app

# Every load command resolves on a stock Mac -- no /nix/store.
otool -L /Applications/MBR.app/Contents/MacOS/mbr

When notarization fails

scripts/notarize.sh (called once for the .app, once for the .dmg) prints the full notary log and fails loudly on anything but an Accepted verdict. Common causes:

Symptom in the logCauseFix
The signature does not include a secure timestamp--timestamp missing, or Apple’s timestamp server was unreachableRe-run; it is usually transient
The executable does not have the hardened runtime enabledA Mach-O got signed without --options runtimeAdd it to sign() in scripts/make-macos-dmg.sh
The binary is not signed naming a path under Contents/A new nested binary was added to the bundle but not to the sign stepAdd it to scripts/make-macos-dmg.sh explicitly, on purpose (see “Signing order” above)
Team is not yet configured for notarizationNew membership, agreements not acceptedAccept the current agreements in App Store Connect

Entitlements. The main app needs none: it is not sandboxed, and wry drives WKWebView, whose JIT lives in the out-of-process com.apple.WebKit.WebContent system service rather than in mbr’s own address space, so com.apple.security.cs.allow-jit and its relatives are not needed. MBRPreview.appex (the QuickLook extension) does carry an entitlement — a broad, deliberately-scoped-in-code temporary-exception file-read grant; see the comment in apple/quicklook/MBRPreview/MBRPreview.entitlements for why it is that wide and what actually constrains it. If notarization ever rejects either for entitlement reasons, that is the first thing to revisit.

Publishing to crates.io

publish-crate runs after the GitHub Release, gated (via needs: release) the same way the release itself is — a dry run publishes nothing. It uses CARGO_REGISTRY_TOKEN, which predates this pipeline and is unrelated to the five Apple secrets above.

It runs cargo publish --workspace, which publishes all five crates in dependency order — mbr-core, then mbr-server, mbr-ssg and mbr-ffi, then mbr-markdown-browser — waiting for each to reach the index before the next. mbr-markdown-browser depends on the others by version, so it cannot be published alone. The first release after the workspace split creates the four member crates on crates.io, so the token must be allowed to publish new crates (the publish-new scope), not only update mbr-markdown-browser. A re-run after a partial failure stops at the first crate whose version is already published; publish the rest with cargo publish -p <crate> in the order above.

Updating the Homebrew cask

The Homebrew tap is this repository: Casks/mbr.rb on main is what brew update reads. After every stable release the update-homebrew-cask job points the cask at the new DMG and pushes the change to main as Homebrew cask: mbr X.Y.Z [skip ci].

The job:

The cask always uses the DMG, never mbr-macos-arm64.tar.gz, because only the DMG’s app is notarized.

ci.yml’s cask-lint job runs brew style/brew audit and smoke-tests scripts/update-cask.sh whenever either file changes.

One-time setup

main requires pull requests, and GITHUB_TOKEN cannot bypass that. The job pushes with a deploy key instead, which a repository ruleset can exempt. Classic branch protection cannot, so it has to be replaced by a ruleset.

  1. Generate a key pair and add the public half as a deploy key with write access:

    ssh-keygen -t ed25519 -N "" -C "mbr homebrew cask bot" -f mbr-cask-deploy-key
    gh repo deploy-key add mbr-cask-deploy-key.pub \
      --repo zmre/mbr-markdown-browser --allow-write --title "Homebrew cask bot"
    
  2. Store the private half as the HOMEBREW_CASK_DEPLOY_KEY Actions secret, then delete both files:

    gh secret set HOMEBREW_CASK_DEPLOY_KEY \
      --repo zmre/mbr-markdown-browser < mbr-cask-deploy-key
    rm mbr-cask-deploy-key mbr-cask-deploy-key.pub
    
  3. Add Deploy keys to the bypass list of the protect main ruleset (Settings → Rules → Rulesets), which already requires a PR and the Clippy check. Then remove the classic branch protection on main: it requires a PR too, and classic protection has no deploy-key exemption, so the push stays blocked while it exists. Nothing is lost, since protect main and Copilot review for default branch together cover everything it enforced (PR required, no force pushes, no deletion).

    gh api -X PUT repos/zmre/mbr-markdown-browser/rulesets/19498799 --input - <<'JSON'
    {"bypass_actors": [{"actor_type": "DeployKey", "bypass_mode": "always"}]}
    JSON
    gh api -X DELETE repos/zmre/mbr-markdown-browser/branches/main/protection
    

    A deploy key bypasses every rule in that ruleset, including the required status check, which is why the job verifies the cask itself before pushing.

Fixing the cask by hand

If the job fails, or a release needs its cask repaired, update it on a branch and open a PR:

VERSION=0.7.0
SHA=$(gh release download "v$VERSION" --repo zmre/mbr-markdown-browser \
  -p SHA256SUMS -O - | awk '$2 == "mbr-macos-arm64.dmg" { print $1 }')
scripts/update-cask.sh "$VERSION" "$SHA"
git diff Casks/mbr.rb