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
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,9 @@ jobs:
if: runner.os != 'macOS' || matrix.arch != 'x86_64'
shell: bash
run: meson test -C build --list | grep -q aegisub-wrapper
- name: Check macOS release tests are enabled
if: runner.os == 'macOS'
run: meson test -C build --list | grep -q macos-release-tools
- name: Test
run: meson test -C build --print-errorlogs
- name: Check binary architecture and runtime dependencies
Expand Down
53 changes: 53 additions & 0 deletions docs/releasing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# macOS release signing

Use the same Developer ID Application identity as Aegisub: its library
validation requires native Automation libraries to have the same Team ID.
These scripts use downloaded CI artifacts without rebuilding dependencies.
They require macOS, Xcode command-line tools, your signing certificate, and
a stored `notarytool` profile. Signing and notarization need network access.

Check out the commit that produced the artifacts, then download both macOS
packages from its successful CI run:

```sh
gh run download RUN_ID --name SubInspector-macos-arm64 --dir build/SubInspector-macos-arm64
gh run download RUN_ID --name SubInspector-macos-x86_64 --dir build/SubInspector-macos-x86_64

export SUBINSPECTOR_SIGNATURE='Developer ID Application: Your Name (TEAMID)'
export SUBINSPECTOR_TEAM_ID=TEAMID
export SUBINSPECTOR_NOTARY_PROFILE=aegisub-notary
```

You can reuse Aegisub's existing notary profile. To create a new one, run
`xcrun notarytool store-credentials "$SUBINSPECTOR_NOTARY_PROFILE"` once.
For non-default keychains, set `SUBINSPECTOR_SIGNING_KEYCHAIN` and/or
`SUBINSPECTOR_NOTARY_KEYCHAIN`. `SUBINSPECTOR_NOTARY_TIMEOUT` defaults to `30m`.
Set `SUBINSPECTOR_TEAM_ID` to the 10-character `TeamIdentifier` shown by
`codesign --display --verbose=4 /path/to/Aegisub.app`. Notarization verifies
that the dylib is signed by that team before submitting it to Apple.

```sh
for arch in arm64 x86_64; do
artifact="build/SubInspector-macos-$arch"
tools/osx-sign.sh "$artifact"
tools/osx-notarize.sh "$artifact" "$artifact.zip"
done
```

Signing verifies the downloaded checksums, signs the dylib with a secure
timestamp, and atomically replaces `SHA256SUMS`. The notarization script checks
the signed package and produces the requested ZIP only after Apple returns `Accepted`.
The ZIP's top-level folder matches its filename without `.zip`, regardless of
the input directory's name. It refuses to overwrite an existing output, including
one created while awaiting notarization. On failure it prints the submission
ID/status and requests Apple's log when an ID is available; rerun after fixing
the issue. `SUBINSPECTOR_SIGNATURE=-` permits local ad-hoc signing tests, but
those artifacts cannot pass the notarization script's Developer ID check.

Test the signed dylibs through the wrapper in the signed Aegisub release.
Publish the accepted ZIPs and use the **signed** dylibs when updating the
DependencyControl feed's download URLs and hashes. Keep the wrapper version,
changelog, and dependency changes together with that feed update.

