Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand Down
133 changes: 133 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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
67 changes: 66 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
57 changes: 39 additions & 18 deletions scripts/release.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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")"
Expand All @@ -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

Expand Down
46 changes: 46 additions & 0 deletions scripts/setup-release-keychain.sh
Original file line number Diff line number Diff line change
@@ -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"
Loading
Loading