mirror of
https://github.com/alphaXiv/OpenResearch.git
synced 2026-10-02 01:34:34 +08:00
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:
co-authored by
Claude Opus 5.5
parent
c81d46ca99
commit
de469a3f44
@@ -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
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -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").
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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**
|
||||
|
||||
Reference in New Issue
Block a user