Standalone dylibs and ZIPs cannot have notarization tickets stapled to them;
Apple records tickets online. See [Apple's notarization workflow](https://developer.apple.com/documentation/security/customizing-the-notarization-workflow).
2 changes: 2 additions & 0 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ Should you prefer a Visual Studio solution, just pass `--backend=vs` along with

### Help and Support

For macOS releases, see the [signing instructions](docs/releasing.md).

Talk to `CoffeeFlux` on `irc.rizon.net`.

[libass]: https://github.com/libass/libass
Expand Down
7 changes: 7 additions & 0 deletions tests/meson.build
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,13 @@ render_test = executable('render-test', 'render.c',
link_with: subinspector)
test('render', render_test)

if build_machine.system() == 'darwin' and host_machine.system() == 'darwin'
python = import('python').find_installation()
test('macos-release-tools', python,
args: [files('osx-release.py'), subinspector.full_path()],
depends: subinspector)
endif

if not meson.is_cross_build()
luajit = find_program('luajit', required: false)
moonc = find_program('moonc', required: false)
Expand Down
156 changes: 156 additions & 0 deletions tests/osx-release.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
"""Exercise real ad-hoc signing and the release flow without Apple credentials."""

import hashlib
import os
from pathlib import Path
import shutil
import subprocess
import sys
import tempfile
import unittest

binary = Path(sys.argv.pop(1)).resolve()
tools = Path(__file__).resolve().parents[1] / 'tools'


class ReleaseTests(unittest.TestCase):
def setUp(self):
self.temporary = tempfile.TemporaryDirectory(prefix='SubInspector signing ')
self.addCleanup(self.temporary.cleanup)
self.root = Path(self.temporary.name)
self.artifact = self.root / 'macOS artifact'
self.artifact.mkdir()
shutil.copy2(binary, self.artifact / 'libSubInspector.dylib')
(self.artifact / 'COPYING').write_text('License fixture\n')
(self.artifact / 'licenses/libass').mkdir(parents=True)
(self.artifact / 'licenses/libass/COPYING').write_text('Nested license fixture\n')
with (self.artifact / 'SHA256SUMS').open('w') as checksums:
for name in ['COPYING', 'libSubInspector.dylib', 'licenses/libass/COPYING']:
digest = hashlib.sha256((self.artifact / name).read_bytes()).hexdigest()
checksums.write(f'{digest} {name}\n')
self.env = dict(os.environ, SUBINSPECTOR_SIGNATURE='-',
SUBINSPECTOR_TEAM_ID='TESTTEAM01',
SUBINSPECTOR_NOTARY_PROFILE='test-profile')
self.commands = self.root / 'commands'
self.commands.mkdir()
# Never contact Apple, even if a validation regression reaches xcrun.
(self.commands / 'xcrun').write_text('#!/bin/sh\necho "Unexpected notary call"\nexit 1\n')
(self.commands / 'xcrun').chmod(0o755)
self.env['PATH'] = str(self.commands) + os.pathsep + self.env['PATH']

def run_tool(self, name, *args):
return subprocess.run([tools / name, self.artifact, *args], env=self.env,
text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)

def sign(self):
result = self.run_tool('osx-sign.sh')
self.assertEqual(result.returncode, 0, result.stdout)
for line in (self.artifact / 'SHA256SUMS').read_text().splitlines():
digest, name = line.split(' ', 1)
self.assertEqual(digest, hashlib.sha256((self.artifact / name).read_bytes()).hexdigest())

def test_sign_and_reject_adhoc_notarization(self):
self.sign()
output = self.root / 'release.zip'
result = self.run_tool('osx-notarize.sh', output)
self.assertNotEqual(result.returncode, 0, result.stdout)
self.assertFalse(output.exists())
self.assertNotIn('Unexpected notary call', result.stdout)

def test_corrupt_download_is_not_signed(self):
original = (self.artifact / 'libSubInspector.dylib').read_bytes()
checksums = (self.artifact / 'SHA256SUMS').read_bytes()
(self.artifact / 'COPYING').write_text('Changed after download')
result = self.run_tool('osx-sign.sh')
self.assertNotEqual(result.returncode, 0, result.stdout)
self.assertEqual(original, (self.artifact / 'libSubInspector.dylib').read_bytes())
self.assertEqual(checksums, (self.artifact / 'SHA256SUMS').read_bytes())

def test_wrong_platform_is_rejected(self):
manifest = self.artifact / 'SHA256SUMS'
manifest.write_text(manifest.read_text().replace('libSubInspector.dylib', 'SubInspector.dll'))
for script, args in [('osx-sign.sh', []), ('osx-notarize.sh', [self.root / 'release.zip'])]:
with self.subTest(script=script):
result = self.run_tool(script, *args)
self.assertNotEqual(result.returncode, 0, result.stdout)
self.assertIn('Expected a macOS CI artifact', result.stdout)
self.assertNotIn('Unexpected notary call', result.stdout)

def simulate_notary(self):
self.sign()
# Only simulate Developer ID validation and Apple's service. ditto,
# checksum verification, plist parsing, and output handling remain real.
commands = self.commands
(commands / 'codesign').write_text('''#!/usr/bin/env python3
import sys
if '--verify' in sys.argv:
requirement = next(arg for arg in sys.argv if arg.startswith('-R='))
if 'certificate leaf[subject.OU] = "TESTTEAM01"' not in requirement:
sys.exit('Wrong signing team')
else:
print('Timestamp=Sep 12, 2026')
print('CodeDirectory v=20500 flags=0x10000(runtime)')
''')
(commands / 'xcrun').write_text('''#!/usr/bin/env python3
import hashlib, os, pathlib, plistlib, sys, zipfile
assert sys.argv[1] == 'notarytool'
assert sys.argv[sys.argv.index('--keychain-profile') + 1] == 'test-profile'
assert sys.argv[sys.argv.index('--keychain') + 1] == 'test keychain'
if sys.argv[2] == 'log':
print('Notary service diagnostic')
else:
assert sys.argv[2] == 'submit'
assert sys.argv[sys.argv.index('--timeout') + 1] == '1m'
archive = next(arg for arg in sys.argv if arg.endswith('.zip'))
with zipfile.ZipFile(archive) as package:
prefix = os.environ['TEST_PACKAGE_NAME'] + '/'
for line in package.read(prefix + 'SHA256SUMS').decode().splitlines():
digest, name = line.split(' ', 1)
assert digest == hashlib.sha256(package.read(prefix + name)).hexdigest()
if os.environ.get('TEST_CONCURRENT_OUTPUT'):
pathlib.Path(os.environ['TEST_CONCURRENT_OUTPUT']).write_text('Created during notarization')
plistlib.dump({'id': 'test-submission', 'status': os.environ['TEST_STATUS']}, sys.stdout.buffer)
sys.exit(int(os.environ['TEST_EXIT']))
''')
for command in commands.iterdir():
command.chmod(0o755)
self.env.update(SUBINSPECTOR_NOTARY_KEYCHAIN='test keychain',
SUBINSPECTOR_NOTARY_TIMEOUT='1m')

def test_notary_results(self):
self.simulate_notary()
for status, exit_code in [('Accepted', '0'), ('Invalid', '0'), ('In Progress', '1')]:
with self.subTest(status=status):
self.env.update(TEST_STATUS=status, TEST_EXIT=exit_code, TEST_PACKAGE_NAME=status)
output = self.root / f'{status}.zip'
result = self.run_tool('osx-notarize.sh', output)
accepted = status == 'Accepted'
self.assertEqual(result.returncode == 0, accepted, result.stdout)
self.assertEqual(output.exists(), accepted)
if accepted:
original = output.read_bytes()
self.assertNotEqual(self.run_tool('osx-notarize.sh', output).returncode, 0)
self.assertEqual(original, output.read_bytes())
else:
self.assertIn('Notary service diagnostic', result.stdout)

def test_wrong_team_is_rejected_before_submission(self):
self.simulate_notary()
self.env['SUBINSPECTOR_TEAM_ID'] = 'OTHERTEAM1'
result = self.run_tool('osx-notarize.sh', self.root / 'wrong-team.zip')
self.assertNotEqual(result.returncode, 0, result.stdout)
self.assertIn('Wrong signing team', result.stdout)
self.assertFalse((self.root / 'wrong-team.zip').exists())

def test_output_created_during_notarization_is_preserved(self):
self.simulate_notary()
output = self.root / 'concurrent.zip'
self.env.update(TEST_STATUS='Accepted', TEST_EXIT='0',
TEST_PACKAGE_NAME='concurrent', TEST_CONCURRENT_OUTPUT=str(output))
result = self.run_tool('osx-notarize.sh', output)
self.assertNotEqual(result.returncode, 0, result.stdout)
self.assertIn('appeared during notarization', result.stdout)
self.assertEqual(output.read_text(), 'Created during notarization')


unittest.main()
102 changes: 102 additions & 0 deletions tools/osx-notarize.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
#!/bin/sh
set -eu

if test "$#" -ne 2; then
echo "Usage: $0 ARTIFACT_DIR OUTPUT_ZIP" >&2
exit 1
fi
if test -z "${SUBINSPECTOR_NOTARY_PROFILE:-}"; then
echo "Set SUBINSPECTOR_NOTARY_PROFILE to a notarytool Keychain profile" >&2
exit 1
fi
if ! printf '%s\n' "${SUBINSPECTOR_TEAM_ID:-}" | grep -Eq '^[A-Z0-9]{10}$'; then
echo "Set SUBINSPECTOR_TEAM_ID to Aegisub's 10-character signing Team ID" >&2
exit 1
fi

ARTIFACT_DIR=$(cd "$1" && pwd)
OUTPUT_DIR=$(cd "$(dirname "$2")" && pwd)
OUTPUT_ZIP="${OUTPUT_DIR}/$(basename "$2")"
case "${OUTPUT_ZIP}" in
*.zip) ;;
*) echo "OUTPUT_ZIP must end in .zip" >&2; exit 1 ;;
esac
if test -e "${OUTPUT_ZIP}" || test -L "${OUTPUT_ZIP}"; then
echo "${OUTPUT_ZIP} already exists" >&2
exit 1
fi

