From 579cc8f8e81dc922a71fe5c92191147da876e8b0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 27 Oct 2026 14:51:49 +0100 Subject: [PATCH 1/4] chore: sync main into development after 0.4.0 (#110) chore(release): 0.4.0 - bump VERSION to 0.4.0 - update CHANGELOG.md Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Patrick Schaper --- CHANGELOG.md | 8 ++++++++ VERSION | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f4fe104..aa3024b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 + +## [0.4.0] - 2026-10-27 + +### Bug Fixes + +- speed up wallpaper startup and restore desktop images (#108) +- compact options and add coffee links (#107) + ## [0.3.0] - 2026-10-23 ### Features diff --git a/VERSION b/VERSION index 0d91a54..1d0ba9e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.3.0 +0.4.0 From bc2aee70850e5bb87a9fd27989692a05bc71e08f Mon Sep 17 00:00:00 2001 From: Patrick Schaper Date: Tue, 27 Oct 2026 15:58:43 +0100 Subject: [PATCH 2/4] feat: sign and notarize GitHub releases (#111) - sign both release bundles with Developer ID and staple notarization tickets - import release credentials into a temporary keychain and clean up on failure - keep local builds ad-hoc signed and test release signing safeguards --- .github/workflows/release.yml | 62 ++++++++++++++- .github/workflows/tests.yml | 3 + .gitignore | 4 + README.md | 5 +- Scripts/notarize.sh | 44 ++++++++++ Scripts/sign-bundle.sh | 14 ++++ Tests/Signing/signing_test.py | 146 ++++++++++++++++++++++++++++++++++ 7 files changed, 276 insertions(+), 2 deletions(-) create mode 100644 Scripts/notarize.sh create mode 100644 Scripts/sign-bundle.sh create mode 100644 Tests/Signing/signing_test.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6ca779b..44d21d3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -409,17 +409,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/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. Buy Me a Coffee
Buy Me a Coffee QR code @@ -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/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() From a65440cfe68988c95076fef4ac303e0f70e87874 Mon Sep 17 00:00:00 2001 From: Patrick Schaper Date: Tue, 27 Oct 2026 16:22:37 +0100 Subject: [PATCH 3/4] fix: select the highest release version tag (#113) - sort release tags numerically instead of using the nearest reachable tag - cover squash-merge history and numeric ordering with an isolated Git fixture --- .github/workflows/release.yml | 6 ++---- Tests/Homebrew/release_metadata_test.py | 24 ++++++++++++++++++++++++ 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 44d21d3..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 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) From 2d7dba2c7a04cc5ab75db48c82eecda629314eaf Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 27 Oct 2026 15:30:58 +0000 Subject: [PATCH 4/4] chore(release): 0.4.1 - bump VERSION to 0.4.1 - update CHANGELOG.md --- CHANGELOG.md | 11 +++++++++++ VERSION | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) 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/VERSION b/VERSION index 1d0ba9e..267577d 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.4.0 +0.4.1