diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 36fc682..6fd2942 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,6 +28,8 @@ jobs: - uses: actions/checkout@v5 - name: Toolchain run: swift --version + - name: Test release credential setup + run: python3 scripts/tests/test_release_credentials.py - name: Test core and macOS application behavior shell: bash run: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..a83a724 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,133 @@ +name: Build Release + +on: + push: + tags: + - 'v*' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +jobs: + build: + name: Build MacDiff for macOS + if: github.event.deleted != true + runs-on: macos-14 + timeout-minutes: 75 + env: + DEVELOPER_DIR: /Applications/Xcode_16.2.app/Contents/Developer + defaults: + run: + shell: bash + steps: + - name: Check out source + uses: actions/checkout@v5 + with: + persist-credentials: false + + - name: Toolchain + run: swift --version + + - name: Install release signing and notarization credentials + if: github.ref_type == 'tag' && startsWith(github.ref_name, 'v') + env: + BUILD_CERTIFICATE_BASE64: ${{ secrets.BUILD_CERTIFICATE_BASE64 }} + P12_PASSWORD: ${{ secrets.P12_PASSWORD }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} + run: bash scripts/setup-release-keychain.sh + + - name: Test, build, sign, and notarize tagged release + if: github.ref_type == 'tag' && startsWith(github.ref_name, 'v') + run: ./scripts/release.sh + + - name: Test and package branch build + if: github.ref_type != 'tag' || !startsWith(github.ref_name, 'v') + run: | + swift test --jobs 3 + MACDIFF_UNIVERSAL=1 ./scripts/build-app.sh release + APP_PATH=dist/MacDiff.app + for ARCH in arm64 x86_64; do + lipo "$APP_PATH/Contents/MacOS/MacDiff" -verify_arch "$ARCH" + done + codesign --verify --deep --strict --verbose=2 "$APP_PATH" + mkdir -p dist/releases + ditto -c -k --sequesterRsrc --keepParent "$APP_PATH" dist/releases/MacDiff-macOS.zip + (cd dist/releases && shasum -a 256 MacDiff-macOS.zip > MacDiff-macOS.zip.sha256) + + - name: Upload release app + uses: actions/upload-artifact@v4 + with: + name: MacDiff-macOS + path: | + dist/releases/*.zip + dist/releases/*.zip.sha256 + if-no-files-found: error + retention-days: 30 + + - name: Upload notarization diagnostics + if: always() && github.ref_type == 'tag' && startsWith(github.ref_name, 'v') + uses: actions/upload-artifact@v4 + with: + name: MacDiff-notarization + include-hidden-files: true + path: | + dist/releases/*-notarization.json + dist/.release.*/submission.json + dist/.release.*/notarization-log.json + dist/.release.*/tests.log + dist/.release.*/build.log + if-no-files-found: ignore + retention-days: 30 + + - name: Remove release credentials + if: always() && github.ref_type == 'tag' && startsWith(github.ref_name, 'v') + run: | + KEYCHAIN_PATH="$RUNNER_TEMP/macdiff-signing.keychain-db" + if [[ -f "$KEYCHAIN_PATH" ]]; then + security delete-keychain "$KEYCHAIN_PATH" + fi + rm -f "$RUNNER_TEMP/macdiff-signing.p12" + + publish: + name: Publish GitHub Release + needs: build + if: github.ref_type == 'tag' && startsWith(github.ref_name, 'v') + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: write + steps: + - name: Download verified release app + uses: actions/download-artifact@v4 + with: + name: MacDiff-macOS + path: release + + - name: Publish app on GitHub Releases + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + RELEASE_TAG: ${{ github.ref_name }} + shell: bash + run: | + ASSETS=(release/*.zip release/*.zip.sha256) + if gh release view "$RELEASE_TAG" >/dev/null 2>&1; then + gh release upload "$RELEASE_TAG" "${ASSETS[@]}" --clobber + else + RELEASE_FLAGS=() + if [[ "$RELEASE_TAG" == *-* ]]; then + RELEASE_FLAGS+=(--prerelease) + fi + gh release create "$RELEASE_TAG" "${ASSETS[@]}" \ + --verify-tag \ + --title "MacDiff $RELEASE_TAG" \ + --generate-notes \ + "${RELEASE_FLAGS[@]}" + fi diff --git a/README.md b/README.md index f06e6c1..440333a 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,72 @@ To install the built app, quit MacDiff, then copy `dist/MacDiff.app` into `/Appl The build converts `icon/icon.png` into the macOS app icon, including standard and Retina sizes. To update the icon, replace that square PNG (at least 1024 × 1024 pixels) and rebuild the app bundle. -## Signed and notarized releases +## GitHub releases + +Push a tag starting with `v` to build and publish a notarized GitHub Release: + +```sh +git tag v1.0 +git push origin v1.0 +``` + +The **Build Release** workflow runs the tests and uses `scripts/release.sh` to +build a universal Intel/Apple Silicon app, sign it with Developer ID, notarize +it, staple the ticket, and verify the final ZIP after extracting it. Only a +successful verified build reaches the publishing job. The release contains +the ZIP and its SHA-256 checksum, with generated release notes. Missing secrets, +failed notarization, or failed verification prevent publication. + +Use a tag matching `CFBundleShortVersionString` in `Resources/Info.plist`, with +an optional prerelease suffix (for example, `v1.0-rc.1`). Tags containing a +hyphen create GitHub prereleases. The bundle version still comes from the +plist; the tag does not rewrite it. About continues to show the source commit +and build timestamp. Rerunning a successful tag build updates its release assets. + +For a manual test without Apple credentials, choose **Actions → Build Release → +Run workflow** on a branch. The resulting **MacDiff-macOS** artifact contains an +ad hoc signed universal build; it is not notarized and does not create a GitHub +Release. Run the workflow on a `v*` tag to use the notarized release path. + +### One-time Actions secrets + +Add these repository secrets in [MacDiff Actions secrets](https://github.com/stuartd/macdiff/settings/secrets/actions) +before pushing a release tag: + +| Secret | Value | +| --- | --- | +| `BUILD_CERTIFICATE_BASE64` | Base64-encoded `.p12` export of one **Developer ID Application** certificate and its private key. | +| `P12_PASSWORD` | Password chosen when exporting that `.p12`. | +| `APPLE_ID` | Apple Account email used for notarization. | +| `APPLE_TEAM_ID` | Apple Developer team ID (currently `ZQ5KWSZ72K`). | +| `APPLE_APP_SPECIFIC_PASSWORD` | App-specific password generated at [account.apple.com](https://account.apple.com/). | + +The certificate and Apple account used for ClipDiff can be reused. Repository +secrets are separate: add them to MacDiff too, or grant MacDiff access to the +corresponding organization secrets. A local `notarytool` Keychain profile does +not transfer to a GitHub runner. An **Apple Development** certificate is not +suitable; export the **Developer ID Application** identity, including its private +key, from **My Certificates** in Keychain Access. + +For example, upload the certificate directly without printing or copying it to +the clipboard, then enter the remaining values at the CLI prompts: + +```sh +base64 -i /path/to/DeveloperID.p12 | gh secret set BUILD_CERTIFICATE_BASE64 --repo stuartd/macdiff +gh secret set P12_PASSWORD --repo stuartd/macdiff +gh secret set APPLE_ID --repo stuartd/macdiff +gh secret set APPLE_TEAM_ID --repo stuartd/macdiff +gh secret set APPLE_APP_SPECIFIC_PASSWORD --repo stuartd/macdiff +``` + +The workflow imports the certificate into a temporary Keychain, validates the +notarization credentials before building, and removes the Keychain at the end +of the job, including on failure. Submission results and available failure logs +are retained as a **MacDiff-notarization** workflow artifact. Publishing uses +GitHub's built-in token; no personal access token is needed. Never commit a +certificate, private key, password, or secret value. + +## Local signed and notarized releases Local builds use ad hoc signing. To distribute a download, install a **Developer ID Application** certificate and its private key in Keychain, and save notarization credentials once: diff --git a/scripts/release.sh b/scripts/release.sh index 7ed5d9a..c947844 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -30,13 +30,33 @@ if [[ -e "$final_zip" ]]; then exit 1 fi -# Check Keychain access before spending time building. No credential values are printed. -identities="$(security find-identity -v -p codesigning)" -if ! printf '%s\n' "$identities" | grep -F 'Developer ID Application:' | grep -Fq -- "$SIGNING_IDENTITY"; then - echo "Developer ID signing identity not found: $SIGNING_IDENTITY" >&2 - exit 1 +# An isolated CI Keychain is optional; local releases keep using the user's Keychain. +identity_args=(-v -p codesigning) +notary_args=(--keychain-profile "$NOTARY_PROFILE") +signing_args=(--options runtime --timestamp) +if [[ -n "${SIGNING_KEYCHAIN:-}" ]]; then + identity_args+=("$SIGNING_KEYCHAIN") + notary_args+=(--keychain "$SIGNING_KEYCHAIN") + signing_args+=(--keychain "$SIGNING_KEYCHAIN") fi -xcrun notarytool history --keychain-profile "$NOTARY_PROFILE" --output-format json >/dev/null + +# Check Keychain access before spending time building. Match exactly one +# Developer ID Application identity, by its full name or SHA-1. +identities="$(security find-identity "${identity_args[@]}")" +identity_hash="$(printf '%s\n' "$identities" | awk -v identity="$SIGNING_IDENTITY" ' + /"Developer ID Application:/ { + hash = $2 + name = $0 + sub(/^[^"]*"/, "", name) + sub(/"[[:space:]]*$/, "", name) + if (hash == identity || name == identity) { print hash; count++ } + } + END { if (count != 1) exit 1 } +')" || { + echo "Expected exactly one valid Developer ID Application identity matching SIGNING_IDENTITY." >&2 + exit 1 +} +xcrun notarytool history "${notary_args[@]}" --output-format json >/dev/null mkdir -p "$release_dir" work_dir="$(mktemp -d "$project_root/dist/.release.XXXXXX")" @@ -58,25 +78,26 @@ if [[ "$(git rev-parse HEAD)" != "$commit" || -n "$(git status --porcelain --unt echo "Source changed during the release build; start again from committed source." >&2 exit 1 fi -/usr/bin/codesign --force --sign "$SIGNING_IDENTITY" --options runtime --timestamp "$app" +/usr/bin/codesign --force --sign "$identity_hash" "${signing_args[@]}" "$app" /usr/bin/codesign --verify --deep --strict --verbose=2 "$app" upload_zip="$work_dir/notarization.zip" /usr/bin/ditto -c -k --sequesterRsrc --keepParent "$app" "$upload_zip" # Capture JSON even on failure so Apple's status and submission ID can be inspected. -if ! xcrun notarytool submit "$upload_zip" --keychain-profile "$NOTARY_PROFILE" \ - --wait --timeout 30m --output-format json > "$work_dir/submission.json"; then - cat "$work_dir/submission.json" - echo "Notarization did not complete successfully. Inspect the retained submission result." >&2 - exit 1 -fi +submit_result=0 +xcrun notarytool submit "$upload_zip" "${notary_args[@]}" \ + --wait --timeout 30m --output-format json > "$work_dir/submission.json" || submit_result=$? cat "$work_dir/submission.json" -status="$(/usr/bin/plutil -extract status raw -o - "$work_dir/submission.json")" -submission_id="$(/usr/bin/plutil -extract id raw -o - "$work_dir/submission.json")" -if [[ "$status" != "Accepted" ]]; then - xcrun notarytool log "$submission_id" --keychain-profile "$NOTARY_PROFILE" \ +# Apple may return a nonzero exit status for rejection or a processing timeout. +# Retrieve available diagnostics before checking the final result. +submission_id="$(/usr/bin/plutil -extract id raw -o - "$work_dir/submission.json" 2>/dev/null || true)" +if [[ -n "$submission_id" ]]; then + xcrun notarytool log "$submission_id" "${notary_args[@]}" \ "$work_dir/notarization-log.json" || true - echo "Apple returned $status; no release ZIP was produced." >&2 +fi +status="$(/usr/bin/plutil -extract status raw -o - "$work_dir/submission.json" 2>/dev/null || true)" +if [[ "$submit_result" != 0 || "$status" != "Accepted" ]]; then + echo "Notarization did not complete with Accepted status; no release ZIP was produced." >&2 exit 1 fi diff --git a/scripts/setup-release-keychain.sh b/scripts/setup-release-keychain.sh new file mode 100644 index 0000000..4381fdc --- /dev/null +++ b/scripts/setup-release-keychain.sh @@ -0,0 +1,46 @@ +#!/bin/bash +set -euo pipefail + +# Called only by the tagged-release workflow. Values come from Actions secrets. +for variable in BUILD_CERTIFICATE_BASE64 P12_PASSWORD APPLE_ID APPLE_TEAM_ID APPLE_APP_SPECIFIC_PASSWORD; do + if [[ -z "${!variable:-}" ]]; then + echo "::error::Missing Actions secret: $variable. See README.md for release setup." >&2 + exit 1 + fi +done +: "${RUNNER_TEMP:?This script requires the GitHub Actions temporary directory.}" +: "${GITHUB_ENV:?This script requires the GitHub Actions environment file.}" + +umask 077 +certificate_path="$RUNNER_TEMP/macdiff-signing.p12" +keychain_path="$RUNNER_TEMP/macdiff-signing.keychain-db" +keychain_password="$(openssl rand -hex 32)" +echo "::add-mask::$keychain_password" +trap 'rm -f "$certificate_path"' EXIT +printf '%s' "$BUILD_CERTIFICATE_BASE64" | base64 --decode > "$certificate_path" +security create-keychain -p "$keychain_password" "$keychain_path" +security set-keychain-settings -lut 7200 "$keychain_path" +security unlock-keychain -p "$keychain_password" "$keychain_path" +security import "$certificate_path" -P "$P12_PASSWORD" -k "$keychain_path" \ + -T /usr/bin/codesign -T /usr/bin/security +security set-key-partition-list -S apple-tool:,apple: -k "$keychain_password" "$keychain_path" >/dev/null +security list-keychains -d user -s "$keychain_path" +rm -f "$certificate_path" + +identity_hashes="$(security find-identity -v -p codesigning "$keychain_path" \ + | awk '/"Developer ID Application:/ { print $2 }')" +if [[ -z "$identity_hashes" || "$identity_hashes" == *$'\n'* ]]; then + echo "::error::The .p12 must contain exactly one valid Developer ID Application identity, including its private key." >&2 + exit 1 +fi + +# notarytool validates these credentials before tests and builds consume time. +xcrun notarytool store-credentials MacDiff-Notary \ + --keychain "$keychain_path" \ + --apple-id "$APPLE_ID" --team-id "$APPLE_TEAM_ID" \ + --password "$APPLE_APP_SPECIFIC_PASSWORD" +{ + echo "SIGNING_IDENTITY=$identity_hashes" + echo "SIGNING_KEYCHAIN=$keychain_path" + echo "NOTARY_PROFILE=MacDiff-Notary" +} >> "$GITHUB_ENV" diff --git a/scripts/tests/test_release_credentials.py b/scripts/tests/test_release_credentials.py new file mode 100644 index 0000000..d7aca79 --- /dev/null +++ b/scripts/tests/test_release_credentials.py @@ -0,0 +1,143 @@ +#!/usr/bin/env python3 +"""Exercise release credential setup with synthetic values and fake Apple tools.""" + +import base64 +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest + + +SCRIPT = Path(__file__).resolve().parents[1] / "setup-release-keychain.sh" +IDENTITY = "A" * 40 +SECRETS = { + "BUILD_CERTIFICATE_BASE64": base64.b64encode(b"synthetic p12 fixture").decode(), + "P12_PASSWORD": "synthetic-export-password", + "APPLE_ID": "release@example.com", + "APPLE_TEAM_ID": "EXAMPLETEAM", + "APPLE_APP_SPECIFIC_PASSWORD": "synthetic-notary-password", +} +FAKE_TOOL = f'#!{sys.executable}\n' + ''' +import json +import os +from pathlib import Path +import sys + +program = Path(sys.argv[0]).name +arguments = sys.argv[1:] +with open(os.environ["TOOL_LOG"], "a") as log: + log.write(json.dumps([program, *arguments]) + "\\n") +if " ".join([program, *arguments[:1]]) == os.environ.get("FAIL_COMMAND"): + sys.exit(1) +if program == "security" and arguments[0] == "import": + # macOS base64 can accept input rejected by GNU base64. The real Keychain + # import must still reject bytes that are not a valid exported identity. + if Path(arguments[1]).read_bytes() != b"synthetic p12 fixture": + sys.exit(1) +if program == "security" and arguments[0] == "find-identity": + print(os.environ["FAKE_IDENTITIES"]) +''' + + +class ReleaseCredentialTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory() + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name) + binary = self.root / "bin" + binary.mkdir() + for tool in ["security", "xcrun"]: + path = binary / tool + path.write_text(FAKE_TOOL) + path.chmod(0o755) + self.environment_file = self.root / "github-env" + self.log = self.root / "tool-log" + # Do not inherit real credentials or platform bindings into these tests. + self.environment = { + "PATH": str(binary) + os.pathsep + os.defpath, + "RUNNER_TEMP": str(self.root), + "GITHUB_ENV": str(self.environment_file), + "TOOL_LOG": str(self.log), + "FAKE_IDENTITIES": f' 1) {IDENTITY} "Developer ID Application: Example (EXAMPLETEAM)"', + **SECRETS, + } + + def run_setup(self, **overrides): + return subprocess.run( + ["bash", str(SCRIPT)], + env={**self.environment, **overrides}, + text=True, + capture_output=True, + check=False, + ) + + def calls(self): + return [json.loads(line) for line in self.log.read_text().splitlines()] + + def test_each_missing_secret_fails_before_touching_keychain(self): + for name in SECRETS: + with self.subTest(secret=name): + result = self.run_setup(**{name: ""}) + self.assertNotEqual(result.returncode, 0) + self.assertIn(f"Missing Actions secret: {name}", result.stderr) + self.assertFalse(self.log.exists()) + self.assertFalse(self.environment_file.exists()) + + def test_valid_identity_scopes_credentials_to_temporary_keychain(self): + result = self.run_setup() + self.assertEqual(result.returncode, 0, result.stderr) + keychain = str(self.root / "macdiff-signing.keychain-db") + exported = dict(line.split("=", 1) for line in self.environment_file.read_text().splitlines()) + self.assertEqual(exported, { + "SIGNING_IDENTITY": IDENTITY, + "SIGNING_KEYCHAIN": keychain, + "NOTARY_PROFILE": "MacDiff-Notary", + }) + store = next(call for call in self.calls() if call[:2] == ["xcrun", "notarytool"]) + self.assertEqual(store[2:6], ["store-credentials", "MacDiff-Notary", "--keychain", keychain]) + self.assertFalse((self.root / "macdiff-signing.p12").exists()) + for secret in [SECRETS["P12_PASSWORD"], SECRETS["APPLE_APP_SPECIFIC_PASSWORD"]]: + self.assertNotIn(secret, result.stdout + result.stderr + self.environment_file.read_text()) + + def test_development_identity_cannot_be_used_for_release(self): + result = self.run_setup(FAKE_IDENTITIES=f' 1) {IDENTITY} "Apple Development: Example"') + self.assertNotEqual(result.returncode, 0) + self.assertFalse(self.environment_file.exists()) + self.assertFalse(any(call[0] == "xcrun" for call in self.calls())) + + def test_ambiguous_developer_identities_are_rejected(self): + identity = self.environment["FAKE_IDENTITIES"] + result = self.run_setup(FAKE_IDENTITIES=identity + "\n" + identity.replace(IDENTITY, "B" * 40)) + self.assertNotEqual(result.returncode, 0) + self.assertFalse(self.environment_file.exists()) + + def test_failed_import_removes_certificate_and_exports_no_identity(self): + result = self.run_setup(FAIL_COMMAND="security import") + self.assertNotEqual(result.returncode, 0) + self.assertFalse((self.root / "macdiff-signing.p12").exists()) + self.assertFalse(self.environment_file.exists()) + + def test_invalid_notary_credentials_export_no_identity(self): + result = self.run_setup(FAIL_COMMAND="xcrun notarytool") + self.assertNotEqual(result.returncode, 0) + self.assertFalse((self.root / "macdiff-signing.p12").exists()) + self.assertFalse(self.environment_file.exists()) + + def test_malformed_certificate_exports_no_identity(self): + result = self.run_setup(BUILD_CERTIFICATE_BASE64="invalid!") + self.assertNotEqual(result.returncode, 0) + self.assertFalse(self.environment_file.exists()) + self.assertFalse((self.root / "macdiff-signing.p12").exists()) + + +if __name__ == "__main__": + run = unittest.main(exit=False) + if os.environ.get("GITHUB_ACTIONS") == "true": + for test, traceback in run.result.failures + run.result.errors: + message = f"{test}: {traceback}" + message = message.replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A") + print(f"::error file=scripts/tests/test_release_credentials.py::{message}") + sys.exit(not run.result.wasSuccessful())