WORK_DIR=$(mktemp -d "${TMPDIR:-/tmp}/subinspector-notary.XXXXXX")
trap 'rm -rf "${WORK_DIR}"' EXIT
trap 'exit 1' HUP INT TERM

# Validate a private copy, then submit exactly those files.
PACKAGE_DIR="${WORK_DIR}/$(basename "${OUTPUT_ZIP}" .zip)"
ditto "${ARTIFACT_DIR}" "${PACKAGE_DIR}"
(
cd "${PACKAGE_DIR}"
if ! grep -Eq '^[[:xdigit:]]{64} libSubInspector[.]dylib$' SHA256SUMS; then
echo "Expected a macOS CI artifact with libSubInspector.dylib in SHA256SUMS" >&2
exit 1
fi
shasum -a 256 -c SHA256SUMS
codesign --verify --strict --verbose=2 \
-R="anchor apple generic and certificate leaf[field.1.2.840.113635.100.6.1.13] exists and certificate leaf[subject.OU] = \"${SUBINSPECTOR_TEAM_ID}\"" \
libSubInspector.dylib
SIGNATURE=$(codesign --display --verbose=4 libSubInspector.dylib 2>&1)
if ! printf '%s\n' "${SIGNATURE}" | grep -q '^Timestamp='; then
echo "The dylib has no secure signing timestamp" >&2
exit 1
fi
if ! printf '%s\n' "${SIGNATURE}" | grep -q '^CodeDirectory .*flags=.*runtime'; then
echo "The dylib was not signed with --options runtime" >&2
exit 1
fi
)
ARCHIVE="${WORK_DIR}/submission.zip"
ditto -c -k --keepParent "${PACKAGE_DIR}" "${ARCHIVE}"

