diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9cf276d..66b4ac2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,8 +5,7 @@ on: branches: - main # Dry-run mode: PRs targeting main that carry the `test-release` label run - # the full release build, but upload the result as a workflow artifact - # instead of touching any GitHub release. + # the release build, but never submit to Apple or modify a GitHub release. pull_request: branches: - main @@ -69,14 +68,12 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} build_release: - name: Build Release DMG + name: Build and Sign Release DMG needs: - changes - draft_release - # Runs when: - # - push to main touching macOS paths (or workflow_dispatch), after a - # successful draft; or - # - a PR into main labeled `test-release` (dry run, bypasses all gates). + # A real release requires all signing and notarization credentials. A PR + # dry run may fall back to the explicitly suffixed unsigned package. if: >- !cancelled() && (needs.changes.result == 'success' || needs.changes.result == 'skipped') @@ -95,33 +92,37 @@ jobs: mode: ${{ steps.mode.outputs.mode }} appcast: ${{ steps.mode.outputs.appcast }} dmg_file: ${{ steps.version.outputs.dmg_file }} - notarization_id: ${{ steps.signed_release.outputs.notarization_id }} - env: - NOTARY_PROFILE: ci-notary steps: - name: Determine signing mode id: mode env: MACOS_CERT_P12: ${{ secrets.MACOS_CERT_P12 }} MACOS_CERT_PASSWORD: ${{ secrets.MACOS_CERT_PASSWORD }} + SIGNING_TEAM_ID: ${{ secrets.SIGNING_TEAM_ID }} NOTARY_KEY_ID: ${{ secrets.NOTARY_KEY_ID }} NOTARY_ISSUER_ID: ${{ secrets.NOTARY_ISSUER_ID }} NOTARY_KEY: ${{ secrets.NOTARY_KEY }} SPARKLE_ED_PRIVATE_KEY: ${{ secrets.SPARKLE_ED_PRIVATE_KEY }} run: | - if [ -n "$MACOS_CERT_P12" ] && [ -n "$MACOS_CERT_PASSWORD" ] \ - && [ -n "$NOTARY_KEY_ID" ] && [ -n "$NOTARY_ISSUER_ID" ] \ - && [ -n "$NOTARY_KEY" ]; then - echo "mode=signed" >> "$GITHUB_OUTPUT" - else + set -eu + if [ "$GITHUB_EVENT_NAME" = "pull_request" ]; then echo "mode=unsigned" >> "$GITHUB_OUTPUT" - echo "::notice::Apple signing secrets are not configured; building an unsigned release." - fi - if [ -n "$SPARKLE_ED_PRIVATE_KEY" ]; then - echo "appcast=true" >> "$GITHUB_OUTPUT" - else echo "appcast=false" >> "$GITHUB_OUTPUT" - echo "::notice::SPARKLE_ED_PRIVATE_KEY is not configured; skipping appcast generation (auto-update feed)." + echo "::notice::PR release dry runs build an unsigned artifact and never submit to Apple." + else + if [ -z "$MACOS_CERT_P12" ] || [ -z "$MACOS_CERT_PASSWORD" ] \ + || [ -z "$SIGNING_TEAM_ID" ] || [ -z "$NOTARY_KEY_ID" ] \ + || [ -z "$NOTARY_ISSUER_ID" ] || [ -z "$NOTARY_KEY" ]; then + echo "::error::A real release requires MACOS_CERT_P12, MACOS_CERT_PASSWORD, SIGNING_TEAM_ID, NOTARY_KEY, NOTARY_KEY_ID, and NOTARY_ISSUER_ID." + exit 1 + fi + echo "mode=signed" >> "$GITHUB_OUTPUT" + if [ -n "$SPARKLE_ED_PRIVATE_KEY" ]; then + echo "appcast=true" >> "$GITHUB_OUTPUT" + else + echo "appcast=false" >> "$GITHUB_OUTPUT" + echo "::notice::SPARKLE_ED_PRIVATE_KEY is not configured; skipping appcast generation." + fi fi - name: Checkout Code @@ -151,11 +152,7 @@ jobs: printf '%s\n' "$version" | grep -Eq '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$' \ || { echo "error: drafted tag '$TAG_NAME' is not a strict semver tag" >&2; exit 1; } echo "APP_VERSION=$version" >> "$GITHUB_ENV" - # Sparkle compares CFBundleVersion, so the build number must increase - # with every published release; the release semver satisfies that. echo "BUILD_NUMBER=$version" >> "$GITHUB_ENV" - # Unsigned builds carry an explicit suffix so nobody mistakes them - # for notarized artifacts; the manifest also records build_flavor. suffix="" [ "$MODE" = "signed" ] || suffix="-unsigned" echo "DMG_NAME=ForgeCode-$version$suffix" >> "$GITHUB_ENV" @@ -186,96 +183,39 @@ jobs: echo "SIGNING_IDENTITY=$identity" >> "$GITHUB_ENV" echo "RELEASE_KEYCHAIN=$keychain" >> "$GITHUB_ENV" - - name: Store notarization credentials - if: steps.mode.outputs.mode == 'signed' - env: - NOTARY_KEY: ${{ secrets.NOTARY_KEY }} - NOTARY_KEY_ID: ${{ secrets.NOTARY_KEY_ID }} - NOTARY_ISSUER_ID: ${{ secrets.NOTARY_ISSUER_ID }} - run: | - set -eu - key_file="$RUNNER_TEMP/notary-key.p8" - printf '%s\n' "$NOTARY_KEY" > "$key_file" - xcrun notarytool store-credentials "$NOTARY_PROFILE" \ - --key "$key_file" \ - --key-id "$NOTARY_KEY_ID" \ - --issuer "$NOTARY_ISSUER_ID" - rm -f "$key_file" - - - name: Build, sign, and submit DMG for notarization - id: signed_release + - name: Build and sign DMG if: steps.mode.outputs.mode == 'signed' working-directory: macos env: SIGNING_TEAM_ID: ${{ secrets.SIGNING_TEAM_ID }} - run: | - set -eu - scripts/release.sh - submission_id=$(plutil -extract id raw -o - .build-artifacts/notarization-logs/dmg-submit.json) - echo "notarization_id=$submission_id" >> "$GITHUB_OUTPUT" - { - echo "## Apple notarization submitted" - echo "" - echo "- Submission ID: \`$submission_id\`" - echo "- DMG: \`$DMG_NAME.dmg\`" - echo "- Status check: \`xcrun notarytool info $submission_id --keychain-profile \`" - echo "" - echo "The workflow does not wait for Apple. Verify that this submission is Accepted before publishing the draft release." - } >> "$GITHUB_STEP_SUMMARY" + run: scripts/release.sh - - name: Build unsigned release + - name: Build unsigned dry-run release if: steps.mode.outputs.mode == 'unsigned' working-directory: macos env: BUILD_FLAVOR: unsigned run: scripts/package-unsigned.sh - - name: Generate appcast - if: steps.mode.outputs.appcast == 'true' - env: - SPARKLE_ED_PRIVATE_KEY: ${{ secrets.SPARKLE_ED_PRIVATE_KEY }} - TAG_NAME: ${{ github.event_name == 'pull_request' && 'v0.0.0' || needs.draft_release.outputs.tag_name }} - MODE: ${{ steps.mode.outputs.mode }} + - name: Package signed candidate for job transfer + if: github.event_name != 'pull_request' && steps.mode.outputs.mode == 'signed' run: | - set -eu - dist="macos/dist/$MODE" - dmg="$dist/$DMG_NAME.dmg" - signature=$(echo "$SPARKLE_ED_PRIVATE_KEY" \ - | ./macos/Vendor/Sparkle/bin/sign_update --ed-key-file - -p "$dmg") - length=$(stat -f %z "$dmg") - download_url="https://github.com/$GITHUB_REPOSITORY/releases/download/$TAG_NAME/$DMG_NAME.dmg" - minimum_macos=$(sed -n 's/^minimum_macos=//p' "$dist/manifest.txt") - published=$(date -u '+%a, %d %b %Y %H:%M:%S +0000') - cat > "$dist/appcast.xml" < - - - ForgeCode - - ForgeCode $APP_VERSION - https://github.com/$GITHUB_REPOSITORY/releases/tag/$TAG_NAME - $BUILD_NUMBER - $APP_VERSION - $minimum_macos - $published - - - - - XML - # Strip the heredoc indentation so the XML declaration is at column 0. - sed -i '' 's/^ //' "$dist/appcast.xml" - xmllint --noout "$dist/appcast.xml" + set -euo pipefail + rm -rf release-transfer + mkdir -p release-transfer + tar -C macos/dist -czf release-transfer/signed-release.tar.gz signed - - name: Upload notarization submission receipt - if: steps.mode.outputs.mode == 'signed' + - name: Upload signed candidate for notarization + if: github.event_name != 'pull_request' && steps.mode.outputs.mode == 'signed' uses: actions/upload-artifact@v4 with: - name: notarization-submission - path: macos/.build-artifacts/notarization-logs/dmg-submit.json + name: signed-release-candidate + path: release-transfer/signed-release.tar.gz if-no-files-found: error + retention-days: 1 - - name: Stage release artifacts + - name: Stage dry-run artifacts + if: github.event_name == 'pull_request' env: MODE: ${{ steps.mode.outputs.mode }} run: | @@ -283,29 +223,24 @@ jobs: dist="macos/dist/$MODE" rm -rf release-artifacts mkdir -p release-artifacts - # Only the DMG and the Sparkle feed are published. manifest.txt and - # SHA256SUMS stay build-internal: they are inputs to - # verify-release.sh, which has already run against this build, and - # SHA256SUMS mostly hashes files *inside* the bundle, which a - # downloader cannot check without mounting the DMG first. GitHub - # exposes a SHA-256 digest for every release asset anyway. cp "$dist/$DMG_NAME.dmg" release-artifacts/ if [ -f "$dist/appcast.xml" ]; then cp "$dist/appcast.xml" release-artifacts/ fi - - name: Upload staged artifacts - id: upload + - name: Upload dry-run artifacts + id: dry_run_upload + if: github.event_name == 'pull_request' uses: actions/upload-artifact@v4 with: - name: release-macos + name: release-macos-dry-run path: release-artifacts/* if-no-files-found: error - name: Report dry-run artifact if: github.event_name == 'pull_request' env: - ARTIFACT_URL: ${{ steps.upload.outputs.artifact-url }} + ARTIFACT_URL: ${{ steps.dry_run_upload.outputs.artifact-url }} MODE: ${{ steps.mode.outputs.mode }} run: | echo "::notice::Release dry run complete ($MODE mode); artifact: $ARTIFACT_URL" @@ -316,65 +251,279 @@ jobs: echo "- DMG: \`$DMG_NAME.dmg\`" echo "- Artifact: $ARTIFACT_URL" echo "" - echo "No release was created or modified." + echo "No Apple notarization was submitted and no GitHub release was modified." } >> "$GITHUB_STEP_SUMMARY" - name: Clean up keychain if: always() run: | + rm -f "$RUNNER_TEMP/cert.p12" if [ -n "${RELEASE_KEYCHAIN:-}" ] && [ -f "$RELEASE_KEYCHAIN" ]; then security delete-keychain "$RELEASE_KEYCHAIN" fi - upload_release_artifacts: - name: Upload Release Artifacts + notarize_release: + name: Submit and Wait for Notarization + needs: + - build_release + if: >- + !cancelled() + && github.event_name != 'pull_request' + && needs.build_release.result == 'success' + && needs.build_release.outputs.mode == 'signed' + runs-on: [self-hosted, linux, x64] + timeout-minutes: 2880 + permissions: + contents: read + env: + DMG_FILE: ${{ needs.build_release.outputs.dmg_file }} + steps: + - name: Download signed candidate + uses: actions/download-artifact@v4 + with: + name: signed-release-candidate + path: release-transfer + + - name: Extract and validate candidate + run: | + set -euo pipefail + test -f release-transfer/signed-release.tar.gz + mkdir -p candidate + tar -xzf release-transfer/signed-release.tar.gz -C candidate + test -f "candidate/signed/$DMG_FILE" + test -f candidate/signed/manifest.txt + test -f candidate/signed/SHA256SUMS + find candidate/signed -maxdepth 1 -type f -print | sort + sha256sum "candidate/signed/$DMG_FILE" | tee release-transfer/pre-notarization.sha256 + + - name: Materialize App Store Connect API key + env: + NOTARY_KEY: ${{ secrets.NOTARY_KEY }} + NOTARY_KEY_ID: ${{ secrets.NOTARY_KEY_ID }} + NOTARY_ISSUER_ID: ${{ secrets.NOTARY_ISSUER_ID }} + run: | + set -euo pipefail + umask 077 + python3 - <<'PY' + import base64 + import json + import os + import pathlib + + pem = os.environ["NOTARY_KEY"].strip().replace("\\n", "\n").splitlines() + if pem[0] != "-----BEGIN PRIVATE KEY-----" or pem[-1] != "-----END PRIVATE KEY-----": + raise SystemExit("NOTARY_KEY must contain a PKCS#8 PEM private key") + encoded = "".join(line.strip() for line in pem[1:-1]) + der = base64.b64decode(encoded, validate=True) + if not der: + raise SystemExit("NOTARY_KEY contains an empty private key") + payload = { + "issuer_id": os.environ["NOTARY_ISSUER_ID"], + "key_id": os.environ["NOTARY_KEY_ID"], + "private_key": base64.b64encode(der).decode("ascii"), + } + pathlib.Path("app-store-connect-key.json").write_text( + json.dumps(payload), encoding="utf-8" + ) + PY + chmod 0600 app-store-connect-key.json + + - name: Submit DMG and wait for Apple acceptance + uses: indygreg/apple-code-sign-action@5e7a4a01fe4dffe948ef3bcf1f4538d963cb0c72 # v1.2 + with: + input_path: candidate/signed/${{ needs.build_release.outputs.dmg_file }} + sign: false + notarize: true + staple: false + app_store_connect_api_key_json_file: app-store-connect-key.json + rcodesign_version: 0.29.0 + + - name: Package the accepted unstapled candidate + run: | + set -euo pipefail + sha256sum -c release-transfer/pre-notarization.sha256 + rm -f release-transfer/notarized-release.tar.gz + tar -C candidate -czf release-transfer/notarized-release.tar.gz signed + + - name: Upload accepted unstapled candidate + uses: actions/upload-artifact@v4 + with: + name: notarized-release-candidate + path: release-transfer/notarized-release.tar.gz + if-no-files-found: error + retention-days: 1 + + - name: Remove App Store Connect credentials + if: always() + run: rm -f app-store-connect-key.json + + finalize_release: + name: Staple and Validate Release DMG needs: - draft_release - build_release + - notarize_release if: >- !cancelled() && github.event_name != 'pull_request' && needs.draft_release.result == 'success' && needs.build_release.result == 'success' + && needs.notarize_release.result == 'success' + runs-on: macos-latest + timeout-minutes: 20 + permissions: + contents: read + outputs: + appcast: ${{ needs.build_release.outputs.appcast }} + dmg_file: ${{ needs.build_release.outputs.dmg_file }} + env: + BUILD_FLAVOR: signed + DMG_FILE: ${{ needs.build_release.outputs.dmg_file }} + TAG_NAME: ${{ needs.draft_release.outputs.tag_name }} + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Restore accepted candidate + uses: actions/download-artifact@v4 + with: + name: notarized-release-candidate + path: release-transfer + + - name: Extract accepted candidate + run: | + set -euo pipefail + rm -rf macos/dist/signed + tar -xzf release-transfer/notarized-release.tar.gz -C macos/dist + test -f "macos/dist/signed/$DMG_FILE" + + - name: Normalize release environment + run: | + set -euo pipefail + version="${TAG_NAME#v}" + { + echo "APP_VERSION=$version" + echo "BUILD_NUMBER=$version" + echo "DMG_NAME=${DMG_FILE%.dmg}" + } >> "$GITHUB_ENV" + + - name: Staple and explicitly validate DMG + working-directory: macos + env: + SIGNING_TEAM_ID: ${{ secrets.SIGNING_TEAM_ID }} + run: scripts/staple-dmg.sh + + - name: Vendor Sparkle for final appcast + if: needs.build_release.outputs.appcast == 'true' + working-directory: macos + run: tools/fetch-sparkle.sh + + - name: Generate appcast from final stapled DMG bytes + if: needs.build_release.outputs.appcast == 'true' + env: + SPARKLE_ED_PRIVATE_KEY: ${{ secrets.SPARKLE_ED_PRIVATE_KEY }} + run: | + set -eu + dist="macos/dist/signed" + dmg="$dist/$DMG_FILE" + signature=$(echo "$SPARKLE_ED_PRIVATE_KEY" \ + | ./macos/Vendor/Sparkle/bin/sign_update --ed-key-file - -p "$dmg") + length=$(stat -f %z "$dmg") + minimum_macos=$(sed -n 's/^minimum_macos=//p' "$dist/manifest.txt") + published=$(date -u '+%a, %d %b %Y %H:%M:%S +0000') + cat > "$dist/appcast.xml" < + + + ForgeCode + + ForgeCode $APP_VERSION + https://github.com/$GITHUB_REPOSITORY/releases/tag/$TAG_NAME + $BUILD_NUMBER + $APP_VERSION + $minimum_macos + $published + + + + + XML + sed -i '' 's/^ //' "$dist/appcast.xml" + xmllint --noout "$dist/appcast.xml" + + - name: Stage only final release artifacts + run: | + set -euo pipefail + dist="macos/dist/signed" + rm -rf release-artifacts + mkdir -p release-artifacts + cp "$dist/$DMG_FILE" release-artifacts/ + if [ -f "$dist/appcast.xml" ]; then + cp "$dist/appcast.xml" release-artifacts/ + fi + + - name: Upload final notarized release artifacts + uses: actions/upload-artifact@v4 + with: + name: release-macos-final + path: release-artifacts/* + if-no-files-found: error + + upload_release_artifacts: + name: Upload Release Artifacts + needs: + - draft_release + - finalize_release + if: >- + !cancelled() + && github.event_name != 'pull_request' + && needs.draft_release.result == 'success' + && needs.finalize_release.result == 'success' runs-on: ubuntu-latest timeout-minutes: 10 permissions: contents: write env: TAG_NAME: ${{ needs.draft_release.outputs.tag_name }} - DMG_FILE: ${{ needs.build_release.outputs.dmg_file }} - HAS_APPCAST: ${{ needs.build_release.outputs.appcast }} + DMG_FILE: ${{ needs.finalize_release.outputs.dmg_file }} + HAS_APPCAST: ${{ needs.finalize_release.outputs.appcast }} steps: - - name: Download staged artifacts + - name: Download final staged artifacts uses: actions/download-artifact@v4 with: - name: release-macos + name: release-macos-final path: release-artifacts - - name: Validate complete artifact set + - name: Validate complete final artifact set run: | set -euo pipefail cd release-artifacts - expected=( - "$DMG_FILE" - ) + expected=("$DMG_FILE") if [ "$HAS_APPCAST" = "true" ]; then expected+=("appcast.xml") fi for file in "${expected[@]}"; do - test -f "$file" || { echo "missing expected artifact: $file" >&2; exit 1; } + test -f "$file" || { echo "missing expected final artifact: $file" >&2; exit 1; } done actual_count=$(find . -maxdepth 1 -type f | wc -l | tr -d ' ') expected_count=${#expected[@]} if [ "$actual_count" != "$expected_count" ]; then - echo "expected $expected_count artifacts, found $actual_count" >&2 + echo "expected $expected_count final artifacts, found $actual_count" >&2 find . -maxdepth 1 -type f -print | sort >&2 exit 1 fi - - name: Upload artifacts to draft release + - name: Upload final artifacts to draft release env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | + set -euo pipefail + release=$(gh release view "$TAG_NAME" --repo "$GITHUB_REPOSITORY" --json isDraft,assets) + [ "$(jq -r '.isDraft' <<< "$release")" = "true" ] \ + || { echo "target release is not a draft: $TAG_NAME" >&2; exit 1; } + jq -r '.assets[].name | select(. == "appcast.xml" or (startswith("ForgeCode-") and endswith(".dmg")))' <<< "$release" \ + | while IFS= read -r asset; do + gh release delete-asset "$TAG_NAME" "$asset" --repo "$GITHUB_REPOSITORY" --yes + done find release-artifacts -maxdepth 1 -type f -print0 \ - | xargs -0 gh release upload "$TAG_NAME" --repo "$GITHUB_REPOSITORY" --clobber + | xargs -0 gh release upload "$TAG_NAME" --repo "$GITHUB_REPOSITORY" diff --git a/macos/README.md b/macos/README.md index 98d418b..225e109 100644 --- a/macos/README.md +++ b/macos/README.md @@ -99,18 +99,17 @@ The active app does not poll usage, extensions, models, or providers. Readiness ## Signed release -Create a Developer ID Application certificate and a `notarytool` keychain profile, then run: +Create a Developer ID Application certificate, then run the local build/sign stage: ```sh export SIGNING_IDENTITY='Developer ID Application: Example Corp (TEAMID)' export SIGNING_TEAM_ID='TEAMID' -export NOTARY_PROFILE='forge-menubar-notary' scripts/release.sh ``` -The release pipeline validates the universal application slices, minimum OS, dependencies, and exact runtime-free payload inventory; replaces the development seal by signing the application executable and bundle with hardened runtime; creates and signs the DMG; compares the complete mounted app bundle; validates the `/Applications` symlink; and atomically writes and verifies the exact manifest/checksum inventory. It submits only the signed DMG to Apple and returns as soon as `notarytool` supplies a submission ID. It does not submit the app separately, wait for acceptance, or staple the artifact. CI records the submission ID and uploads `dmg-submit.json`; confirm that Apple reports `Accepted` before publishing the draft release. Real signing credentials are not needed for development: `scripts/package-unsigned.sh` runs the ad-hoc validation path. +The release pipeline validates the universal application slices, minimum OS, dependencies, and exact runtime-free payload inventory; replaces the development seal by signing the application executable and bundle with hardened runtime; creates and signs the DMG; compares the complete mounted app bundle; validates the `/Applications` symlink; and atomically writes and verifies the exact manifest/checksum inventory. CI submits only the signed DMG from Linux with pinned `rcodesign`, waits for Apple acceptance, then returns to macOS to staple and comprehensively validate the final DMG before uploading it and the optional final appcast to the draft release. Real signing credentials are not needed for development: `scripts/package-unsigned.sh` runs the ad-hoc validation path. -Useful individual commands are `scripts/assemble-app.sh`, `scripts/sign-app.sh`, `scripts/create-dmg.sh`, `scripts/sign-dmg.sh`, `scripts/notarize-dmg.sh`, and `scripts/verify-release.sh`. `APP_VERSION`, `BUILD_NUMBER`, and `MINIMUM_MACOS_VERSION` accept numeric dot-separated plist versions only. +Useful individual commands are `scripts/assemble-app.sh`, `scripts/sign-app.sh`, `scripts/create-dmg.sh`, `scripts/sign-dmg.sh`, `scripts/staple-dmg.sh`, and `scripts/verify-release.sh`. `APP_VERSION`, `BUILD_NUMBER`, and `MINIMUM_MACOS_VERSION` accept numeric dot-separated plist versions only. ## Current limitations and security diff --git a/macos/scripts/release.sh b/macos/scripts/release.sh index e6d9c5f..463023a 100755 --- a/macos/scripts/release.sh +++ b/macos/scripts/release.sh @@ -1,12 +1,15 @@ #!/bin/sh set -eu +# Build and Developer ID sign the release DMG. Notarization is deliberately +# performed by the release workflow on Linux after this script completes, and +# stapling/final validation are deliberately performed by a later macOS job. + SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) BUILD_FLAVOR=signed export BUILD_FLAVOR . "$SCRIPT_DIR/common.sh" [ -n "${SIGNING_IDENTITY:-}" ] || fail "SIGNING_IDENTITY is required" -[ -n "${NOTARY_PROFILE:-}" ] || fail "NOTARY_PROFILE is required" packaging_preflight run_tests @@ -15,6 +18,6 @@ sh "$SCRIPT_DIR/sign-app.sh" sh "$SCRIPT_DIR/create-dmg.sh" sh "$SCRIPT_DIR/sign-dmg.sh" VERIFY_SIGNATURES=1 VERIFY_DMG_NOTARIZATION=0 sh "$SCRIPT_DIR/verify-release.sh" -sh "$SCRIPT_DIR/notarize-dmg.sh" -printf 'Release submitted: %s\nManifest: %s\nChecksums: %s\nNotarization submission: %s\n' "$DMG_PATH" "$MANIFEST_PATH" "$CHECKSUMS_PATH" "$NOTARIZATION_LOG_DIR/dmg-submit.json" +printf 'Signed release ready for notarization: %s\nManifest: %s\nChecksums: %s\n' \ + "$DMG_PATH" "$MANIFEST_PATH" "$CHECKSUMS_PATH" diff --git a/macos/scripts/staple-dmg.sh b/macos/scripts/staple-dmg.sh new file mode 100755 index 0000000..852d509 --- /dev/null +++ b/macos/scripts/staple-dmg.sh @@ -0,0 +1,25 @@ +#!/bin/sh +set -eu + +# Staple Apple's already-issued ticket to a previously signed and accepted DMG, +# then validate the exact final bytes that are eligible for publication. + +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +BUILD_FLAVOR=signed +export BUILD_FLAVOR +. "$SCRIPT_DIR/common.sh" + +require_command xcrun + +[ -f "$DMG_PATH" ] || fail "accepted DMG is missing: $DMG_PATH" +[ "$(signature_kind "$DMG_PATH")" = "developer-id" ] \ + || fail "accepted DMG is not Developer ID signed" + +xcrun stapler staple "$DMG_PATH" + +# Stapling changes the DMG bytes, so checksums must be regenerated only after +# the ticket is attached. The comprehensive verifier validates the final DMG. +finalize_release_metadata +VERIFY_SIGNATURES=1 VERIFY_DMG_NOTARIZATION=1 sh "$SCRIPT_DIR/verify-release.sh" + +printf 'Stapled and validated release DMG: %s\n' "$DMG_PATH"