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
68 changes: 63 additions & 5 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<a href="https://www.buymeacoffee.com/yesman82"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me a Coffee" width="150"></a><br>
<img src="docs/bmc_qr.png" alt="Buy Me a Coffee QR code" width="150">
Expand Down Expand Up @@ -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
Expand Down
44 changes: 44 additions & 0 deletions Scripts/notarize.sh
Original file line number Diff line number Diff line change
@@ -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
14 changes: 14 additions & 0 deletions Scripts/sign-bundle.sh
Original file line number Diff line number Diff line change
@@ -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"
24 changes: 24 additions & 0 deletions Tests/Homebrew/release_metadata_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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", "[email protected]",
"-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)
Expand Down
146 changes: 146 additions & 0 deletions Tests/Signing/signing_test.py
Original file line number Diff line number Diff line change
@@ -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()
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.4.0
0.4.1
Loading