run_notarytool() {
command=$1
shift
if test -n "${SUBINSPECTOR_NOTARY_KEYCHAIN:-}"; then
xcrun notarytool "${command}" --keychain-profile "${SUBINSPECTOR_NOTARY_PROFILE}" \
--keychain "${SUBINSPECTOR_NOTARY_KEYCHAIN}" "$@"
else
xcrun notarytool "${command}" --keychain-profile "${SUBINSPECTOR_NOTARY_PROFILE}" "$@"
fi
}

RESULT="${WORK_DIR}/result.plist"
if run_notarytool submit "${ARCHIVE}" --wait \
--timeout "${SUBINSPECTOR_NOTARY_TIMEOUT:-30m}" --output-format plist > "${RESULT}"; then
SUBMIT_EXIT=0
else
SUBMIT_EXIT=$?
fi
if test -s "${RESULT}"; then
plutil -p "${RESULT}" || cat "${RESULT}"
fi
SUBMISSION_ID=$(plutil -extract id raw -o - "${RESULT}" 2>/dev/null || true)
STATUS=$(plutil -extract status raw -o - "${RESULT}" 2>/dev/null || true)
if test "${SUBMIT_EXIT}" -ne 0 || test "${STATUS}" != Accepted; then
if test -n "${SUBMISSION_ID}"; then
run_notarytool log "${SUBMISSION_ID}" || true
fi
echo "Notarization failed with status ${STATUS:-unknown}" >&2
exit 1
fi

