diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 6ca779b..ef7a364 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -75,10 +75,8 @@ jobs:
run: |
set -euo pipefail
- # Use the latest git tag as the source of truth so the bump is
- # always based on the actual last release, even if the VERSION file
- # is stale (e.g. a back-merge PR was never merged).
- current_version="$(git describe --tags --abbrev=0 2>/dev/null | tr -d '[:space:]')"
+ # Squash merges can leave an older tag nearer than the latest release.
+ current_version="$(git for-each-ref --sort=-version:refname --count=1 --format='%(refname:short)' refs/tags)"
if [[ -z "${current_version}" ]]; then
current_version="$(tr -d '[:space:]' < VERSION)"
fi
@@ -409,17 +407,77 @@ jobs:
echo "exists=false" >> "$GITHUB_OUTPUT"
fi
- - name: Build screen saver and wallpaper app
+ - name: Import Developer ID and notarization credentials
if: steps.release.outputs.exists != 'true'
+ env:
+ DEVELOPER_ID_CERTIFICATE_BASE64: ${{ secrets.DEVELOPER_ID_CERTIFICATE_BASE64 }}
+ DEVELOPER_ID_CERTIFICATE_PASSWORD: ${{ secrets.DEVELOPER_ID_CERTIFICATE_PASSWORD }}
+ SIGNING_IDENTITY: ${{ secrets.SIGNING_IDENTITY }}
+ APPLE_ID: ${{ secrets.APPLE_ID }}
+ APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
+ APPLE_APP_PASSWORD: ${{ secrets.APPLE_APP_PASSWORD }}
+ shell: bash
+ run: |
+ set -euo pipefail
+ : "${DEVELOPER_ID_CERTIFICATE_BASE64:?Configure DEVELOPER_ID_CERTIFICATE_BASE64.}"
+ : "${DEVELOPER_ID_CERTIFICATE_PASSWORD:?Configure DEVELOPER_ID_CERTIFICATE_PASSWORD.}"
+ : "${APPLE_ID:?Configure APPLE_ID.}"
+ : "${APPLE_TEAM_ID:?Configure APPLE_TEAM_ID.}"
+ : "${APPLE_APP_PASSWORD:?Configure APPLE_APP_PASSWORD.}"
+ if [[ "$SIGNING_IDENTITY" != "Developer ID Application: "* ]]; then
+ echo 'Configure SIGNING_IDENTITY with the full Developer ID Application certificate name.' >&2
+ exit 1
+ fi
+
+ KEYCHAIN_PATH="$RUNNER_TEMP/matrix-signing.keychain-db"
+ KEYCHAIN_PASSWORD="$(openssl rand -hex 32)"
+ echo "::add-mask::$KEYCHAIN_PASSWORD"
+ umask 077
+ printf '%s' "$DEVELOPER_ID_CERTIFICATE_BASE64" | base64 --decode > "$RUNNER_TEMP/developer-id.p12"
+ security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
+ security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH"
+ security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
+ security import "$RUNNER_TEMP/developer-id.p12" -P "$DEVELOPER_ID_CERTIFICATE_PASSWORD" \
+ -k "$KEYCHAIN_PATH" -T /usr/bin/codesign
+ security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" >/dev/null
+ security list-keychains -d user -s "$KEYCHAIN_PATH" "$HOME/Library/Keychains/login.keychain-db"
+ xcrun notarytool store-credentials matrix-release --apple-id "$APPLE_ID" \
+ --team-id "$APPLE_TEAM_ID" --password "$APPLE_APP_PASSWORD" --keychain "$KEYCHAIN_PATH"
+ rm -f "$RUNNER_TEMP/developer-id.p12"
+
+ - name: Build and sign screen saver and wallpaper app
+ if: steps.release.outputs.exists != 'true'
+ env:
+ SIGNING_IDENTITY: ${{ secrets.SIGNING_IDENTITY }}
shell: bash
run: |
set -euo pipefail
rm -rf build
./build.sh
./build-wallpaper.sh
+ bash Scripts/sign-bundle.sh "build/MatrixScreenSaver.saver"
+ bash Scripts/sign-bundle.sh "build/MatrixWallpaper.app"
codesign --verify --deep --strict "build/MatrixScreenSaver.saver"
codesign --verify --deep --strict "build/MatrixWallpaper.app"
+ - name: Notarize and staple both bundles
+ if: steps.release.outputs.exists != 'true'
+ env:
+ NOTARY_KEYCHAIN_PROFILE: matrix-release
+ NOTARY_KEYCHAIN: ${{ runner.temp }}/matrix-signing.keychain-db
+ shell: bash
+ run: bash Scripts/notarize.sh
+
+ - name: Remove signing credentials
+ if: always()
+ shell: bash
+ run: |
+ set -euo pipefail
+ rm -f "$RUNNER_TEMP/developer-id.p12"
+ if [[ -f "$RUNNER_TEMP/matrix-signing.keychain-db" ]]; then
+ security delete-keychain "$RUNNER_TEMP/matrix-signing.keychain-db"
+ fi
+
- name: Stage release files
if: steps.release.outputs.exists != 'true'
id: stage
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 2cf9b82..b0c54e4 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -24,6 +24,9 @@ jobs:
- name: Run tests
run: ./tests.sh
+ - name: Check signing and notarization scripts
+ run: python3 Tests/Signing/signing_test.py
+
- name: Build screen saver
run: ./build.sh
diff --git a/.gitignore b/.gitignore
index 0ad617a..1aa9e1c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -40,6 +40,10 @@ cmake-build-*/
*.vsix
# Xcode and Swift user data
+# Signing credentials must stay in Keychain or GitHub secrets.
+*.p12
+*.p8
+*.keychain-db
xcuserdata/
*.xcuserdatad/
*.xcuserstate
diff --git a/CHANGELOG.md b/CHANGELOG.md
index aa3024b..fdb5488 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
+
+## [0.4.1] - 2026-10-27
+
+### Features
+
+- sign and notarize GitHub releases (#111)
+
+### Bug Fixes
+
+- select the highest release version tag (#113)
+
## [0.4.0] - 2026-10-27
### Bug Fixes
diff --git a/README.md b/README.md
index 4350852..bca6ea9 100644
--- a/README.md
+++ b/README.md
@@ -21,7 +21,7 @@ brew install --cask patrickschaper/tap/matrix-screen-saver && open /Applications
## Buy me a coffee
-There is no spoon. There is coffee. Donations help fund a developer license for signing the screen saver.
+There is no spoon. There is coffee. Donations help cover the Apple Developer membership and ongoing development.

@@ -93,6 +93,9 @@ build/MatrixWallpaper.app
Both build scripts read the current version from `./VERSION` and write it into their bundle metadata.
+Local builds and ordinary CI use ad-hoc signing and do not need Apple credentials.
+Only the GitHub release publish job uses Developer ID signing and Apple notarization.
+
### Test
```bash
diff --git a/Scripts/notarize.sh b/Scripts/notarize.sh
new file mode 100644
index 0000000..30b4fb7
--- /dev/null
+++ b/Scripts/notarize.sh
@@ -0,0 +1,44 @@
+#!/usr/bin/env bash
+# Submit both signed build bundles to Apple, then staple their tickets in place.
+# Requires NOTARY_KEYCHAIN_PROFILE created with `xcrun notarytool store-credentials`.
+# NOTARY_KEYCHAIN optionally selects a non-default keychain (used by CI).
+set -euo pipefail
+
+ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
+: "${NOTARY_KEYCHAIN_PROFILE:?Set NOTARY_KEYCHAIN_PROFILE to your notarytool credential profile.}"
+BUNDLES=("$ROOT_DIR/build/MatrixScreenSaver.saver" "$ROOT_DIR/build/MatrixWallpaper.app")
+
+for bundle in "${BUNDLES[@]}"; do
+ codesign --verify --deep --strict "$bundle"
+ if ! codesign --display --verbose=4 "$bundle" 2>&1 | grep -q '^Authority=Developer ID Application: '; then
+ echo "Notarization requires Developer ID Application signing: $bundle" >&2
+ exit 1
+ fi
+done
+
+WORK_DIR="$(mktemp -d "${TMPDIR:-/tmp}/matrix-notarize.XXXXXX")"
+trap 'rm -rf "$WORK_DIR"' EXIT
+mkdir "$WORK_DIR/payload"
+for bundle in "${BUNDLES[@]}"; do
+ ditto "$bundle" "$WORK_DIR/payload/$(basename "$bundle")"
+done
+ditto -c -k --sequesterRsrc "$WORK_DIR/payload" "$WORK_DIR/submission.zip"
+
+AUTH=(--keychain-profile "$NOTARY_KEYCHAIN_PROFILE")
+if [[ -n "${NOTARY_KEYCHAIN:-}" ]]; then
+ AUTH+=(--keychain "$NOTARY_KEYCHAIN")
+fi
+xcrun notarytool submit "$WORK_DIR/submission.zip" \
+ "${AUTH[@]}" --wait --output-format plist > "$WORK_DIR/result.plist"
+cat "$WORK_DIR/result.plist"
+if [[ "$(/usr/libexec/PlistBuddy -c 'Print :status' "$WORK_DIR/result.plist")" != "Accepted" ]]; then
+ echo 'Apple did not accept the submission. Use `xcrun notarytool log ID --keychain-profile PROFILE` for details.' >&2
+ exit 1
+fi
+
+# ZIPs cannot carry stapled tickets: staple the originals before release packaging.
+for bundle in "${BUNDLES[@]}"; do
+ xcrun stapler staple "$bundle"
+ xcrun stapler validate "$bundle"
+ codesign --verify --deep --strict "$bundle"
+done
diff --git a/Scripts/sign-bundle.sh b/Scripts/sign-bundle.sh
new file mode 100644
index 0000000..5464f84
--- /dev/null
+++ b/Scripts/sign-bundle.sh
@@ -0,0 +1,14 @@
+#!/usr/bin/env bash
+# Sign a release bundle using a required Developer ID Application SIGNING_IDENTITY.
+set -euo pipefail
+
+BUNDLE="${1:?Usage: bash Scripts/sign-bundle.sh bundle}"
+IDENTITY="${SIGNING_IDENTITY:-}"
+
+if [[ "$IDENTITY" != "Developer ID Application: "* ]]; then
+ echo 'SIGNING_IDENTITY must be a full Developer ID Application certificate name.' >&2
+ exit 1
+fi
+
+codesign --force --options runtime --timestamp --sign "$IDENTITY" "$BUNDLE"
+codesign --verify --deep --strict "$BUNDLE"
diff --git a/Tests/Homebrew/release_metadata_test.py b/Tests/Homebrew/release_metadata_test.py
index 503057d..5faeb24 100644
--- a/Tests/Homebrew/release_metadata_test.py
+++ b/Tests/Homebrew/release_metadata_test.py
@@ -165,6 +165,30 @@ def test_release_workflow_builds_and_stages_both_bundles(self):
prepare.index("./build.sh\n"))
self.assertIn('ditto "build/MatrixWallpaper.app" "${release_dir}/MatrixWallpaper.app"', publish)
+ def test_release_uses_highest_version_tag_not_nearest(self):
+ repository = self.root / "repository"
+ repository.mkdir()
+
+ def git(*args):
+ return subprocess.run(["git", "-c", "user.name=Test", "-c", "user.email=test@example.invalid",
+ "-c", "commit.gpgsign=false", "-c", "tag.gpgsign=false",
+ "-c", "core.hooksPath=/dev/null", *args], cwd=repository,
+ check=True, capture_output=True, text=True)
+
+ git("init", "--quiet")
+ git("commit", "--allow-empty", "-m", "older release commit")
+ git("tag", "0.4.0")
+ git("tag", "0.10.0")
+ git("commit", "--allow-empty", "-m", "nearer older version tag")
+ git("tag", "0.3.0")
+ workflow = (ROOT / ".github/workflows/release.yml").read_text()
+ assignment = next(line.strip() for line in workflow.splitlines()
+ if line.strip().startswith('current_version="$('))
+ result = subprocess.run(["bash", "-euo", "pipefail", "-c",
+ assignment + '\nprintf "%s" "$current_version"'],
+ cwd=repository, check=True, capture_output=True, text=True)
+ self.assertEqual("0.10.0", result.stdout)
+
def test_fetch_consumes_existing_asset_and_missing_asset_fails(self):
from unittest.mock import Mock, call, patch
spec = importlib.util.spec_from_file_location("render_release", SCRIPT)
diff --git a/Tests/Signing/signing_test.py b/Tests/Signing/signing_test.py
new file mode 100644
index 0000000..f00571a
--- /dev/null
+++ b/Tests/Signing/signing_test.py
@@ -0,0 +1,146 @@
+"""Exercise signing and notarization without accessing real Apple credentials."""
+
+import os
+from pathlib import Path
+import plistlib
+import subprocess
+import tempfile
+import unittest
+
+
+ROOT = Path(__file__).resolve().parents[2]
+
+
+class SigningTests(unittest.TestCase):
+ def setUp(self):
+ self.temp = tempfile.TemporaryDirectory()
+ self.addCleanup(self.temp.cleanup)
+ self.directory = Path(self.temp.name)
+ self.bin = self.directory / "bin"
+ self.bin.mkdir()
+ self.log = self.directory / "commands"
+ self.env = dict(os.environ, PATH=f"{self.bin}:{os.environ['PATH']}",
+ COMMAND_LOG=str(self.log), SIGNING_IDENTITY="-",
+ NOTARY_KEYCHAIN_PROFILE="test-profile")
+ self.env.pop("NOTARY_KEYCHAIN", None)
+ self.stub("codesign", '''printf '%s\\n' "$*" >> "$COMMAND_LOG"
+if [[ "$1" == --display ]]; then
+ echo "Authority=${TEST_AUTHORITY:-Developer ID Application: Test (TEAMID)}" >&2
+fi
+exit "${SIGN_FAILURE:-0}"
+''')
+
+ def stub(self, name, body):
+ path = self.bin / name
+ path.write_text("#!/bin/bash\nset -eu\n" + body)
+ path.chmod(0o755)
+
+ def run_script(self, name, *args):
+ return subprocess.run(["bash", str(ROOT / "Scripts" / name), *map(str, args)],
+ env=self.env, text=True, capture_output=True)
+
+ def test_release_signing_rejects_missing_or_adhoc_identity(self):
+ for identity in (None, "-"):
+ with self.subTest(identity=identity):
+ if identity is None:
+ self.env.pop("SIGNING_IDENTITY", None)
+ else:
+ self.env["SIGNING_IDENTITY"] = identity
+ result = self.run_script("sign-bundle.sh", "test bundle.app")
+ self.assertNotEqual(0, result.returncode)
+ self.assertIn("Developer ID Application", result.stderr)
+ self.assertFalse(self.log.exists())
+
+ def test_developer_id_build_requires_runtime_and_timestamp(self):
+ self.env["SIGNING_IDENTITY"] = "Developer ID Application: Test (TEAMID)"
+ result = self.run_script("sign-bundle.sh", "test.saver")
+ self.assertEqual(0, result.returncode, result.stderr)
+ commands = self.log.read_text()
+ self.assertIn("--options runtime", commands)
+ self.assertIn("--timestamp", commands)
+ self.assertIn("--sign Developer ID Application: Test (TEAMID)", commands)
+
+ def test_development_certificate_is_not_distribution_signing(self):
+ self.env["SIGNING_IDENTITY"] = "Apple Development: Test (TEAMID)"
+ result = self.run_script("sign-bundle.sh", "test.app")
+ self.assertNotEqual(0, result.returncode)
+ self.assertIn("Developer ID Application", result.stderr)
+ self.assertFalse(self.log.exists())
+
+ def test_signing_failure_never_falls_back(self):
+ self.env["SIGNING_IDENTITY"] = "Developer ID Application: Missing (TEAMID)"
+ self.env["SIGN_FAILURE"] = "1"
+ self.assertNotEqual(0, self.run_script("sign-bundle.sh", "test.app").returncode)
+ self.assertEqual(1, len(self.log.read_text().splitlines()))
+
+ def prepare_notary(self, status="Accepted"):
+ response = self.directory / "response.plist"
+ response.write_bytes(plistlib.dumps({"status": status, "id": "test-id"}))
+ self.env["NOTARY_RESPONSE"] = str(response)
+ self.stub("ditto", 'printf "%s\\n" "$*" >> "$COMMAND_LOG"\n')
+ self.stub("xcrun", '''printf '%s\\n' "$*" >> "$COMMAND_LOG"
+if [[ "$1 $2" == 'notarytool submit' ]]; then cat "$NOTARY_RESPONSE"; fi
+if [[ "$1 $2" == 'stapler staple' ]]; then exit "${STAPLE_FAILURE:-0}"; fi
+''')
+
+ def test_accepted_submission_staples_and_validates_both_bundles(self):
+ self.prepare_notary()
+ result = self.run_script("notarize.sh")
+ self.assertEqual(0, result.returncode, result.stderr)
+ commands = self.log.read_text()
+ self.assertIn("--keychain-profile test-profile --wait", commands)
+ for bundle in ("MatrixScreenSaver.saver", "MatrixWallpaper.app"):
+ self.assertIn(f"stapler staple {ROOT}/build/{bundle}", commands)
+ self.assertIn(f"stapler validate {ROOT}/build/{bundle}", commands)
+
+ def test_rejected_submission_is_not_stapled(self):
+ self.prepare_notary("Invalid")
+ result = self.run_script("notarize.sh")
+ self.assertNotEqual(0, result.returncode)
+ self.assertNotIn("stapler", self.log.read_text())
+
+ def test_ci_profile_uses_its_temporary_keychain(self):
+ self.prepare_notary()
+ self.env["NOTARY_KEYCHAIN"] = str(self.directory / "temporary.keychain-db")
+ result = self.run_script("notarize.sh")
+ self.assertEqual(0, result.returncode, result.stderr)
+ self.assertIn(f"--keychain {self.env['NOTARY_KEYCHAIN']}", self.log.read_text())
+
+ def test_release_notarizes_before_staging_and_cleans_up_on_failure(self):
+ workflow = (ROOT / ".github/workflows/release.yml").read_text()
+ prepare, publish = workflow.split(" publish:", 1)
+ self.assertNotIn("secrets.DEVELOPER_ID", prepare)
+ self.assertIn("SIGNING_IDENTITY: ${{ secrets.SIGNING_IDENTITY }}", publish)
+ self.assertIn("APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}", publish)
+ for bundle in ("MatrixScreenSaver.saver", "MatrixWallpaper.app"):
+ sign_command = f'bash Scripts/sign-bundle.sh "build/{bundle}"'
+ self.assertIn(sign_command, publish)
+ self.assertLess(publish.index("./build-wallpaper.sh"), publish.index(sign_command))
+ self.assertLess(publish.index(sign_command), publish.index("bash Scripts/notarize.sh"))
+ self.assertIn("NOTARY_KEYCHAIN: ${{ runner.temp }}/matrix-signing.keychain-db", publish)
+ self.assertLess(publish.index("bash Scripts/notarize.sh"),
+ publish.index("- name: Stage release files"))
+ cleanup = publish.split("- name: Remove signing credentials", 1)[1].split("- name:", 1)[0]
+ self.assertIn("if: always()", cleanup)
+ self.assertIn('security delete-keychain "$RUNNER_TEMP/matrix-signing.keychain-db"', cleanup)
+
+ def test_adhoc_bundle_is_not_submitted(self):
+ self.prepare_notary()
+ self.env["TEST_AUTHORITY"] = "adhoc"
+ self.assertNotEqual(0, self.run_script("notarize.sh").returncode)
+ self.assertNotIn("notarytool submit", self.log.read_text())
+
+ def test_missing_profile_is_not_submitted(self):
+ self.env.pop("NOTARY_KEYCHAIN_PROFILE")
+ self.assertNotEqual(0, self.run_script("notarize.sh").returncode)
+ self.assertFalse(self.log.exists())
+
+ def test_staple_failure_aborts(self):
+ self.prepare_notary()
+ self.env["STAPLE_FAILURE"] = "1"
+ self.assertNotEqual(0, self.run_script("notarize.sh").returncode)
+ self.assertNotIn("stapler validate", self.log.read_text())
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/VERSION b/VERSION
index 1d0ba9e..267577d 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-0.4.0
+0.4.1