Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -380,6 +380,59 @@ jobs:
find src-tauri/target/release/bundle/appimage -name "*.AppImage.sig" -exec gh release upload v${{ needs.create-release.outputs.version }} {} --clobber \;

# --- MacOS Build ---
# macOS binds a persisted file-access grant to the app's *designated
# requirement*, not to its bytes. An unsigned bundle has no certificate to
# pin, so the requirement falls back to a content hash that changes with
# every build -- which is why the folder permission has to be granted
# again after each update (#209).
#
# Signing with a certificate that stays the same across releases replaces
# that hash with `identifier <bundle-id> and certificate leaf = H"..."`,
# which does not move when the code does. The certificate does NOT have to
# come from Apple for this: TCC compares the requirement, it does not ask
# who issued the certificate.
#
# Without the three secrets below this step prints one line and does
# nothing, and the build stays exactly as it is today. This is not Apple
# notarization -- Gatekeeper still warns on first launch either way.
- name: Import macOS signing certificate
if: matrix.os == 'macos'
shell: bash
env:
MACOS_CERTIFICATE: ${{ secrets.MACOS_CERTIFICATE }}
MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }}
MACOS_SIGNING_IDENTITY: ${{ secrets.MACOS_SIGNING_IDENTITY }}
run: |
set -euo pipefail
if [ -z "${MACOS_CERTIFICATE:-}" ]; then
echo "No MACOS_CERTIFICATE secret; leaving the bundle unsigned (unchanged behaviour)."
exit 0
fi
KEYCHAIN="$RUNNER_TEMP/markpad-signing.keychain"
KEYCHAIN_PASSWORD="$(openssl rand -base64 24)"
CERTIFICATE="$RUNNER_TEMP/certificate.p12"

printf '%s' "$MACOS_CERTIFICATE" | base64 --decode > "$CERTIFICATE"
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN"
# Lock the keychain after an hour and on sleep, so a stuck runner does
# not leave the key readable for the rest of its life.
security set-keychain-settings -lut 3600 "$KEYCHAIN"
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN"
security import "$CERTIFICATE" -k "$KEYCHAIN" -P "$MACOS_CERTIFICATE_PASSWORD" \
-T /usr/bin/codesign
# Without this, codesign blocks on a GUI prompt for the private key
# that nobody is there to answer.
security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" \
"$KEYCHAIN" > /dev/null
# codesign resolves the identity through the search list, so the new
# keychain has to join it rather than replace it.
security list-keychains -d user -s \
$(security list-keychains -d user | tr -d '"') "$KEYCHAIN"
rm -f "$CERTIFICATE"

# `tauri build` reads this and signs with it; unset means no signing.
echo "APPLE_SIGNING_IDENTITY=$MACOS_SIGNING_IDENTITY" >> "$GITHUB_ENV"

- name: Build MacOS (Universal)
if: matrix.os == 'macos'
env:
Expand Down
47 changes: 46 additions & 1 deletion RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,40 @@ The failure is quiet and unfixable from here: `latest.json` 404s, the updater re

**`build.yml` checks this before creating a release.** It fetches that URL and asserts the feed's download URLs still name this repository. A network failure warns instead of blocking.

### 6. Optional: a stable macOS signing identity

This is unrelated to the minisign keypair above, and it is not the Apple Developer Program. Skip it and macOS releases behave exactly as they did before.