# Apple issues tickets for dylibs, but neither dylibs nor ZIPs support stapling.
# The destination may have appeared while we waited for Apple.
if test -e "${OUTPUT_ZIP}" || test -L "${OUTPUT_ZIP}"; then
echo "${OUTPUT_ZIP} appeared during notarization; leaving it untouched" >&2
exit 1
fi
mv -n "${ARCHIVE}" "${OUTPUT_ZIP}"
if test -e "${ARCHIVE}"; then
echo "Could not publish ${OUTPUT_ZIP} without overwriting an existing file" >&2
exit 1
fi
shasum -a 256 "${OUTPUT_ZIP}"
echo "Notarized ${OUTPUT_ZIP}"
44 changes: 44 additions & 0 deletions tools/osx-sign.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
#!/bin/sh
set -eu

if test "$#" -ne 1; then
echo "Usage: $0 ARTIFACT_DIR" >&2
exit 1
fi
if test -z "${SUBINSPECTOR_SIGNATURE:-}"; then
echo "Set SUBINSPECTOR_SIGNATURE to Aegisub's Developer ID identity (or '-' for an ad-hoc test)" >&2
exit 1
fi

cd "$1"
LIBRARY=libSubInspector.dylib
if ! grep -Eq '^[[:xdigit:]]{64} libSubInspector[.]dylib$' SHA256SUMS; then
echo "Expected a macOS CI artifact with libSubInspector.dylib in SHA256SUMS" >&2
exit 1
fi
shasum -a 256 -c SHA256SUMS

if test "${SUBINSPECTOR_SIGNATURE}" = '-'; then
codesign --force --sign - "${LIBRARY}"
else
set -- --force --options runtime --timestamp --sign "${SUBINSPECTOR_SIGNATURE}"
if test -n "${SUBINSPECTOR_SIGNING_KEYCHAIN:-}"; then
set -- "$@" --keychain "${SUBINSPECTOR_SIGNING_KEYCHAIN}"
fi
codesign "$@" "${LIBRARY}"
fi
codesign --verify --strict --verbose=2 "${LIBRARY}"

# Signing changes only the dylib. Preserve the checksums of the other files.
CHECKSUMS=$(mktemp './.SHA256SUMS.XXXXXX')
trap 'rm -f "${CHECKSUMS}"' EXIT
trap 'exit 1' HUP INT TERM
SIGNED_CHECKSUM=$(shasum -a 256 "${LIBRARY}")
awk -v signed="${SIGNED_CHECKSUM}" '
$2 == "libSubInspector.dylib" { $0 = signed }
{ print }
' SHA256SUMS > "${CHECKSUMS}"
chmod 644 "${CHECKSUMS}"
mv -f "${CHECKSUMS}" SHA256SUMS
shasum -a 256 -c SHA256SUMS
echo "Signed ${LIBRARY} and updated SHA256SUMS"
Loading