From e84eb7302bab557b807c26856d71988846c925d6 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 30 Sep 2026 16:35:53 -0700 Subject: [PATCH 1/3] test(ci): notarization must use the team App Store Connect API key The nightly DMG and Computer Use helper notarization tests now require notarytool to authenticate with --key/--key-id/--issuer, a mode-600 key file decoded from ASC_API_KEY_P8_BASE64, no Apple ID credentials, and deletion of the decoded key on exit. Co-Authored-By: Claude Opus 5.5 (1M context) --- tests/test_notarize_computer_use_helper.py | 55 +++++++++++++++++++--- tests/test_notarize_nightly_dmg.sh | 54 ++++++++++++++++++--- 2 files changed, 97 insertions(+), 12 deletions(-) diff --git a/tests/test_notarize_computer_use_helper.py b/tests/test_notarize_computer_use_helper.py index 43ee9f47388d..2151f7cfb8cd 100644 --- a/tests/test_notarize_computer_use_helper.py +++ b/tests/test_notarize_computer_use_helper.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 """Exercise helper submission, artifact identity, and release gates with fake Apple tools.""" +import base64 import json import os from pathlib import Path @@ -11,12 +12,28 @@ ROOT = Path(__file__).resolve().parents[1] SCRIPT = ROOT / 'scripts/ci/notarize-computer-use-helper.sh' TOOL = r'''#!/usr/bin/env python3 -import hashlib, json, os, pathlib, shutil, sys +import hashlib, json, os, pathlib, shutil, stat, sys name = pathlib.Path(sys.argv[0]).name args = sys.argv[1:] root = pathlib.Path(os.environ['FIXTURE_ROOT']) with (root / 'calls').open('a') as f: f.write(json.dumps([name, *args]) + '\n') +if name == 'xcrun' and args[:1] == ['notarytool']: + def flag(option): + return args[args.index(option) + 1] if option in args else None + key = flag('--key') + key_path = pathlib.Path(key) if key else None + exists = bool(key_path and key_path.is_file()) + with (root / 'notary-auth').open('a') as f: + f.write(json.dumps({ + 'key': key, + 'key_id': flag('--key-id'), + 'issuer': flag('--issuer'), + 'exists': exists, + 'mode': stat.S_IMODE(key_path.stat().st_mode) if exists else None, + 'content_ok': exists and key_path.read_bytes() == b'fixture-p8', + 'apple_id_auth': any(a in args for a in ('--apple-id', '--password', '--team-id')), + }) + '\n') helper = root / 'cmux.app/Contents/Library/cmux Computer Use.app' def arches(): return os.environ.get('FIXTURE_ARCHS', 'arm64 x86_64').split() @@ -92,10 +109,11 @@ def setUp(self): self.entitlements = self.root / 'entitlements.plist' self.entitlements.write_bytes(plistlib.dumps({})) self.state = self.root / 'submission.state' - # Authentication is stubbed; this credential has no account or network access. - self.env = dict(os.environ, FIXTURE_ROOT=str(self.root), - APPLE_ID='fixture@example.com', APPLE_TEAM_ID='FIXTURETEAM', - APPLE_APP_SPECIFIC_PASSWORD='fixture-password', # noqa: S106 # gitleaks:allow + # Authentication is stubbed; this key has no account or network access. + env = {k: v for k, v in os.environ.items() if not k.startswith(('ASC_API_', 'APPLE_'))} + self.env = dict(env, FIXTURE_ROOT=str(self.root), + ASC_API_KEY_ID='FIXTUREKEY', ASC_API_ISSUER_ID='fixture-issuer', + ASC_API_KEY_P8_BASE64=base64.b64encode(b'fixture-p8').decode(), CMUX_HELPER_ENTITLEMENTS=str(self.entitlements), CMUX_GATEKEEPER_ASSESS_DELAY_SECONDS='0', CMUX_GATEKEEPER_ASSESS_ATTEMPTS='3') @@ -115,7 +133,8 @@ def run_helper(self, *args, success=True, **env): return result def calls(self, tool, *prefix): - calls = [json.loads(line) for line in (self.root / 'calls').read_text().splitlines()] + path = self.root / 'calls' + calls = [json.loads(line) for line in path.read_text().splitlines()] if path.exists() else [] return [c for c in calls if c[0] == tool and c[1:1 + len(prefix)] == list(prefix)] def test_submit_and_finish_preserve_ticket_and_reseal_outer_app(self): @@ -189,6 +208,30 @@ def test_gatekeeper_rejection_still_blocks_release(self): self.assertEqual(len(self.calls('spctl')), 3) self.assertFalse(self.calls('sign-bundle')) + def notary_auth(self): + path = self.root / 'notary-auth' + return [json.loads(line) for line in path.read_text().splitlines()] if path.exists() else [] + + def test_notarytool_authenticates_with_team_api_key_and_deletes_it(self): + self.run_helper('--start', self.state) + self.run_helper('--finish', self.state) + auth = self.notary_auth() + for verb in ('submit', 'wait', 'log'): + self.assertTrue(self.calls('xcrun', 'notarytool', verb), verb) + self.assertEqual(len(auth), len(self.calls('xcrun', 'notarytool'))) + for call in auth: + self.assertEqual((call['key_id'], call['issuer']), ('FIXTUREKEY', 'fixture-issuer')) + self.assertTrue(call['exists'] and call['content_ok'], call) + self.assertEqual(call['mode'], 0o600) + self.assertFalse(call['apple_id_auth'], call) + self.assertFalse(Path(call['key']).exists(), 'the decoded API key must be deleted on exit') + + def test_missing_api_key_stops_before_upload(self): + for missing in ('ASC_API_KEY_ID', 'ASC_API_ISSUER_ID', 'ASC_API_KEY_P8_BASE64'): + with self.subTest(missing=missing): + self.run_helper(success=False, **{missing: ''}) + self.assertFalse(self.calls('xcrun', 'notarytool')) + def test_existing_submission_cannot_be_overwritten(self): self.run_helper('--start', self.state) state = self.state.read_bytes() diff --git a/tests/test_notarize_nightly_dmg.sh b/tests/test_notarize_nightly_dmg.sh index 84c974260eaf..7855e27d6415 100755 --- a/tests/test_notarize_nightly_dmg.sh +++ b/tests/test_notarize_nightly_dmg.sh @@ -40,6 +40,23 @@ cat > "$FAKE_BIN/xcrun" <<'EOF' #!/usr/bin/env bash set -euo pipefail printf 'xcrun %s\n' "$*" >> "$CMUX_TEST_CALL_LOG" +if [ "${1:-}" = "notarytool" ]; then + key="" key_id="" issuer="" prev="" + for arg in "$@"; do + case "$prev" in + --key) key="$arg" ;; + --key-id) key_id="$arg" ;; + --issuer) issuer="$arg" ;; + --apple-id|--password|--team-id) echo "fake xcrun: Apple ID credentials must not be used" >&2; exit 90 ;; + esac + prev="$arg" + done + [ -f "$key" ] || { echo "fake xcrun: --key file missing" >&2; exit 91; } + [ "$(stat -f %Lp "$key")" = 600 ] || { echo "fake xcrun: --key file must be mode 600" >&2; exit 92; } + [ "$(cat "$key")" = fixture-p8 ] || { echo "fake xcrun: --key file content" >&2; exit 93; } + [ "$key_id" = FIXTUREKEY ] && [ "$issuer" = fixture-issuer ] || { echo "fake xcrun: key id or issuer" >&2; exit 94; } + printf 'notary-key %s\n' "$key" >> "$CMUX_TEST_CALL_LOG" +fi if [ "${1:-}" = "notarytool" ] && [ "${2:-}" = "submit" ]; then printf '{"id":"fixture-id","status":"%s"}\n' "${CMUX_TEST_NOTARY_STATUS:-Accepted}" fi @@ -87,6 +104,8 @@ printf 'notarize-helper %s\n' "$*" >> "$CMUX_TEST_CALL_LOG" EOF chmod +x "$FAKE_BIN"/* +FIXTURE_P8_BASE64="$(printf 'fixture-p8' | base64)" + run_helper() { CMUX_TEST_CALL_LOG="$LOG" \ CMUX_TEST_SOURCE_APP="$APP" \ @@ -103,9 +122,9 @@ run_helper() { CMUX_NOTARIZE_COMPUTER_USE_HELPER_TOOL="$FAKE_BIN/notarize-computer-use-helper" \ CMUX_COMPUTER_USE_NOTARY_SUBMISSION_FILE="$HELPER_STATE" \ CMUX_APP_ENTITLEMENTS="$TMP_DIR/cmux.nightly.entitlements" \ - APPLE_ID=fixture@example.com \ - APPLE_APP_SPECIFIC_PASSWORD=fixture-password \ - APPLE_TEAM_ID=FIXTURETEAM \ + ASC_API_KEY_ID="${TEST_ASC_API_KEY_ID-FIXTUREKEY}" \ + ASC_API_ISSUER_ID="${TEST_ASC_API_ISSUER_ID-fixture-issuer}" \ + ASC_API_KEY_P8_BASE64="${TEST_ASC_API_KEY_P8_BASE64-$FIXTURE_P8_BASE64}" \ APPLE_SIGNING_IDENTITY='Developer ID Application: Fixture' \ "$SCRIPT" "$APP" "$DMG" "$IMMUTABLE" } @@ -118,6 +137,29 @@ if ! grep -Fxq \ echo "FAIL: nightly packaging did not finish the early Computer Use notarization" >&2 exit 1 fi +if ! grep -q '^notary-key ' "$LOG"; then + echo "FAIL: notarytool did not authenticate with the team API key" >&2 + exit 1 +fi +while read -r _ key_path; do + if [ -e "$key_path" ]; then + echo "FAIL: decoded API key was left on disk: $key_path" >&2 + exit 1 + fi +done < <(grep '^notary-key ' "$LOG") +for missing in TEST_ASC_API_KEY_ID TEST_ASC_API_ISSUER_ID TEST_ASC_API_KEY_P8_BASE64; do + before="$(grep -c '^xcrun notarytool ' "$LOG" || true)" + rm -rf "$TMP_DIR/cmux-nightly-mount" + if (export "$missing="; run_helper) >/dev/null 2>&1; then + echo "FAIL: notarization must fail when ${missing#TEST_} is empty" >&2 + exit 1 + fi + if [ "$(grep -c '^xcrun notarytool ' "$LOG" || true)" != "$before" ]; then + echo "FAIL: notarytool ran without ${missing#TEST_}" >&2 + exit 1 + fi +done +echo "PASS: nightly notarization uses the team API key and deletes it" if [ "$(grep -c '^xcrun notarytool submit ' "$LOG")" -ne 1 ]; then echo "FAIL: expected exactly one notarization submission" >&2 exit 1 @@ -212,9 +254,9 @@ CMUX_SMOKE_TOOL="$FAKE_BIN/smoke" \ CMUX_VERIFY_METADATA_TOOL="$FAKE_BIN/metadata" \ CMUX_VERIFY_LICENSES_TOOL="$FAKE_BIN/licenses" \ CMUX_NOTARIZE_COMPUTER_USE_HELPER_TOOL="$FAKE_BIN/notarize-computer-use-helper" \ -APPLE_ID=fixture@example.com \ -APPLE_APP_SPECIFIC_PASSWORD=fixture-password \ -APPLE_TEAM_ID=FIXTURETEAM \ +ASC_API_KEY_ID=FIXTUREKEY \ +ASC_API_ISSUER_ID=fixture-issuer \ +ASC_API_KEY_P8_BASE64="$FIXTURE_P8_BASE64" \ APPLE_SIGNING_IDENTITY='Developer ID Application: Fixture' \ "$SCRIPT" "$RC_APP" "$TMP_DIR/cmux-rc-macos.dmg" "$TMP_DIR/cmux-rc-immutable.dmg" for expected in \ From 835366bc7a4e6e8a50b13c2a7f52aa880ae90963 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 30 Sep 2026 16:42:51 -0700 Subject: [PATCH 2/3] ci: notarize Mac builds with the team App Store Connect API key Release, nightly/RC and the v0.64.25 repair workflow now authenticate notarytool with --key/--key-id/--issuer instead of an Apple ID and app-specific password. scripts/ci/lib/notary-auth.sh decodes ASC_API_KEY_P8_BASE64 into a mode-600 file inside the caller's private temp dir, which an EXIT trap deletes. The notarize scripts fail before any upload when a key value is missing. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/nightly.yml | 12 +++--- .github/workflows/release.yml | 30 +++++++------- .../repair-v0-64-25-helper-rpaths.yml | 40 ++++++++++--------- scripts/ci/lib/notary-auth.sh | 40 +++++++++++++++++++ scripts/ci/notarize-computer-use-helper.sh | 28 ++++++------- scripts/ci/notarize-nightly-dmg.sh | 13 ++++-- skills/cmux-release/SKILL.md | 2 +- 7 files changed, 106 insertions(+), 59 deletions(-) create mode 100644 scripts/ci/lib/notary-auth.sh diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 963df60728d3..4e6e7869b3b3 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -1665,9 +1665,9 @@ jobs: - name: Start Computer Use helper notarization env: - APPLE_ID: ${{ secrets.APPLE_ID }} - APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} - APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + ASC_API_KEY_ID: ${{ secrets.ASC_API_KEY_ID }} + ASC_API_ISSUER_ID: ${{ secrets.ASC_API_ISSUER_ID }} + ASC_API_KEY_P8_BASE64: ${{ secrets.ASC_API_KEY_P8_BASE64 }} APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} run: | state="$RUNNER_TEMP/cmux-computer-use-notarization.state" @@ -1789,9 +1789,9 @@ jobs: - name: Notarize app ticket through final DMG env: - APPLE_ID: ${{ secrets.APPLE_ID }} - APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} - APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + ASC_API_KEY_ID: ${{ secrets.ASC_API_KEY_ID }} + ASC_API_ISSUER_ID: ${{ secrets.ASC_API_ISSUER_ID }} + ASC_API_KEY_P8_BASE64: ${{ secrets.ASC_API_KEY_P8_BASE64 }} APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} CMUX_COMPUTER_USE_NOTARY_SUBMISSION_FILE: ${{ runner.temp }}/cmux-computer-use-notarization.state CMUX_CHANNEL: ${{ needs.decide.outputs.channel }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8cdd7a766254..deccc198c464 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -326,9 +326,9 @@ jobs: - name: Start Computer Use helper notarization if: steps.guard_release_assets.outputs.skip_all != 'true' env: - APPLE_ID: ${{ secrets.APPLE_ID }} - APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} - APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + ASC_API_KEY_ID: ${{ secrets.ASC_API_KEY_ID }} + ASC_API_ISSUER_ID: ${{ secrets.ASC_API_ISSUER_ID }} + ASC_API_KEY_P8_BASE64: ${{ secrets.ASC_API_KEY_P8_BASE64 }} APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} run: | state="$RUNNER_TEMP/cmux-computer-use-notarization.state" @@ -543,15 +543,17 @@ jobs: - name: Notarize app if: steps.guard_release_assets.outputs.skip_all != 'true' env: - APPLE_ID: ${{ secrets.APPLE_ID }} - APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} - APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + ASC_API_KEY_ID: ${{ secrets.ASC_API_KEY_ID }} + ASC_API_ISSUER_ID: ${{ secrets.ASC_API_ISSUER_ID }} + ASC_API_KEY_P8_BASE64: ${{ secrets.ASC_API_KEY_P8_BASE64 }} APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} run: | - if [ -z "$APPLE_ID" ] || [ -z "$APPLE_APP_SPECIFIC_PASSWORD" ] || [ -z "$APPLE_TEAM_ID" ]; then - echo "Missing notarization secrets (APPLE_ID, APPLE_APP_SPECIFIC_PASSWORD, APPLE_TEAM_ID)" >&2 - exit 1 - fi + # notarytool authenticates with the team App Store Connect API key. + # The decoded key stays in a private temp dir removed on exit. + NOTARY_TMP="$(mktemp -d "$RUNNER_TEMP/cmux-notary.XXXXXX")" + trap 'rm -rf "$NOTARY_TMP"' EXIT + source ./scripts/ci/lib/notary-auth.sh + notary_auth_init "$NOTARY_TMP" APP_PATH="build-universal/Build/Products/Release/cmux.app" ZIP_SUBMIT="cmux-notary.zip" DMG_RELEASE="cmux-macos.dmg" @@ -561,12 +563,12 @@ jobs: cmux.release.entitlements \ "$APPLE_SIGNING_IDENTITY" ditto -c -k --sequesterRsrc --keepParent "$APP_PATH" "$ZIP_SUBMIT" - APP_SUBMIT_JSON="$(xcrun notarytool submit "$ZIP_SUBMIT" --apple-id "$APPLE_ID" --team-id "$APPLE_TEAM_ID" --password "$APPLE_APP_SPECIFIC_PASSWORD" --wait --output-format json)" + APP_SUBMIT_JSON="$(xcrun notarytool submit "$ZIP_SUBMIT" "${NOTARY_AUTH_ARGS[@]}" --wait --output-format json)" APP_SUBMIT_ID="$(python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])' <<<"$APP_SUBMIT_JSON")" APP_STATUS="$(python3 -c 'import json,sys; print(json.load(sys.stdin)["status"])' <<<"$APP_SUBMIT_JSON")" if [ "$APP_STATUS" != "Accepted" ]; then echo "App notarization failed with status: $APP_STATUS" >&2 - xcrun notarytool log "$APP_SUBMIT_ID" --apple-id "$APPLE_ID" --team-id "$APPLE_TEAM_ID" --password "$APPLE_APP_SPECIFIC_PASSWORD" || true + xcrun notarytool log "$APP_SUBMIT_ID" "${NOTARY_AUTH_ARGS[@]}" || true exit 1 fi xcrun stapler staple "$APP_PATH" @@ -587,12 +589,12 @@ jobs: --sign "$APPLE_SIGNING_IDENTITY" \ "$DMG_RELEASE" /usr/bin/codesign --verify --verbose=2 "$DMG_RELEASE" - DMG_SUBMIT_JSON="$(xcrun notarytool submit "$DMG_RELEASE" --apple-id "$APPLE_ID" --team-id "$APPLE_TEAM_ID" --password "$APPLE_APP_SPECIFIC_PASSWORD" --wait --output-format json)" + DMG_SUBMIT_JSON="$(xcrun notarytool submit "$DMG_RELEASE" "${NOTARY_AUTH_ARGS[@]}" --wait --output-format json)" DMG_SUBMIT_ID="$(python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])' <<<"$DMG_SUBMIT_JSON")" DMG_STATUS="$(python3 -c 'import json,sys; print(json.load(sys.stdin)["status"])' <<<"$DMG_SUBMIT_JSON")" if [ "$DMG_STATUS" != "Accepted" ]; then echo "DMG notarization failed with status: $DMG_STATUS" >&2 - xcrun notarytool log "$DMG_SUBMIT_ID" --apple-id "$APPLE_ID" --team-id "$APPLE_TEAM_ID" --password "$APPLE_APP_SPECIFIC_PASSWORD" || true + xcrun notarytool log "$DMG_SUBMIT_ID" "${NOTARY_AUTH_ARGS[@]}" || true exit 1 fi xcrun stapler staple "$DMG_RELEASE" diff --git a/.github/workflows/repair-v0-64-25-helper-rpaths.yml b/.github/workflows/repair-v0-64-25-helper-rpaths.yml index 18efe9d602a5..953589a084cb 100644 --- a/.github/workflows/repair-v0-64-25-helper-rpaths.yml +++ b/.github/workflows/repair-v0-64-25-helper-rpaths.yml @@ -124,9 +124,9 @@ jobs: - name: Start helper notarization env: - APPLE_ID: ${{ secrets.APPLE_ID }} - APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} - APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + ASC_API_KEY_ID: ${{ secrets.ASC_API_KEY_ID }} + ASC_API_ISSUER_ID: ${{ secrets.ASC_API_ISSUER_ID }} + ASC_API_KEY_P8_BASE64: ${{ secrets.ASC_API_KEY_P8_BASE64 }} APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} run: | set -euo pipefail @@ -153,9 +153,9 @@ jobs: - name: Finish helper notarization env: - APPLE_ID: ${{ secrets.APPLE_ID }} - APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} - APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + ASC_API_KEY_ID: ${{ secrets.ASC_API_KEY_ID }} + ASC_API_ISSUER_ID: ${{ secrets.ASC_API_ISSUER_ID }} + ASC_API_KEY_P8_BASE64: ${{ secrets.ASC_API_KEY_P8_BASE64 }} APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} run: | set -euo pipefail @@ -167,17 +167,19 @@ jobs: - name: Notarize and staple repaired app env: - APPLE_ID: ${{ secrets.APPLE_ID }} - APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} - APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + ASC_API_KEY_ID: ${{ secrets.ASC_API_KEY_ID }} + ASC_API_ISSUER_ID: ${{ secrets.ASC_API_ISSUER_ID }} + ASC_API_KEY_P8_BASE64: ${{ secrets.ASC_API_KEY_P8_BASE64 }} run: | set -euo pipefail + notary_tmp="$(mktemp -d "$RUNNER_TEMP/cmux-notary.XXXXXX")" + trap 'rm -rf "$notary_tmp"' EXIT + source ./scripts/ci/lib/notary-auth.sh + notary_auth_init "$notary_tmp" zip_path="$REPAIR_ROOT/cmux-notary.zip" ditto -c -k --sequesterRsrc --keepParent "$APP_PATH" "$zip_path" submit_json="$(xcrun notarytool submit "$zip_path" \ - --apple-id "$APPLE_ID" \ - --team-id "$APPLE_TEAM_ID" \ - --password "$APPLE_APP_SPECIFIC_PASSWORD" \ + "${NOTARY_AUTH_ARGS[@]}" \ --wait --output-format json)" status="$(python3 -c 'import json,sys; print(json.load(sys.stdin)["status"])' <<<"$submit_json")" if [ "$status" != Accepted ]; then @@ -191,12 +193,16 @@ jobs: - name: Create and notarize repaired DMG env: - APPLE_ID: ${{ secrets.APPLE_ID }} - APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} - APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + ASC_API_KEY_ID: ${{ secrets.ASC_API_KEY_ID }} + ASC_API_ISSUER_ID: ${{ secrets.ASC_API_ISSUER_ID }} + ASC_API_KEY_P8_BASE64: ${{ secrets.ASC_API_KEY_P8_BASE64 }} APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} run: | set -euo pipefail + notary_tmp="$(mktemp -d "$RUNNER_TEMP/cmux-notary.XXXXXX")" + trap 'rm -rf "$notary_tmp"' EXIT + source ./scripts/ci/lib/notary-auth.sh + notary_auth_init "$notary_tmp" dmg_dir="$REPAIR_ROOT/dmg-output" mkdir -p "$dmg_dir" create-dmg --no-code-sign "$APP_PATH" "$dmg_dir" @@ -207,9 +213,7 @@ jobs: --sign "$APPLE_SIGNING_IDENTITY" "$DMG_PATH" codesign --verify --verbose=2 "$DMG_PATH" submit_json="$(xcrun notarytool submit "$DMG_PATH" \ - --apple-id "$APPLE_ID" \ - --team-id "$APPLE_TEAM_ID" \ - --password "$APPLE_APP_SPECIFIC_PASSWORD" \ + "${NOTARY_AUTH_ARGS[@]}" \ --wait --output-format json)" status="$(python3 -c 'import json,sys; print(json.load(sys.stdin)["status"])' <<<"$submit_json")" if [ "$status" != Accepted ]; then diff --git a/scripts/ci/lib/notary-auth.sh b/scripts/ci/lib/notary-auth.sh new file mode 100644 index 000000000000..e94963800e27 --- /dev/null +++ b/scripts/ci/lib/notary-auth.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# notarytool authentication with the team App Store Connect API key. +# +# Source this file, then call `notary_auth_init ` once. It decodes +# ASC_API_KEY_P8_BASE64 into with mode 600 and sets the +# NOTARY_AUTH_ARGS array for `xcrun notarytool submit|wait|log|history`. +# The caller owns and must delete it on exit (an EXIT trap), so +# the decoded key never outlives the script that used it. +# +# Required environment: ASC_API_KEY_ID, ASC_API_ISSUER_ID, ASC_API_KEY_P8_BASE64. + +NOTARY_AUTH_ARGS=() + +notary_auth_init() { + local key_dir="${1:-}" key_path + if [ -z "$key_dir" ] || [ ! -d "$key_dir" ]; then + echo "notary_auth_init needs an existing private directory" >&2 + return 1 + fi + if [ -z "${ASC_API_KEY_ID:-}" ] \ + || [ -z "${ASC_API_ISSUER_ID:-}" ] \ + || [ -z "${ASC_API_KEY_P8_BASE64:-}" ]; then + echo "Missing notarization secrets (ASC_API_KEY_ID, ASC_API_ISSUER_ID, ASC_API_KEY_P8_BASE64)" >&2 + return 1 + fi + + key_path="$key_dir/notary-key/AuthKey_${ASC_API_KEY_ID}.p8" + ( + umask 077 + mkdir -p "$(dirname "$key_path")" + printf '%s' "$ASC_API_KEY_P8_BASE64" | base64 --decode > "$key_path" + ) + chmod 600 "$key_path" + if [ ! -s "$key_path" ]; then + echo "ASC_API_KEY_P8_BASE64 did not decode to a key" >&2 + return 1 + fi + + NOTARY_AUTH_ARGS=(--key "$key_path" --key-id "$ASC_API_KEY_ID" --issuer "$ASC_API_ISSUER_ID") +} diff --git a/scripts/ci/notarize-computer-use-helper.sh b/scripts/ci/notarize-computer-use-helper.sh index 155eb7402d54..b295a4055ab4 100755 --- a/scripts/ci/notarize-computer-use-helper.sh +++ b/scripts/ci/notarize-computer-use-helper.sh @@ -49,6 +49,8 @@ CODESIGN_TOOL="${CMUX_CODESIGN_TOOL:-/usr/bin/codesign}" SPCTL_TOOL="${CMUX_SPCTL_TOOL:-spctl}" # shellcheck source=lib/notarization-ticket.sh source "$ROOT_DIR/scripts/ci/lib/notarization-ticket.sh" +# shellcheck source=lib/notary-auth.sh +source "$ROOT_DIR/scripts/ci/lib/notary-auth.sh" # Gatekeeper learns about a fresh notarization ticket from Apple's CDN, which # lags the notarytool "Accepted" status: usually by a minute or two, but # nightly run 34208928547 (2026-09-08) was still rejected 4m50s after @@ -100,10 +102,10 @@ if [ ! -f "$HELPER_ENTITLEMENTS" ]; then echo "Computer Use helper entitlements not found: $HELPER_ENTITLEMENTS" >&2 exit 1 fi -if [ -z "${APPLE_ID:-}" ] \ - || [ -z "${APPLE_APP_SPECIFIC_PASSWORD:-}" ] \ - || [ -z "${APPLE_TEAM_ID:-}" ]; then - echo "Missing notarization secrets (APPLE_ID, APPLE_APP_SPECIFIC_PASSWORD, APPLE_TEAM_ID)" >&2 +if [ -z "${ASC_API_KEY_ID:-}" ] \ + || [ -z "${ASC_API_ISSUER_ID:-}" ] \ + || [ -z "${ASC_API_KEY_P8_BASE64:-}" ]; then + echo "Missing notarization secrets (ASC_API_KEY_ID, ASC_API_ISSUER_ID, ASC_API_KEY_P8_BASE64)" >&2 exit 1 fi @@ -112,6 +114,8 @@ cleanup() { rm -rf "$TMP_DIR" } trap cleanup EXIT +# The decoded API key lives in TMP_DIR, which the EXIT trap removes. +notary_auth_init "$TMP_DIR" HELPER_ZIP="$TMP_DIR/cmux-cua-notary.zip" STANDALONE_DIR="$TMP_DIR/standalone" @@ -162,9 +166,7 @@ start_submission() { "$DITTO_TOOL" -c -k --sequesterRsrc --keepParent "$HELPER_PATH" "$HELPER_ZIP" submit_json="$("$XCRUN_TOOL" notarytool submit "$HELPER_ZIP" \ - --apple-id "$APPLE_ID" \ - --team-id "$APPLE_TEAM_ID" \ - --password "$APPLE_APP_SPECIFIC_PASSWORD" \ + "${NOTARY_AUTH_ARGS[@]}" \ --output-format json)" submit_id="$(python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])' <<<"$submit_json")" submit_status="$(python3 -c 'import json,sys; print(json.load(sys.stdin).get("status", "unknown"))' <<<"$submit_json")" @@ -207,9 +209,7 @@ finish_submission() { set +e wait_json="$("$XCRUN_TOOL" notarytool wait "$submit_id" \ - --apple-id "$APPLE_ID" \ - --team-id "$APPLE_TEAM_ID" \ - --password "$APPLE_APP_SPECIFIC_PASSWORD" \ + "${NOTARY_AUTH_ARGS[@]}" \ --output-format json)" wait_status=$? set -e @@ -221,16 +221,12 @@ finish_submission() { if [ "$wait_status" -ne 0 ] || [ "$submit_status" != "Accepted" ]; then echo "Computer Use helper notarization failed with status: $submit_status (wait exit $wait_status)" >&2 "$XCRUN_TOOL" notarytool log "$submit_id" \ - --apple-id "$APPLE_ID" \ - --team-id "$APPLE_TEAM_ID" \ - --password "$APPLE_APP_SPECIFIC_PASSWORD" || true + "${NOTARY_AUTH_ARGS[@]}" || true exit 1 fi "$XCRUN_TOOL" notarytool log "$submit_id" \ - --apple-id "$APPLE_ID" \ - --team-id "$APPLE_TEAM_ID" \ - --password "$APPLE_APP_SPECIFIC_PASSWORD" > "$TMP_DIR/notary-log.json" + "${NOTARY_AUTH_ARGS[@]}" > "$TMP_DIR/notary-log.json" cat "$TMP_DIR/notary-log.json" verify_ticket_contents_cover_slices "$TMP_DIR/notary-log.json" "$HELPER_PATH" "$XCRUN_TOOL" stapler staple "$HELPER_PATH" diff --git a/scripts/ci/notarize-nightly-dmg.sh b/scripts/ci/notarize-nightly-dmg.sh index 1de08df40817..09076b9a4664 100755 --- a/scripts/ci/notarize-nightly-dmg.sh +++ b/scripts/ci/notarize-nightly-dmg.sh @@ -32,13 +32,15 @@ case "$CHANNEL" in ;; esac APP_ENTITLEMENTS="${CMUX_APP_ENTITLEMENTS:-$ROOT_DIR/cmux.${CHANNEL}.entitlements}" +# shellcheck source=lib/notary-auth.sh +source "$ROOT_DIR/scripts/ci/lib/notary-auth.sh" if [ ! -d "$APP_PATH/Contents" ]; then echo "Signed app not found: $APP_PATH" >&2 exit 1 fi -if [ -z "${APPLE_ID:-}" ] || [ -z "${APPLE_APP_SPECIFIC_PASSWORD:-}" ] || [ -z "${APPLE_TEAM_ID:-}" ]; then - echo "Missing notarization secrets (APPLE_ID, APPLE_APP_SPECIFIC_PASSWORD, APPLE_TEAM_ID)" >&2 +if [ -z "${ASC_API_KEY_ID:-}" ] || [ -z "${ASC_API_ISSUER_ID:-}" ] || [ -z "${ASC_API_KEY_P8_BASE64:-}" ]; then + echo "Missing notarization secrets (ASC_API_KEY_ID, ASC_API_ISSUER_ID, ASC_API_KEY_P8_BASE64)" >&2 exit 1 fi if [ -z "${APPLE_SIGNING_IDENTITY:-}" ]; then @@ -61,6 +63,9 @@ cleanup() { rm -rf "$DMG_TMP_DIR" } trap cleanup EXIT +NOTARY_DIR="$DMG_TMP_DIR/notary" +mkdir -m 700 "$NOTARY_DIR" +notary_auth_init "$NOTARY_DIR" if [ -n "$COMPUTER_USE_NOTARY_SUBMISSION_FILE" ]; then "$NOTARIZE_COMPUTER_USE_HELPER_TOOL" \ @@ -96,12 +101,12 @@ fi "$DMG_RELEASE" "$CODESIGN_TOOL" --verify --verbose=2 "$DMG_RELEASE" -DMG_SUBMIT_JSON="$("$XCRUN_TOOL" notarytool submit "$DMG_RELEASE" --apple-id "$APPLE_ID" --team-id "$APPLE_TEAM_ID" --password "$APPLE_APP_SPECIFIC_PASSWORD" --wait --output-format json)" +DMG_SUBMIT_JSON="$("$XCRUN_TOOL" notarytool submit "$DMG_RELEASE" "${NOTARY_AUTH_ARGS[@]}" --wait --output-format json)" DMG_SUBMIT_ID="$(python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])' <<<"$DMG_SUBMIT_JSON")" DMG_STATUS="$(python3 -c 'import json,sys; print(json.load(sys.stdin)["status"])' <<<"$DMG_SUBMIT_JSON")" if [ "$DMG_STATUS" != "Accepted" ]; then echo "DMG notarization failed for $DMG_RELEASE with status: $DMG_STATUS" >&2 - "$XCRUN_TOOL" notarytool log "$DMG_SUBMIT_ID" --apple-id "$APPLE_ID" --team-id "$APPLE_TEAM_ID" --password "$APPLE_APP_SPECIFIC_PASSWORD" || true + "$XCRUN_TOOL" notarytool log "$DMG_SUBMIT_ID" "${NOTARY_AUTH_ARGS[@]}" || true exit 1 fi diff --git a/skills/cmux-release/SKILL.md b/skills/cmux-release/SKILL.md index cbef6adb1a92..5b3e32744a1a 100644 --- a/skills/cmux-release/SKILL.md +++ b/skills/cmux-release/SKILL.md @@ -45,7 +45,7 @@ If the pretag guard fails, run `./scripts/bump-version.sh`, commit the build-num ## Release artifacts and secrets - The release asset is `cmux-macos.dmg`, attached to the tag. The README download button points to `releases/latest/download/cmux-macos.dmg`. -- Signing and notarization require the GitHub secrets `APPLE_CERTIFICATE_BASE64`, `APPLE_CERTIFICATE_PASSWORD`, `APPLE_SIGNING_IDENTITY`, `APPLE_ID`, `APPLE_APP_SPECIFIC_PASSWORD`, `APPLE_TEAM_ID`. +- Signing requires the GitHub secrets `APPLE_CERTIFICATE_BASE64`, `APPLE_CERTIFICATE_PASSWORD` and `APPLE_SIGNING_IDENTITY`. CI notarization authenticates with the team App Store Connect API key (`ASC_API_KEY_ID`, `ASC_API_ISSUER_ID`, `ASC_API_KEY_P8_BASE64`) through `scripts/ci/lib/notary-auth.sh`, which decodes the key to a mode-600 temp file that the caller deletes. The local `scripts/build-sign-upload.sh` still uses an Apple ID and app-specific password. ## Detailed reference From 0050a10edd478a6e8a54a328d98f5df12ea725c6 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 30 Sep 2026 16:56:00 -0700 Subject: [PATCH 3/3] test(ci): read the fake notary key mode on Linux and macOS Co-Authored-By: Claude Opus 5.5 (1M context) --- tests/test_notarize_nightly_dmg.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_notarize_nightly_dmg.sh b/tests/test_notarize_nightly_dmg.sh index 7855e27d6415..4dd37d080f62 100755 --- a/tests/test_notarize_nightly_dmg.sh +++ b/tests/test_notarize_nightly_dmg.sh @@ -52,7 +52,7 @@ if [ "${1:-}" = "notarytool" ]; then prev="$arg" done [ -f "$key" ] || { echo "fake xcrun: --key file missing" >&2; exit 91; } - [ "$(stat -f %Lp "$key")" = 600 ] || { echo "fake xcrun: --key file must be mode 600" >&2; exit 92; } + [ "$(stat -c %a "$key" 2>/dev/null || stat -f %Lp "$key")" = 600 ] || { echo "fake xcrun: --key file must be mode 600" >&2; exit 92; } [ "$(cat "$key")" = fixture-p8 ] || { echo "fake xcrun: --key file content" >&2; exit 93; } [ "$key_id" = FIXTUREKEY ] && [ "$issuer" = fixture-issuer ] || { echo "fake xcrun: key id or issuer" >&2; exit 94; } printf 'notary-key %s\n' "$key" >> "$CMUX_TEST_CALL_LOG"