diff --git a/.github/actions/apple-credentials/action.yml b/.github/actions/apple-credentials/action.yml new file mode 100644 index 0000000..0246c0a --- /dev/null +++ b/.github/actions/apple-credentials/action.yml @@ -0,0 +1,53 @@ +name: Apple credentials +description: >- + Decide whether this run can sign for macOS and, when it can, put the App Store + Connect .p8 on disk so notarytool and tauri-action can find it. + +inputs: + certificate: + description: Base64 of the Developer ID Application .p12 (secrets.APPLE_CERTIFICATE) + required: false + default: "" + api-key-p8: + description: Base64 of the App Store Connect AuthKey .p8 (secrets.APPLE_API_KEY_P8) + required: false + default: "" + required: + description: >- + "true" on a tagged release, where missing credentials are a hard failure + rather than a reason to fall back to an unsigned build. + required: false + default: "false" + +outputs: + sign: + description: '"true" when both credentials are present and signing should run' + value: ${{ steps.check.outputs.sign }} + +runs: + using: composite + steps: + - id: check + shell: bash + env: + CERTIFICATE: ${{ inputs.certificate }} + API_KEY_P8: ${{ inputs.api-key-p8 }} + REQUIRED: ${{ inputs.required }} + run: | + set -euo pipefail + if [ -z "$CERTIFICATE" ] || [ -z "$API_KEY_P8" ]; then + if [ "$REQUIRED" = "true" ]; then + echo "::error::A tagged release must ship signed macOS artifacts, but APPLE_CERTIFICATE and/or APPLE_API_KEY_P8 are missing. See docs/macos-signing.md." + exit 1 + fi + echo "sign=false" >> "$GITHUB_OUTPUT" + echo "No Apple credentials — this run produces unsigned macOS artifacts." + exit 0 + fi + key_path="$RUNNER_TEMP/AuthKey.p8" + umask 077 + printf '%s' "$API_KEY_P8" | base64 --decode > "$key_path" + # tauri-action reads APPLE_API_KEY_PATH from the environment, and + # scripts/macos-notarize.sh passes it to notarytool as --key. + echo "APPLE_API_KEY_PATH=$key_path" >> "$GITHUB_ENV" + echo "sign=true" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b1fc455..5d49450 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,6 +34,11 @@ jobs: components: clippy, rustfmt - uses: Swatinem/rust-cache@v2 - run: cargo fmt --all --check + # src-tauri declares the CLI as an externalBin, and its build script fails + # while that file is missing — which `cargo test --workspace` hits, because + # src-tauri is in this workspace. dev, not release-cli: the test run reuses + # the debug artifacts instead of paying for a second full compile. + - run: bash scripts/build-sidecar.sh x86_64-unknown-linux-gnu dev - run: cargo test --workspace - run: cargo clippy --workspace --all-targets -- -D warnings - run: cargo build -p mandalo-cli @@ -84,7 +89,8 @@ jobs: run: | sudo apt-get update && sudo apt-get install -y shellcheck shellcheck -s sh scripts/install.sh - shellcheck scripts/package-cli.sh scripts/release-notes.sh scripts/sync-packaging.sh + shellcheck scripts/package-cli.sh scripts/release-notes.sh scripts/sync-packaging.sh \ + scripts/macos-sign.sh scripts/macos-notarize.sh scripts/build-sidecar.sh - name: The installer the site serves is the installer in the repo run: | diff -u scripts/install.sh landing/install.sh || diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e216885..9fc4040 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -24,18 +24,23 @@ jobs: - label: macOS (Apple silicon) platform: macos-14 args: --target aarch64-apple-darwin + sidecar: aarch64-apple-darwin - label: macOS (Intel) platform: macos-14 args: --target x86_64-apple-darwin + sidecar: x86_64-apple-darwin - label: macOS (universal) platform: macos-14 args: --target universal-apple-darwin + sidecar: universal-apple-darwin - label: Linux platform: ubuntu-22.04 args: "" + sidecar: x86_64-unknown-linux-gnu - label: Windows platform: windows-latest args: "" + sidecar: x86_64-pc-windows-msvc runs-on: ${{ matrix.platform }} steps: - uses: actions/checkout@v4 @@ -43,7 +48,8 @@ jobs: if: runner.os == 'Linux' run: | sudo apt-get update - sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf + sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf \ + pkg-config libssl-dev cmake - uses: pnpm/action-setup@v4 with: version: 10 @@ -56,18 +62,66 @@ jobs: targets: aarch64-apple-darwin,x86_64-apple-darwin - uses: Swatinem/rust-cache@v2 - run: pnpm install --frozen-lockfile + # The bundle carries the CLI as a Tauri sidecar, which is what lets the + # Homebrew cask link one `mandalo` out of the installed .app. Without this + # step `tauri build` fails: externalBin is declared in tauri.conf.json. + - name: Stage the CLI sidecar + shell: bash + run: bash scripts/build-sidecar.sh ${{ matrix.sidecar }} + - name: Apple credentials + id: apple + if: runner.os == 'macOS' + uses: ./.github/actions/apple-credentials + with: + certificate: ${{ secrets.APPLE_CERTIFICATE }} + api-key-p8: ${{ secrets.APPLE_API_KEY_P8 }} + required: ${{ startsWith(github.ref, 'refs/tags/v') }} + # tauri-action signs the .app and .dmg from APPLE_CERTIFICATE and notarizes + # the .app from the APPLE_API_* trio; with none of them set it just builds + # unsigned. It does NOT notarize the .dmg — see the step after it. - uses: tauri-apps/tauri-action@v0 if: startsWith(github.ref, 'refs/tags/v') env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} + APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} with: tagName: ${{ github.ref_name }} releaseName: "Mándalo ${{ github.ref_name }}" releaseDraft: true updaterJsonPreferNsis: true args: ${{ matrix.args }} + # Tauri notarizes and staples the .app but leaves the .dmg carrying it + # merely signed, and Gatekeeper assesses the downloaded .dmg on its own: + # `spctl -a -t open` on it reports "Unnotarized Developer ID" and refuses + # to open it, however notarized the app inside is. So notarize the disk + # image too and put the stapled copy back on the release, before the + # publish job hashes what is there into SHA256SUMS. + - name: Notarize and staple the DMG + if: steps.apple.outputs.sign == 'true' && startsWith(github.ref, 'refs/tags/v') + shell: bash + env: + APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} + APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + shopt -s nullglob + dmgs=(target/*/release/bundle/dmg/*.dmg) + if [ ${#dmgs[@]} -eq 0 ]; then + echo "::error::tauri-action produced no .dmg for ${{ matrix.label }}" + exit 1 + fi + for dmg in "${dmgs[@]}"; do + bash scripts/macos-notarize.sh "$dmg" + spctl -a -t open --context context:primary-signature -v "$dmg" + gh release upload "${{ github.ref_name }}" "$dmg" --clobber + done # workflow_dispatch has no tag to release into, so it only proves it builds. - uses: tauri-apps/tauri-action@v0 if: ${{ !startsWith(github.ref, 'refs/tags/v') }} @@ -108,6 +162,28 @@ jobs: key: ${{ matrix.target }} - name: Build run: cargo build --profile release-cli -p mandalo-cli --target ${{ matrix.target }} + - name: Apple credentials + id: apple + if: runner.os == 'macOS' + uses: ./.github/actions/apple-credentials + with: + certificate: ${{ secrets.APPLE_CERTIFICATE }} + api-key-p8: ${{ secrets.APPLE_API_KEY_P8 }} + required: ${{ startsWith(github.ref, 'refs/tags/v') }} + # Signing has to happen before packaging: the tar.gz must carry the signed + # binary, and notarytool cannot read a tar.gz anyway. + - name: Sign and notarize the binary (macOS) + if: steps.apple.outputs.sign == 'true' + env: + APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} + APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} + run: | + binary=target/${{ matrix.target }}/release-cli/mandalo + bash scripts/macos-sign.sh "$binary" + bash scripts/macos-notarize.sh "$binary" - name: Package id: pkg shell: bash @@ -178,6 +254,27 @@ jobs: - name: Fetch or build the CLI working-directory: editor-extension run: node scripts/fetch-cli.mjs --target ${{ matrix.target }} --build + - name: Apple credentials + id: apple + if: startsWith(matrix.target, 'darwin-') + uses: ./.github/actions/apple-credentials + with: + certificate: ${{ secrets.APPLE_CERTIFICATE }} + api-key-p8: ${{ secrets.APPLE_API_KEY_P8 }} + required: ${{ startsWith(github.ref, 'refs/tags/v') }} + # The binary inside the VSIX is the one that actually runs on the user's + # machine, so it needs the same Developer ID signature as the standalone CLI. + - name: Sign and notarize the bundled CLI (macOS) + if: steps.apple.outputs.sign == 'true' + env: + APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} + APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} + run: | + bash scripts/macos-sign.sh editor-extension/bin/mandalo + bash scripts/macos-notarize.sh editor-extension/bin/mandalo - name: The VSIX carries the binary it claims to if: matrix.target != 'universal' working-directory: editor-extension diff --git a/.gitignore b/.gitignore index 81970ba..f75dcfa 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,9 @@ dist-ssr # Rust workspace build output /target/ +# CLI sidecar staged into the desktop bundle (scripts/build-sidecar.sh) +/src-tauri/binaries/ + # Built browser app + its generated script worker (produced by CI, see landing.yml) /landing/app/ /src/lib/web/public/ diff --git a/docs/macos-signing.md b/docs/macos-signing.md new file mode 100644 index 0000000..d5801ce --- /dev/null +++ b/docs/macos-signing.md @@ -0,0 +1,101 @@ +# macOS signing and notarization + +Unsigned builds on macOS open to *"Mándalo is damaged and can't be opened"*. The +release workflow fixes that by signing everything it ships with a **Developer ID +Application** certificate and notarizing it with Apple: + +| Artifact | Signed by | Ticket stapled | +| --- | --- | --- | +| `Mandalo_*.dmg`, `mandalo.app` | `tauri-action` | yes | +| `mandalo-v*-*-apple-darwin.tar.gz` (CLI) | `scripts/macos-sign.sh` | no — bare Mach-O, Gatekeeper checks online | +| `mandalo-darwin-*.vsix` (bundled CLI) | `scripts/macos-sign.sh` | no, same reason | + +Without the secrets below the workflow still builds, just unsigned — except on a +`v*` tag, where a missing credential fails the job instead of quietly shipping +something Gatekeeper will reject. + +## Repository secrets + +| Secret | What it is | +| --- | --- | +| `APPLE_CERTIFICATE` | base64 of the Developer ID Application `.p12` | +| `APPLE_CERTIFICATE_PASSWORD` | the password that `.p12` was exported with | +| `APPLE_SIGNING_IDENTITY` | e.g. `Developer ID Application: Dani Rus (TEAMID1234)` | +| `APPLE_API_KEY` | App Store Connect key id, e.g. `DHF22R7L7Y` | +| `APPLE_API_ISSUER` | App Store Connect issuer UUID | +| `APPLE_API_KEY_P8` | base64 of `AuthKey_.p8` | + +## Getting the certificate + +The certificate is per-team, not per-machine — do this once and keep the `.p12` +in a password manager. The private key never leaves your machine or the manager. + +```bash +# 1. A key and a certificate signing request. +openssl genrsa -out developer-id.key 2048 +openssl req -new -key developer-id.key -out developer-id.csr \ + -subj "/emailAddress=you@example.com/CN=Your Name/C=RO" +``` + +Upload `developer-id.csr` at + → **Developer ID +Application** → *Previous Sub-CA* is not needed, take the default (G2) → download +`developerID_application.cer`. + +```bash +# 2. Combine the downloaded certificate with the key into a .p12. +openssl x509 -inform DER -in developerID_application.cer -out developer-id.pem +openssl pkcs12 -export -legacy \ + -inkey developer-id.key -in developer-id.pem \ + -out developer-id.p12 -name "Developer ID Application" + +# 3. The secret values. +base64 -i developer-id.p12 | pbcopy # -> APPLE_CERTIFICATE +openssl x509 -in developer-id.pem -noout -subject # -> APPLE_SIGNING_IDENTITY (the CN) +``` + +`-legacy` matters: without it OpenSSL 3 writes a `.p12` that macOS `security +import` refuses. + +## Getting the API key + + → **Keys** → generate +one with the **Developer** role. The page shows the **Issuer ID** once at the top +(`APPLE_API_ISSUER`), the key id in the row (`APPLE_API_KEY`), and lets you +download `AuthKey_.p8` exactly once. + +```bash +base64 -i ~/Downloads/AuthKey_DHF22R7L7Y.p8 | pbcopy # -> APPLE_API_KEY_P8 +``` + +## Uploading them + +```bash +gh secret set APPLE_CERTIFICATE < <(base64 -i developer-id.p12) +gh secret set APPLE_CERTIFICATE_PASSWORD +gh secret set APPLE_SIGNING_IDENTITY +gh secret set APPLE_API_KEY +gh secret set APPLE_API_ISSUER +gh secret set APPLE_API_KEY_P8 < <(base64 -i AuthKey_DHF22R7L7Y.p8) +``` + +## Checking a release + +```bash +spctl --assess --type execute --verbose=4 /Applications/mandalo.app +xcrun stapler validate /Applications/mandalo.app +``` + +For the CLI, `spctl --assess` answers `the code is valid but does not seem to be +an app` — it only assesses bundles, so that sentence is a pass, not a failure. +What actually proves a standalone binary is fine is that it runs with the +quarantine bit set: + +```bash +codesign -dv --verbose=4 ./mandalo # expect Authority=…, flags=0x10000(runtime), Timestamp=… +xattr -w com.apple.quarantine "0083;00000000;Safari;" ./mandalo +./mandalo --version +``` + +A bare Mach-O cannot carry a stapled ticket, so that check needs the machine to +be online the first time. It is the best available for a standalone binary. diff --git a/packaging/README.md b/packaging/README.md index 5161bb3..ca910a2 100644 --- a/packaging/README.md +++ b/packaging/README.md @@ -29,24 +29,36 @@ scripts/sync-packaging.sh v0.1.0 ``` That reads `SHA256SUMS` off the release and rewrites the version and every digest -in both manifests below. The `REPLACE_WITH_SHA256_*` placeholders they carry in -git are what it fills in; `brew style` flags them until it has run, which is +in the three manifests below. The `REPLACE_WITH_SHA256_*` placeholders they carry +in git are what it fills in; `brew style` flags them until it has run, which is expected. +The cask points at `mandalo__universal.dmg`, so it also needs that +asset to be on the release — a tag that only built the per-architecture DMGs +would make `sync-packaging.sh` fail loudly rather than emit a broken cask. + ## Homebrew — `homebrew/Formula/mandalo.rb` -A bottle-less binary formula, four platforms. To publish it: +A bottle-less binary formula for the CLI on Linux, x86_64 and arm64. macOS is +served by the cask below, not by this. To publish it: + +The tap already exists — `De-Rus/homebrew-tap`, the one that carries `sixtysix`. +`brew` maps `De-Rus/tap` to it. To publish: -1. Create a public repository named **`homebrew-tap`** under `De-Rus`. The name - matters: `brew` maps `De-Rus/tap` to `De-Rus/homebrew-tap`. -2. Run `scripts/sync-packaging.sh ` so the formula carries real digests. -3. Copy it into the tap at `Formula/mandalo.rb`. -4. Commit and push. Users then get: +1. Run `scripts/sync-packaging.sh ` so the manifests carry real digests. +2. Copy the formula into the tap at `Formula/mandalo.rb` and the cask at + `Casks/mandalo.rb`. +3. Commit and push. Users then get: ```sh -brew install De-Rus/tap/mandalo +brew install De-Rus/tap/mandalo # Linux, CLI +brew install --cask De-Rus/tap/mandalo # macOS, app + CLI ``` +Until that push lands, `brew install --cask De-Rus/tap/mandalo` fails with +"Cask 'mandalo' is unavailable" — the manifests in this directory are the source, +the tap is what Homebrew reads. + Verify before pushing: ```sh @@ -54,6 +66,34 @@ brew audit --strict --online --formula Formula/mandalo.rb brew install --build-from-source --formula Formula/mandalo.rb ``` +## Homebrew cask — `homebrew/Casks/mandalo.rb` + +The desktop app, from the universal `.dmg`. It also links the CLI: the bundle +carries it as a Tauri sidecar (`externalBin` in `tauri.conf.json`, staged by +`scripts/build-sidecar.sh`), so `binary` points into the installed `.app` rather +than downloading a second copy. Inside the bundle it is `mandalo-cli` — Tauri +rejects a sidecar named after the `src-tauri` crate, which is `mandalo` — and the +cask's `target:` puts it on `PATH` as `mandalo`. One command gets both: + +```sh +brew install --cask De-Rus/tap/mandalo +``` + +The formula is `depends_on :linux`, so the two can never both provide +`bin/mandalo` — macOS is the cask's alone, Linux is the formula's alone, and no +`conflicts_with` is needed. This matches the `sixtysix` tap and exists because a +formula without a bottle takes Homebrew's build-from-source path, which demands +Command Line Tools to install a binary that is already compiled. + +The trade-off is deliberate: on macOS there is no brew route to the CLI without +the app. Anyone who wants only the binary — a CI runner, a server — uses +`scripts/install.sh` or the `.tar.gz` from the release. + +The cask needs the app signed and notarized, or every user lands on +"mandalo.app is damaged and can't be opened" — see +[`../docs/macos-signing.md`](../docs/macos-signing.md). That is also why it needs +no `postflight` quarantine strip. + ## Scoop — `scoop/mandalo.json` A Scoop manifest for Windows. Same shape: create a `De-Rus/scoop-bucket` diff --git a/packaging/homebrew/Casks/mandalo.rb b/packaging/homebrew/Casks/mandalo.rb new file mode 100644 index 0000000..0c4b424 --- /dev/null +++ b/packaging/homebrew/Casks/mandalo.rb @@ -0,0 +1,43 @@ +cask "mandalo" do + version "0.2.0" + sha256 "REPLACE_WITH_SHA256_universal_dmg" + + url "https://github.com/De-Rus/mandalo/releases/download/v#{version}/mandalo_#{version}_universal.dmg" + name "Mándalo" + desc "Offline, git-native API client — HTTP, GraphQL and gRPC" + homepage "https://mandalo.dev/" + + # The app updates itself through the Tauri updater, so brew must not treat a + # self-updated install as outdated and reinstall over it. + auto_updates true + depends_on macos: :catalina + + app "mandalo.app" + # The CLI rides inside the bundle rather than being downloaded a second time. + # It is `mandalo-cli` in there because Tauri refuses a sidecar named after the + # src-tauri crate; target: puts it on PATH as `mandalo`. No conflicts_with is + # needed: the formula is `depends_on :linux`, so it can never land here. + binary "#{appdir}/mandalo.app/Contents/MacOS/mandalo-cli", target: "mandalo" + + # No postflight xattr strip: the app and the sidecar are Developer ID signed + # and notarized, and the .dmg is stapled, so Gatekeeper clears them as they + # are. See ../../docs/macos-signing.md. + + zap trash: [ + "~/Library/Application Support/com.drus.mandalo", + "~/Library/Caches/com.drus.mandalo", + "~/Library/Saved Application State/com.drus.mandalo.savedState", + "~/Library/WebKit/com.drus.mandalo", + ] + + caveats <<~EOS + This installs both the app and the `mandalo` CLI, which share the workspace + in ~/Mandalo — plain TOML collections and environments, yours to put in git. + + The app updates itself, so `brew upgrade --cask mandalo` is not how new + versions arrive; Homebrew only reinstalls if you ask it to. + + `brew uninstall --cask mandalo` leaves ~/Mandalo alone. --zap clears the + app's caches and state, and still does not touch your collections. + EOS +end diff --git a/packaging/homebrew/Formula/mandalo.rb b/packaging/homebrew/Formula/mandalo.rb index a087c2c..996f08e 100644 --- a/packaging/homebrew/Formula/mandalo.rb +++ b/packaging/homebrew/Formula/mandalo.rb @@ -4,16 +4,11 @@ class Mandalo < Formula version "0.2.0" license "MIT" - on_macos do - on_arm do - url "https://github.com/De-Rus/mandalo/releases/download/v#{version}/mandalo-v#{version}-aarch64-apple-darwin.tar.gz" - sha256 "REPLACE_WITH_SHA256_aarch64-apple-darwin" - end - on_intel do - url "https://github.com/De-Rus/mandalo/releases/download/v#{version}/mandalo-v#{version}-x86_64-apple-darwin.tar.gz" - sha256 "REPLACE_WITH_SHA256_x86_64-apple-darwin" - end - end + # macOS gets the cask, not the formula: a formula without a bottle takes + # Homebrew's build-from-source path, which demands current Command Line Tools + # to install a binary that is already compiled. The cask also carries the + # desktop app, and linking bin/mandalo from both would collide. + depends_on :linux on_linux do on_arm do diff --git a/scripts/build-sidecar.sh b/scripts/build-sidecar.sh new file mode 100755 index 0000000..16ccf77 --- /dev/null +++ b/scripts/build-sidecar.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# Build the mandalo CLI and stage it where Tauri expects a sidecar. +# +# scripts/build-sidecar.sh [cargo profile] +# +# Tauri resolves `externalBin: ["binaries/mandalo-cli"]` to +# src-tauri/binaries/mandalo-cli- and drops it in the +# bundle as `mandalo-cli` next to the desktop binary. That is what lets the +# Homebrew cask link one CLI out of the installed .app instead of shipping a +# second copy; the cask renames it back to `mandalo` on the symlink. +# +# The name cannot be plain `mandalo`: Tauri rejects a sidecar that collides with +# the Cargo package name of src-tauri, which is `mandalo`. +# +# universal-apple-darwin is not a real compilation target: both macOS binaries +# are built and lipo'd, which is also what Tauri does with the app itself. +# +# The profile defaults to release-cli, what releases ship. CI passes `dev` only +# to satisfy the src-tauri build script — `cargo test --workspace` compiles +# src-tauri, which refuses to build while the declared sidecar is missing — and +# the debug artifacts it produces are the ones the test run reuses anyway. +set -euo pipefail + +target=${1:?usage: build-sidecar.sh [cargo profile]} +profile=${2:-release-cli} +root=$(cd "$(dirname "$0")/.." && pwd) +cd "$root" + +# cargo writes the `dev` profile to target//debug, not target//dev. +case "$profile" in + dev) profile_dir="debug" ;; + *) profile_dir="$profile" ;; +esac + +out_dir="src-tauri/binaries" +mkdir -p "$out_dir" + +case "$target" in + *windows*) suffix=".exe" ;; + *) suffix="" ;; +esac + +build() { + cargo build --profile "$profile" -p mandalo-cli --target "$1" + echo "target/$1/$profile_dir/mandalo$suffix" +} + +dest="$out_dir/mandalo-cli-${target}${suffix}" + +if [ "$target" = "universal-apple-darwin" ]; then + arm=$(build aarch64-apple-darwin) + intel=$(build x86_64-apple-darwin) + lipo -create -output "$dest" "$arm" "$intel" + lipo -info "$dest" +else + src=$(build "$target") + cp "$src" "$dest" +fi + +[ -n "$suffix" ] || chmod 0755 "$dest" +ls -l "$dest" diff --git a/scripts/macos-notarize.sh b/scripts/macos-notarize.sh new file mode 100755 index 0000000..cf7ef01 --- /dev/null +++ b/scripts/macos-notarize.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# Notarize an already-signed macOS artifact and staple the ticket where possible. +# +# scripts/macos-notarize.sh +# +# Needs APPLE_API_ISSUER, APPLE_API_KEY (the key id) and APPLE_API_KEY_PATH (the +# .p8 on disk). notarytool only accepts .zip, .dmg and .pkg, so a bare binary is +# zipped for submission; a ticket cannot be stapled onto a bare Mach-O, Gatekeeper +# checks those online instead. Bundles and installers do get stapled, which is +# what makes them work on a machine that is offline the first time it opens them. +set -euo pipefail + +: "${APPLE_API_ISSUER:?APPLE_API_ISSUER is not set}" +: "${APPLE_API_KEY:?APPLE_API_KEY is not set}" +: "${APPLE_API_KEY_PATH:?APPLE_API_KEY_PATH is not set}" + +target=${1:?usage: macos-notarize.sh } +[ -e "$target" ] || { echo "macos-notarize: $target does not exist" >&2; exit 1; } + +staple="" +case "$target" in + *.zip) + submission=$target + ;; + *.dmg | *.pkg) + submission=$target + staple=$target + ;; + *) + submission="$(mktemp -d)/$(basename "$target").zip" + ditto -c -k --keepParent "$target" "$submission" + case "$target" in *.app) staple=$target ;; esac + ;; +esac + +xcrun notarytool submit "$submission" \ + --key "$APPLE_API_KEY_PATH" \ + --key-id "$APPLE_API_KEY" \ + --issuer "$APPLE_API_ISSUER" \ + --wait --timeout 30m + +if [ -n "$staple" ]; then + xcrun stapler staple "$staple" + xcrun stapler validate "$staple" +else + echo "notarized $target (no ticket stapled — Gatekeeper verifies it online)" +fi diff --git a/scripts/macos-sign.sh b/scripts/macos-sign.sh new file mode 100755 index 0000000..23c9a56 --- /dev/null +++ b/scripts/macos-sign.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# Sign macOS binaries with the Developer ID Application certificate. +# +# scripts/macos-sign.sh [path...] +# +# Needs APPLE_CERTIFICATE (base64 of the .p12), APPLE_CERTIFICATE_PASSWORD and +# APPLE_SIGNING_IDENTITY. The certificate is imported into a throwaway keychain +# under $RUNNER_TEMP, never the login keychain, and the keychain is reused +# across calls so a job that signs several binaries imports the cert once. +# +# The desktop bundles do NOT go through here: tauri-action reads the same three +# variables and signs the .app and the .dmg itself. +set -euo pipefail + +: "${APPLE_CERTIFICATE:?APPLE_CERTIFICATE is not set}" +: "${APPLE_CERTIFICATE_PASSWORD:?APPLE_CERTIFICATE_PASSWORD is not set}" +: "${APPLE_SIGNING_IDENTITY:?APPLE_SIGNING_IDENTITY is not set}" +[ "$#" -gt 0 ] || { echo "usage: macos-sign.sh [path...]" >&2; exit 1; } + +keychain="${RUNNER_TEMP:-/tmp}/mandalo-signing.keychain-db" +password_file="${RUNNER_TEMP:-/tmp}/mandalo-signing.password" + +if [ ! -f "$keychain" ]; then + umask 077 + openssl rand -hex 24 > "$password_file" + password=$(cat "$password_file") + + security create-keychain -p "$password" "$keychain" + # -lut 21600: no auto-lock on idle, hard timeout at 6h so a hung job cannot + # leave an unlocked keychain behind forever. + security set-keychain-settings -lut 21600 "$keychain" + security unlock-keychain -p "$password" "$keychain" + + p12="${RUNNER_TEMP:-/tmp}/mandalo-signing.p12" + printf '%s' "$APPLE_CERTIFICATE" | base64 --decode > "$p12" + security import "$p12" -k "$keychain" -P "$APPLE_CERTIFICATE_PASSWORD" \ + -T /usr/bin/codesign -T /usr/bin/security + rm -f "$p12" + + # Without this codesign prompts for the keychain password on every use, which + # in CI means it hangs until the step times out. + security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$password" "$keychain" >/dev/null + + # Prepending rather than replacing: dropping the runner's own keychains from + # the search list breaks anything else in the job that needs them. + existing=() + while IFS= read -r line; do + existing+=("$(printf '%s' "$line" | tr -d ' "')") + done < <(security list-keychains -d user) + security list-keychains -d user -s "$keychain" "${existing[@]}" +fi + +security unlock-keychain -p "$(cat "$password_file")" "$keychain" + +for path in "$@"; do + [ -e "$path" ] || { echo "macos-sign: $path does not exist" >&2; exit 1; } + # --options runtime is what notarization requires; --timestamp is what keeps + # the signature valid after the certificate expires. + codesign --force --options runtime --timestamp \ + --keychain "$keychain" --sign "$APPLE_SIGNING_IDENTITY" "$path" + codesign --verify --strict --verbose=2 "$path" + echo "signed $path" +done diff --git a/scripts/sync-packaging.sh b/scripts/sync-packaging.sh index 294d76b..be6395d 100755 --- a/scripts/sync-packaging.sh +++ b/scripts/sync-packaging.sh @@ -54,10 +54,24 @@ src, n = re.subn( fill, src, ) -if n != 4: - sys.exit(f"sync-packaging: expected 4 formula urls, rewrote {n}") +if n != 2: + sys.exit(f"sync-packaging: expected 2 formula urls, rewrote {n}") open(formula_path, "w").write(src) +cask_path = f"{root}/packaging/homebrew/Casks/mandalo.rb" +src = open(cask_path).read() +src = re.sub(r'^( version )"[^"]*"', rf'\g<1>"{version}"', src, count=1, flags=re.M) +src, n = re.subn( + r'^( sha256 )"[^"]*"', + lambda m: f'{m.group(1)}"{digest(f"mandalo_{version}_universal.dmg")}"', + src, + count=1, + flags=re.M, +) +if n != 1: + sys.exit("sync-packaging: could not rewrite the cask sha256") +open(cask_path, "w").write(src) + scoop_path = f"{root}/packaging/scoop/mandalo.json" m = json.load(open(scoop_path)) asset = f"mandalo-{tag}-x86_64-pc-windows-msvc.zip" @@ -69,8 +83,9 @@ with open(scoop_path, "w") as fh: json.dump(m, fh, indent=2, ensure_ascii=False) fh.write("\n") -print(f"formula and scoop manifest now describe {tag}") +print(f"formula, cask and scoop manifest now describe {tag}") PY echo "copy packaging/homebrew/Formula/mandalo.rb into De-Rus/homebrew-tap" +echo "copy packaging/homebrew/Casks/mandalo.rb into De-Rus/homebrew-tap" echo "copy packaging/scoop/mandalo.json into De-Rus/scoop-bucket" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index bf3ad81..2a2b78c 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -28,6 +28,7 @@ "active": true, "createUpdaterArtifacts": true, "targets": "all", + "externalBin": ["binaries/mandalo-cli"], "icon": [ "icons/32x32.png", "icons/128x128.png",