**What it buys.** macOS binds a persisted file-access grant — including the Full Disk Access checkbox — to the app's *designated requirement*, not to its bytes. Today's bundles carry no certificate, so the requirement is a content hash that changes with every build, and every update looks like a different app to TCC. Users re-grant folder access after each release ([#209](https://github.com/sftwrdotdev/Markpad/issues/209)). Signing with a certificate that outlives releases replaces that hash with `identifier "com.alecdotdev.markpad" and certificate leaf = H"..."`, which does not move when the code does.

**What it does not buy.** Nothing about Gatekeeper. The app stays un-notarized, so a downloaded `.dmg` still warns on first launch — the same as today.

**Create the certificate** (once, on a Mac, no Apple account involved):

1. Keychain Access → menu bar → *Certificate Assistant* → *Create a Certificate…*
2. Name it `markpad-codesign-certificate`, Identity Type *Self Signed Root*, Certificate Type **Code Signing**, and tick *Let me override defaults*.
3. **Set the validity period to something long — 7300 days.** The default is 365, and an expired certificate is as disruptive as a lost one.
4. Finish, then right-click the certificate → *Export…* → `.p12`, and set a password.

**Add three secrets** (Settings → Secrets and variables → Actions):

| Name | Value |
|---|---|
| `MACOS_CERTIFICATE` | `base64 -i markpad-codesign-certificate.p12 \| pbcopy` |
| `MACOS_CERTIFICATE_PASSWORD` | the `.p12` export password |
| `MACOS_SIGNING_IDENTITY` | `markpad-codesign-certificate` (the certificate's common name) |

With all three set, the macOS job imports the certificate into a throwaway keychain and `tauri build` signs with it. With `MACOS_CERTIFICATE` absent, the step prints one line and exits.

### 7. CRITICAL: the signing certificate has no revocation path

Apple can revoke a Developer ID certificate. Nobody can revoke this one. Two consequences worth accepting deliberately before step 6:

- **If the `.p12` leaks**, whoever holds it can sign a build that satisfies the same designated requirement — and therefore inherits every folder grant users gave the real Markpad. The only remedy is to switch certificates, which costs every user their permissions.
- **If it is lost or expires**, same outcome: a new certificate is a new identity, and every user re-grants from scratch.

Back it up in the same place as the minisign private key, and treat it with the same care.

## Per-release workflow

The workflow uses `npm ci`, so its installed dependency graph is exactly the committed lockfile. Do not replace it with `npm install` in release jobs. `scripts/releaseWorkflow.test.ts` guards that.
Expand Down Expand Up @@ -91,6 +125,7 @@ The workflow uses `npm ci`, so its installed dependency graph is exactly the com
4. **Wait** ~30 min for matrix builds to finish, plus ~2 min for `generate-update-feed`.
5. **Open the draft release** on the [Releases page](https://github.com/sftwrdotdev/Markpad/releases). Verify the assets:
- **macOS**: `*.dmg`, `*.app.tar.gz`, `*.app.tar.gz.sig`
- Once one-time setup step 6 is done, check the signature took as well: mount the `.dmg` and run `codesign -d -r-` against the `.app` inside it. It must print `certificate leaf = H"…"`. `code object is not signed at all` means the secrets are missing or the import step exited early — the build is green either way, and shipping it costs every macOS user their folder grants again.
- **Windows x64**: `Markpad_<version>_x64.exe` (portable), `*_x64-setup.exe` (NSIS installer), `*_x64-setup.exe.sig`
- **Windows ARM64**: `Markpad_<version>_arm64.exe` (portable), `*_arm64-setup.exe` (NSIS installer), `*_arm64-setup.exe.sig`
- **Linux**: `*.deb`, `*.rpm`, `*.AppImage`, `*.AppImage.sig`
Expand All @@ -106,6 +141,14 @@ Mention this clearly in the release notes for the first auto-update-capable vers

> This release activates in-app auto-updates. **Install it manually one last time** — future releases will update Markpad on their own.

## First signed macOS release

Turning on one-time setup step 6 changes the app's identity once. Existing bundles are pinned to an ad-hoc content hash; the signed one is pinned to the certificate, so to macOS the first signed release is a different app. Its users grant folder access one last time, and from then on the grant survives updates. No other platform is affected.

Say so in that release's notes, e.g.:

> macOS will ask for folder access once more after this update. **This is the last time** — from this release on, the permission carries across updates.

## Coverage notes

- **macOS** uses one universal binary (`darwin-aarch64` + `darwin-x86_64` share the same `.app.tar.gz` and signature).
Expand All @@ -118,6 +161,8 @@ Mention this clearly in the release notes for the first auto-update-capable vers
| Symptom | Likely cause / fix |
|---------|---------------------|
| Build fails: "missing `TAURI_SIGNING_PRIVATE_KEY`" | Step 2 of one-time setup wasn't done, or Secret name doesn't match. |
| macOS build fails at `security import` | The `.p12` was exported by a recent `openssl` with its default cipher, which `security` cannot read (it reports a MAC failure, not a cipher failure). Re-export from Keychain Access, or pass `-legacy -certpbe PBE-SHA1-3DES -keypbe PBE-SHA1-3DES -macalg sha1`. |
| macOS build logs `no identity found` | The certificate is a CA rather than a leaf — `codesign` will not sign with it. Keychain Access's *Create a Certificate…* produces the right kind; `openssl req -x509` needs an explicit `basicConstraints=critical,CA:FALSE`. |
| `generate-update-feed` succeeds but `latest.json` lacks a platform | That platform's matrix build failed silently (or the `.sig` file wasn't produced). Check the failed build's logs. |
| `latest.json` missing entirely | The `generate-update-feed` job didn't run — usually because no `*.sig` files were uploaded. Check the `Upload * Artifacts` steps. |
| Users don't see the update | (1) Did you click *Publish release*? Drafts aren't visible to clients. (2) Is the user on a version older than the first auto-update-capable release? They need a one-time manual reinstall. |
Expand All @@ -128,6 +173,6 @@ Mention this clearly in the release notes for the first auto-update-capable vers

## Out of scope (not handled by this workflow)

- **Apple Developer ID code-signing & notarization** — `.app` bundles are unsigned. macOS may show a Gatekeeper warning on first launch. Minisign verification by the updater is independent of Apple code-signing.
- **Apple Developer ID code-signing & notarization** — not done, and one-time setup step 6 is not a substitute: a self-signed certificate gives the bundle a stable identity for TCC, but macOS still shows a Gatekeeper warning on first launch because the app is not notarized. Minisign verification by the updater is independent of both.
- **Windows Authenticode signing** — neither the portable `.exe` nor the `*-setup.exe` NSIS installer is signed with a code-signing certificate. Users may see a SmartScreen warning. Minisign verification by the updater is independent.
- **Retroactive signing** of older releases.
30 changes: 29 additions & 1 deletion scripts/releaseWorkflow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -281,7 +281,8 @@ test('a package manager is not published from inside the platform matrix', () =>

test('package managers are published from a release a human published', () => {
// `release: published` fires when a maintainer clicks Publish on the draft,
// which RELEASING.md step 6 describes as the gate after checking the assets.
// which RELEASING.md's per-release step 6 describes as the gate after
// checking the assets.
// Anything earlier — `created`, a tag push, the end of the build — publishes
// on a release nobody has looked at.
const on = sliceBetween(publishWorkflow, '\non:', '\npermissions:');
Expand Down Expand Up @@ -403,6 +404,33 @@ test('signing uses the environment variables the pinned CLI reads', () => {
assert.match(workflow, /TAURI_SIGNING_PRIVATE_KEY_PASSWORD/);
});

test('a macOS release without the signing secrets still builds', () => {
// #294 was closed because a workflow that *requires* signing credentials
// blocks every release until someone configures them. This identity is
// optional by construction: the step exits before it touches a keychain when
// MACOS_CERTIFICATE is empty, and `tauri build` signs only when
// APPLE_SIGNING_IDENTITY is exported -- the name the pinned CLI reads, and
// the reason this step has to run before the build rather than beside it.
const step = sliceBetween(workflow, 'Import macOS signing certificate', 'Build MacOS (Universal)');
assert.match(step, /if \[ -z "\$\{MACOS_CERTIFICATE:-\}" \]; then[\s\S]*?exit 0/);
assert.match(step, /echo "APPLE_SIGNING_IDENTITY=[^"]*" >> "\$GITHUB_ENV"/);
});

test('RELEASING.md names the signing secrets the workflow reads', () => {
// Whoever configures the secrets works from the runbook and never opens the
// workflow, so a rename on either side sends them to create a secret nothing
// reads. The failure is the quiet one this step is built to allow: the import
// exits 0, the build stays green, and the bundle ships unsigned -- which
// reaches users as folder access being asked for all over again.
const step = sliceBetween(workflow, 'Import macOS signing certificate', 'Build MacOS (Universal)');
const secrets = [...new Set(step.match(/secrets\.[A-Z_]+/g) ?? [])];
assert.ok(secrets.length > 0, 'the signing step reads no secrets at all');
for (const secret of secrets) {
const name = secret.slice('secrets.'.length);
assert.ok(releasing.includes(`\`${name}\``), `RELEASING.md never tells anyone to create ${name}`);
}
});

test('the package managers we recommend are the ones we publish to', () => {
// Three places tell people a package manager can install Markpad: the README,
// the download table written into every release body, and the workflow that
Expand Down
Loading