OR-319 Sign and notarize the macOS CLI binaries in releases (#427)

* Sign and notarize the macOS CLI binaries in releases

The install.sh archives for macOS shipped unsigned, so device-management
policies on work Macs blocked them. A release-signing-gated job now signs
and notarizes each darwin archive between dist's local and global builds,
rewriting its checksums so the installers and sha256.sum match.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Harden CLI signing from review

Keep the called workflow from reporting skipped on dry runs, narrow its
token to read, pin the Developer ID requirement the updater checks, keep
notarization logs on failure, and correct the allow-dirty docs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Myles Anderson
2026-09-23 15:21:50 -07:00
committed by GitHub
co-authored by Claude Opus 5.5
parent c81d46ca99
commit de469a3f44
8 changed files with 218 additions and 30 deletions
+3
View File
@@ -5,6 +5,9 @@
# Security-sensitive: release CI and the macOS signing pipeline handle (or can
# reach) the Developer ID certificate. Changes here must be reviewed.
/.github/workflows/ @myles332 @sox8502
/.github/actions/ @myles332 @sox8502
/.github/build-setup.yml @myles332 @sox8502
/dist-workspace.toml @myles332 @sox8502
/scripts/build-macos-app.sh @myles332 @sox8502
/scripts/package-macos-app.sh @myles332 @sox8502
/macos/ @myles332 @sox8502
+50
View File
@@ -0,0 +1,50 @@
name: macOS signing setup
description: >-
Import the Developer ID certificate into a temporary keychain and store
notarytool credentials under the `orx-notary` keychain profile.
inputs:
cert-p12-base64:
required: true
cert-password:
required: true
notary-apple-id:
required: true
notary-team-id:
required: true
notary-password:
required: true
runs:
using: composite
steps:
- name: Import signing certificate into a temp keychain
shell: bash
env:
CERT_P12_BASE64: ${{ inputs.cert-p12-base64 }}
CERT_PASSWORD: ${{ inputs.cert-password }}
run: |
set -euo pipefail
KEYCHAIN="$RUNNER_TEMP/signing.keychain-db"
KEYCHAIN_PW="$(openssl rand -base64 24)"
security create-keychain -p "$KEYCHAIN_PW" "$KEYCHAIN"
security set-keychain-settings -lut 21600 "$KEYCHAIN"
security unlock-keychain -p "$KEYCHAIN_PW" "$KEYCHAIN"
echo "$CERT_P12_BASE64" | base64 --decode > "$RUNNER_TEMP/cert.p12"
security import "$RUNNER_TEMP/cert.p12" -k "$KEYCHAIN" -P "$CERT_PASSWORD" -T /usr/bin/codesign
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PW" "$KEYCHAIN"
# Prepend the temp keychain to the search list, keeping the login keychain.
security list-keychains -d user -s "$KEYCHAIN" $(security list-keychains -d user | sed 's/"//g')
rm -f "$RUNNER_TEMP/cert.p12"
- name: Store notary credentials
shell: bash
env:
NOTARY_APPLE_ID: ${{ inputs.notary-apple-id }}
NOTARY_TEAM_ID: ${{ inputs.notary-team-id }}
NOTARY_PASSWORD: ${{ inputs.notary-password }}
run: |
xcrun notarytool store-credentials orx-notary \
--apple-id "$NOTARY_APPLE_ID" \
--team-id "$NOTARY_TEAM_ID" \
--password "$NOTARY_PASSWORD"
+7 -28
View File
@@ -98,34 +98,13 @@ jobs:
- name: Add Rust targets
run: rustup target add aarch64-apple-darwin x86_64-apple-darwin
- name: Import signing certificate into a temp keychain
env:
CERT_P12_BASE64: ${{ secrets.MACOS_CERT_P12_BASE64 }}
CERT_PASSWORD: ${{ secrets.MACOS_CERT_PASSWORD }}
run: |
set -euo pipefail
KEYCHAIN="$RUNNER_TEMP/signing.keychain-db"
KEYCHAIN_PW="$(openssl rand -base64 24)"
security create-keychain -p "$KEYCHAIN_PW" "$KEYCHAIN"
security set-keychain-settings -lut 21600 "$KEYCHAIN"
security unlock-keychain -p "$KEYCHAIN_PW" "$KEYCHAIN"
echo "$CERT_P12_BASE64" | base64 --decode > "$RUNNER_TEMP/cert.p12"
security import "$RUNNER_TEMP/cert.p12" -k "$KEYCHAIN" -P "$CERT_PASSWORD" -T /usr/bin/codesign
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PW" "$KEYCHAIN"
# Prepend the temp keychain to the search list, keeping the login keychain.
security list-keychains -d user -s "$KEYCHAIN" $(security list-keychains -d user | sed 's/"//g')
rm -f "$RUNNER_TEMP/cert.p12"
- name: Store notary credentials
env:
NOTARY_APPLE_ID: ${{ secrets.MACOS_NOTARY_APPLE_ID }}
NOTARY_TEAM_ID: ${{ secrets.MACOS_NOTARY_TEAM_ID }}
NOTARY_PASSWORD: ${{ secrets.MACOS_NOTARY_PASSWORD }}
run: |
xcrun notarytool store-credentials orx-notary \
--apple-id "$NOTARY_APPLE_ID" \
--team-id "$NOTARY_TEAM_ID" \
--password "$NOTARY_PASSWORD"
- uses: ./.github/actions/macos-signing
with:
cert-p12-base64: ${{ secrets.MACOS_CERT_P12_BASE64 }}
cert-password: ${{ secrets.MACOS_CERT_PASSWORD }}
notary-apple-id: ${{ secrets.MACOS_NOTARY_APPLE_ID }}
notary-team-id: ${{ secrets.MACOS_NOTARY_TEAM_ID }}
notary-password: ${{ secrets.MACOS_NOTARY_PASSWORD }}
- name: Build universal app
run: ORX_OFFICIAL_RELEASE_BUILD=1 ORX_APP_UNIVERSAL=1 bash scripts/build-macos-app.sh
+16 -1
View File
@@ -173,11 +173,22 @@ jobs:
${{ steps.cargo-dist.outputs.paths }}
${{ env.BUILD_MANIFEST_NAME }}
custom-sign-macos-cli:
needs:
- plan
- build-local-artifacts
if: ${{ needs.plan.outputs.publishing == 'true' || fromJson(needs.plan.outputs.val).ci.github.pr_run_mode == 'upload' || inputs.tag == 'dry-run' }}
uses: ./.github/workflows/sign-macos-cli.yml
with:
plan: ${{ needs.plan.outputs.val }}
secrets: inherit
# Build and package all the platform-agnostic(ish) things
build-global-artifacts:
needs:
- plan
- build-local-artifacts
- custom-sign-macos-cli
runs-on: "ubuntu-22.04"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -224,6 +235,7 @@ jobs:
needs:
- plan
- build-local-artifacts
- custom-sign-macos-cli
uses: ./.github/workflows/verify-build-channel.yml
with:
plan: ${{ needs.plan.outputs.val }}
@@ -233,6 +245,7 @@ jobs:
needs:
- plan
- build-local-artifacts
- custom-sign-macos-cli
uses: ./.github/workflows/telemetry-contract.yml
with:
plan: ${{ needs.plan.outputs.val }}
@@ -242,6 +255,7 @@ jobs:
needs:
- plan
- build-local-artifacts
- custom-sign-macos-cli
uses: ./.github/workflows/ci.yml
with:
plan: ${{ needs.plan.outputs.val }}
@@ -251,12 +265,13 @@ jobs:
needs:
- plan
- build-local-artifacts
- custom-sign-macos-cli
- build-global-artifacts
- custom-verify-build-channel
- custom-telemetry-contract
- custom-ci
# Only run if we're "publishing", and only if plan, local and global didn't fail (skipped is fine)
if: ${{ always() && needs.plan.result == 'success' && needs.plan.outputs.publishing == 'true' && (needs.build-global-artifacts.result == 'skipped' || needs.build-global-artifacts.result == 'success') && (needs.custom-verify-build-channel.result == 'skipped' || needs.custom-verify-build-channel.result == 'success') && (needs.custom-telemetry-contract.result == 'skipped' || needs.custom-telemetry-contract.result == 'success') && (needs.custom-ci.result == 'skipped' || needs.custom-ci.result == 'success') && (needs.build-local-artifacts.result == 'skipped' || needs.build-local-artifacts.result == 'success') }}
if: ${{ always() && needs.plan.result == 'success' && needs.plan.outputs.publishing == 'true' && (needs.build-global-artifacts.result == 'skipped' || needs.build-global-artifacts.result == 'success') && (needs.custom-verify-build-channel.result == 'skipped' || needs.custom-verify-build-channel.result == 'success') && (needs.custom-telemetry-contract.result == 'skipped' || needs.custom-telemetry-contract.result == 'success') && (needs.custom-ci.result == 'skipped' || needs.custom-ci.result == 'success') && (needs.build-local-artifacts.result == 'skipped' || needs.build-local-artifacts.result == 'success') && (needs.custom-sign-macos-cli.result == 'skipped' || needs.custom-sign-macos-cli.result == 'success') }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
runs-on: "ubuntu-22.04"
+118
View File
@@ -0,0 +1,118 @@
# Signs and notarizes the macOS CLI archives before dist's global build, which
# derives installers and sha256.sum from each target's manifest checksums.
# See macos/DISTRIBUTION.md.
name: Sign macOS CLI
on:
workflow_call:
inputs:
plan:
required: true
type: string
permissions:
contents: read
jobs:
# Always runs: if every job here skipped, the caller would too, skipping all downstream release jobs.
targets:
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.targets.outputs.matrix }}
steps:
- id: targets
env:
PLAN: ${{ inputs.plan }}
run: |
set -euo pipefail
matrix="$(jq -c '{include: [.ci.github.artifacts_matrix.include[] | select(any(.targets[]; endswith("-apple-darwin"))) | {targets}]}' <<< "$PLAN")"
echo "$matrix"
echo "matrix=$matrix" >> "$GITHUB_OUTPUT"
sign:
name: sign (${{ join(matrix.targets, ', ') }})
needs: targets
# Dry runs build development binaries that are never published.
if: ${{ !fromJson(inputs.plan).announcement_tag_is_implicit }}
strategy:
matrix: ${{ fromJson(needs.targets.outputs.matrix) }}
runs-on: macos-14
timeout-minutes: 40
environment: release-signing
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
persist-credentials: false
- uses: ./.github/actions/macos-signing
with:
cert-p12-base64: ${{ secrets.MACOS_CERT_P12_BASE64 }}
cert-password: ${{ secrets.MACOS_CERT_PASSWORD }}
notary-apple-id: ${{ secrets.MACOS_NOTARY_APPLE_ID }}
notary-team-id: ${{ secrets.MACOS_NOTARY_TEAM_ID }}
notary-password: ${{ secrets.MACOS_NOTARY_PASSWORD }}
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: artifacts-build-local-${{ join(matrix.targets, '_') }}
path: artifacts
- name: Sign, notarize, and repack
env:
MACOS_SIGN_IDENTITY: ${{ secrets.MACOS_SIGN_IDENTITY }}
run: |
set -euo pipefail
shopt -s nullglob
archives=(artifacts/*.tar.xz)
manifests=(artifacts/*-dist-manifest.json)
if (( ${#archives[@]} != 1 || ${#manifests[@]} != 1 )); then
echo "::error::Expected one archive and one manifest, found ${#archives[@]} and ${#manifests[@]}"
exit 1
fi
archive="${archives[0]}"
manifest="${manifests[0]}"
name="$(basename "$archive")"
work="$RUNNER_TEMP/unpacked"
mkdir -p "$work"
tar -xf "$archive" -C "$work"
binary="$(find "$work" -type f -name orx -print -quit)"
if [[ -z "$binary" ]]; then
echo "::error::No orx binary found in $name"
exit 1
fi
codesign --force --options runtime --timestamp --sign "$MACOS_SIGN_IDENTITY" "$binary"
# Same requirement src/updates/macos_app.rs pins, so a wrong identity fails here.
codesign --verify --strict --verbose=2 \
-R='anchor apple generic and certificate leaf[subject.OU] = "9P69UXUJUK" and certificate 1[field.1.2.840.113635.100.6.2.6] exists and certificate leaf[field.1.2.840.113635.100.6.1.13] exists' \
"$binary"
# A bare binary can't carry a stapled ticket; Gatekeeper finds it online.
ditto -c -k "$binary" "$RUNNER_TEMP/orx.zip"
result="$(xcrun notarytool submit "$RUNNER_TEMP/orx.zip" --keychain-profile orx-notary --wait --timeout 25m --output-format json)" || true
echo "$result"
if [[ "$(jq -r .status <<< "$result")" != Accepted ]]; then
xcrun notarytool log "$(jq -r .id <<< "$result")" --keychain-profile orx-notary || true
echo "::error::Notarization of $name was not accepted"
exit 1
fi
rm "$archive"
(cd "$work" && COPYFILE_DISABLE=1 tar --no-xattrs --no-mac-metadata -cJf "$GITHUB_WORKSPACE/$archive" $(ls -A))
# Same format dist writes, including its trailing blank line.
sum="$(shasum -a 256 "$archive" | cut -d' ' -f1)"
printf '%s *%s\n\n' "$sum" "$name" > "$archive.sha256"
jq --arg name "$name" --arg sum "$sum" \
'if .artifacts[$name].checksums.sha256 then .artifacts[$name].checksums.sha256 = $sum else error("\($name) has no sha256 in the manifest") end' \
"$manifest" > "$manifest.new"
mv "$manifest.new" "$manifest"
echo "$name signed and notarized; sha256 $sum"
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: artifacts-build-local-${{ join(matrix.targets, '_') }}
path: artifacts/
overwrite: true
+1 -1
View File
@@ -21,4 +21,4 @@ When changing authentication, organization, sandbox, or managed-compute APIs, in
- GitHub protection for `main` must require the `fmt, clippy, test`, `version sanity`, and `linked issue` checks from GitHub Actions, including for administrators. Do not require a merge queue or require branches to be up to date. These settings are managed in GitHub, not by this file.
- `linked issue` fails PRs from forks unless the description links an issue in this repository (e.g. `Closes #N`). PRs from branches in this repository are exempt, since only people with write access can push them. It runs on `pull_request_target`, so it must never check out or run PR code.
- PR CI must test GitHub's simulated merge (`refs/pull/<number>/merge`), which `actions/checkout` selects by default for `pull_request` events, rather than checking out the PR head alone. Each run tests its merge candidate; subsequent changes to `main` do not automatically rerun open PRs.
- CI also runs on `main`. Releases call the same CI workflow on the commit being packaged; publishing requires that run to succeed. Keep `./ci` in cargo-dist's `global-artifacts-jobs` when regenerating the release workflow.
- CI also runs on `main`. Releases call the same CI workflow on the commit being packaged; publishing requires that run to succeed. Keep `./ci` in cargo-dist's `global-artifacts-jobs` when regenerating the release workflow; `allow-dirty` makes `dist generate` skip it, so follow the steps in `macos/DISTRIBUTION.md` ("CLI binaries").
+5
View File
@@ -9,6 +9,11 @@ cargo-dist-version = "0.32.0"
ci = "github"
# Mark only publishing builds and verify every packaged binary before hosting.
github-build-setup = "../build-setup.yml"
# Sign and notarize the macOS archives before the global build checksums them.
local-artifacts-jobs = ["./sign-macos-cli"]
# release.yml is hand-edited (custom-sign-macos-cli needs build-local-artifacts),
# so dist skips it; see macos/DISTRIBUTION.md before changing dist config.
allow-dirty = ["ci"]
# Verify packaged binaries and source CI before publishing.
global-artifacts-jobs = ["./verify-build-channel", "./telemetry-contract", "./ci"]
# The installers to generate for each app
+18
View File
@@ -64,6 +64,24 @@ signature check — not the digest — is what makes an unattended swap safe, so
**changing the signing identity breaks self-update for every installed app**:
update `EXPECTED_TEAM_ID` and ship that release before retiring the old cert.
## CLI binaries
The `install.sh` archives for macOS are signed with the same Developer ID and
notarized by `.github/workflows/sign-macos-cli.yml`, which dist runs between its
local and global builds. It re-uploads each darwin archive under the same
artifact name with a rewritten `.sha256` and per-target manifest checksum, so the
installers and `sha256.sum` built afterwards match the signed archives. It uses
the same `release-signing` environment, so a release waits on one approval for
it, then another for the DMG. Unlike the DMG, it ignores `MACOS_SIGNING_ENABLED`:
every published release needs those secrets and that approval. Dry runs skip it,
so the first real release is its first end-to-end run.
dist does not generate this job's `needs: build-local-artifacts`, so
`dist-workspace.toml` sets `allow-dirty = ["ci"]`, which makes `dist generate`
skip `release.yml` entirely: dist config changes no longer reach CI on their own.
To regenerate, remove `allow-dirty`, run the pinned `dist generate`, re-add that `needs`
line to `custom-sign-macos-cli`, and restore `allow-dirty`.
## Configure signing (CI)
Needs an Apple Developer Program account with a **Developer ID Application**