Skip to content
Open
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
42 changes: 42 additions & 0 deletions .github/scripts/macos-notarize.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
#!/usr/bin/env bash
# Notarize a signed macOS app bundle or DMG and staple the ticket to it.
#
# Usage: macos-notarize.sh <aTrain.app | file.dmg> <notary-log.json>
#
# Needs APPLE_API_KEY_P8 (the App Store Connect API key, base64), APPLE_API_KEY_ID
# and APPLE_API_ISSUER_ID. Writes Apple's notary log to the second argument,
# also when notarization fails, and exits non-zero unless it was accepted.
set -euo pipefail

target="$1"
log="$2"

workdir="$(mktemp -d)"
trap 'rm -rf "$workdir"' EXIT
printf '%s' "$APPLE_API_KEY_P8" | base64 --decode > "$workdir/AuthKey.p8"
auth=(--key "$workdir/AuthKey.p8" --key-id "$APPLE_API_KEY_ID" --issuer "$APPLE_API_ISSUER_ID")

upload="$target"
if [[ -d "$target" ]]; then
# notarytool takes a zip, dmg or pkg, not a bundle.
upload="$workdir/$(basename "$target").zip"
ditto -c -k --keepParent "$target" "$upload"
fi

# Usually minutes, at times an hour.
xcrun notarytool submit "$upload" "${auth[@]}" --wait --timeout 1h --output-format json \
> "$workdir/submit.json" || true
cat "$workdir/submit.json"
id="$(jq -r '.id // empty' "$workdir/submit.json" 2>/dev/null || true)"
status="$(jq -r '.status // empty' "$workdir/submit.json" 2>/dev/null || true)"
if [[ -n "$id" ]]; then
xcrun notarytool log "$id" "${auth[@]}" "$log" || true
fi
if [[ "$status" != "Accepted" ]]; then
echo "::error::Notarization of $target: ${status:-no result}"
[[ -f "$log" ]] && cat "$log"
exit 1
fi

xcrun stapler staple "$target"
xcrun stapler validate "$target"
143 changes: 134 additions & 9 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@ name: Release
# and stay available as workflow artifacts, since they exceed the 2 GiB
# release-asset limit; the GitHub release carries the sha256 checksums
# and the CycloneDX SBOM (#213) for verification. Flatpak (needs Flathub
# manifest refresh) is a follow-up. An Apple silicon macOS DMG (not yet
# signed or notarized) is added to the same Zenodo draft, its checksums and
# SBOM to the release.
# manifest refresh) is a follow-up. An Apple silicon macOS DMG is added to the
# same Zenodo draft, its checksums and SBOM to the release, only after Developer
# ID signing and notarization. Ad-hoc builds remain available as test artifacts.

on:
push:
Expand Down Expand Up @@ -396,16 +396,26 @@ jobs:
.github/scripts/zenodo-upload.sh "$version" artifacts/aTrain-* artifacts/checksums.txt

build-macos-dmg:
name: Build macOS DMG (unsigned, Apple silicon)
name: Build macOS DMG (Apple silicon)
# macos-15 is arm64; macos-latest moves to a new macOS over time.
runs-on: macos-15
timeout-minutes: 60
# Notarization uploads the app and the DMG (about 400 MB each) and can
# take up to an hour on Apple's side.
timeout-minutes: 90
outputs:
version: ${{ steps.version.outputs.version }}
# Set only after signing, notarization and verification succeed. The
# publishing job requires it so ad-hoc test builds cannot become releases.
signing-verified: ${{ steps.sign-dmg.outputs.verified }}
env:
# Wheels are chosen for this macOS, not the runner's (orjson ships a
# macosx_15_0 wheel next to an 11.0 one). Matches LSMinimumSystemVersion.
MACOSX_DEPLOYMENT_TARGET: "14.0"
# Developer ID signing and notarization run only with the Apple secrets;
# without them (forks, contributors) the app keeps PyInstaller's ad-hoc
# signature. Only this flag is job-wide: the secrets go to the signing
# steps alone, not to uv, PyInstaller or the app.
MACOS_SIGN: ${{ secrets.MACOS_CERTIFICATE_P12 != '' && secrets.APPLE_API_KEY_P8 != '' }}

steps:
- name: Checkout
Expand Down Expand Up @@ -477,6 +487,51 @@ jobs:
set -euo pipefail
uv run --no-sync pyinstaller macos_freeze.spec --noconfirm 2>&1 | tee pyinstaller-macos.log

- name: Sign the app with the Developer ID
# Inside out, without --deep (deprecated for signing): every Mach-O
# file, then the frameworks, then the bundle with its main executable.
# The keychain lives only for this job, see the cleanup step.
if: env.MACOS_SIGN == 'true'
shell: bash
env:
MACOS_CERTIFICATE_P12: ${{ secrets.MACOS_CERTIFICATE_P12 }}
MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }}
MACOS_SIGNING_IDENTITY: ${{ secrets.MACOS_SIGNING_IDENTITY }}
run: |
set -euo pipefail
keychain="$RUNNER_TEMP/signing.keychain-db"
keychain_password="$(openssl rand -base64 32)"
security create-keychain -p "$keychain_password" "$keychain"
security set-keychain-settings -lut 21600 "$keychain"
security unlock-keychain -p "$keychain_password" "$keychain"
printf '%s' "$MACOS_CERTIFICATE_P12" | base64 --decode > "$RUNNER_TEMP/certificate.p12"
security import "$RUNNER_TEMP/certificate.p12" -k "$keychain" \
-P "$MACOS_CERTIFICATE_PASSWORD" -T /usr/bin/codesign
rm "$RUNNER_TEMP/certificate.p12"
security set-key-partition-list -S apple-tool:,apple: -s -k "$keychain_password" "$keychain" > /dev/null
security list-keychains -d user -s "$keychain" $(security list-keychains -d user | tr -d '"')
# A line break in the secret would split the grep pattern and match anything.
if [[ "$MACOS_SIGNING_IDENTITY" == *$'\n'* ]] \
|| ! security find-identity -v -p codesigning "$keychain" | grep -qF "\"$MACOS_SIGNING_IDENTITY\""; then
echo "::error::MACOS_SIGNING_IDENTITY is not in MACOS_CERTIFICATE_P12 (or ends with a line break)"
exit 1
fi

sign() {
# --timestamp needs Apple's timestamp server: one retry.
codesign --force --timestamp --options runtime --sign "$MACOS_SIGNING_IDENTITY" "$@" \
|| { sleep 10; codesign --force --timestamp --options runtime --sign "$MACOS_SIGNING_IDENTITY" "$@"; }
}
app=dist/aTrain.app
find "$app/Contents" -type f ! -path "$app/Contents/MacOS/aTrain" -print0 \
| xargs -0 file | grep 'Mach-O' | cut -d: -f1 > sign-files.txt
echo "Signing $(wc -l < sign-files.txt | tr -d ' ') Mach-O files"
while IFS= read -r f; do sign "$f"; done < sign-files.txt
# Deepest first, so a framework is sealed after anything inside it.
find "$app/Contents" -type d -name '*.framework' | awk -F/ '{ print NF, $0 }' | sort -rn | cut -d' ' -f2- \
| while IFS= read -r framework; do sign "$framework"; done
sign --entitlements entitlements.plist "$app"

- name: Smoke test - architecture
shell: bash
run: |
Expand Down Expand Up @@ -519,14 +574,33 @@ jobs:

- name: Smoke test - code signature
# PyInstaller signs ad hoc on arm64; an unsigned arm64 binary will
# not launch.
# not launch. A Developer ID build must carry a Team ID and the
# hardened runtime, and no file may be left signed ad hoc.
shell: bash
run: codesign --verify --deep --strict --verbose=2 dist/aTrain.app
run: |
set -euo pipefail
codesign --verify --deep --strict --verbose=2 dist/aTrain.app
[[ "$MACOS_SIGN" == 'true' ]] || exit 0
codesign -dv --verbose=2 dist/aTrain.app 2>&1 | tee app-signature.txt
if grep -q '^TeamIdentifier=not set' app-signature.txt \
|| ! grep -Eq 'flags=0x[0-9a-f]+\([^)]*runtime' app-signature.txt; then
echo "::error::The app has no Team ID or no hardened runtime"
exit 1
fi
while IFS= read -r f; do
if codesign -dv "$f" 2>&1 | grep -q 'Signature=adhoc'; then echo "$f"; fi
done < macho-files.txt > adhoc.txt
if [[ -s adhoc.txt ]]; then
echo "::error::Files still signed ad hoc:"
cat adhoc.txt
exit 1
fi

- name: Smoke test - the app starts
# Not --help: the frozen entry point ignores arguments and starts the GUI.
# `script` gives the app a pty, so its stdout is line-buffered (a frozen
# app ignores PYTHONUNBUFFERED) and the log is complete after 60 s.
# After signing, so it runs under the hardened runtime that ships.
shell: bash
run: |
set -euo pipefail
Expand All @@ -547,9 +621,24 @@ jobs:
cat app-start.log
exit "$status"

- name: Notarize the app
# Before the DMG, so the app inside carries the stapled ticket and
# Gatekeeper accepts it offline, also once copied out of the DMG.
if: env.MACOS_SIGN == 'true'
shell: bash
env:
APPLE_API_KEY_P8: ${{ secrets.APPLE_API_KEY_P8 }}
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
APPLE_API_ISSUER_ID: ${{ secrets.APPLE_API_ISSUER_ID }}
run: |
set -euo pipefail
.github/scripts/macos-notarize.sh dist/aTrain.app notary-app.json
spctl -a -vvv -t exec dist/aTrain.app 2>&1 | tee spctl-app.txt
grep -q 'source=Notarized Developer ID' spctl-app.txt

- name: Create the DMG
# The .app must not change after the ad-hoc signing; ditto preserves
# the bundle (symlinks, xattrs) as built.
# The .app must not change after signing; ditto preserves the bundle
# (symlinks, xattrs) as built.
shell: bash
run: |
set -euo pipefail
Expand All @@ -573,6 +662,30 @@ jobs:
hdiutil detach "$mountpoint"
exit "$status"

- name: Sign and notarize the DMG
id: sign-dmg
if: env.MACOS_SIGN == 'true'
shell: bash
env:
MACOS_SIGNING_IDENTITY: ${{ secrets.MACOS_SIGNING_IDENTITY }}
APPLE_API_KEY_P8: ${{ secrets.APPLE_API_KEY_P8 }}
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
APPLE_API_ISSUER_ID: ${{ secrets.APPLE_API_ISSUER_ID }}
run: |
set -euo pipefail
dmg="aTrain-${{ steps.version.outputs.version }}-macos-arm64.dmg"
codesign --timestamp --sign "$MACOS_SIGNING_IDENTITY" "$dmg" \
|| { sleep 10; codesign --timestamp --sign "$MACOS_SIGNING_IDENTITY" "$dmg"; }
.github/scripts/macos-notarize.sh "$dmg" notary-dmg.json
codesign --verify --strict --verbose=2 "$dmg"
spctl -a -vvv -t open --context context:primary-signature "$dmg"
echo "verified=true" >> "$GITHUB_OUTPUT"

- name: Remove the signing keychain
if: always() && env.MACOS_SIGN == 'true'
shell: bash
run: security delete-keychain "$RUNNER_TEMP/signing.keychain-db" || true

- name: Compute checksums
shell: bash
run: |
Expand Down Expand Up @@ -603,6 +716,7 @@ jobs:
build/macos_freeze/warn-macos_freeze.txt
app-start.log
minos.txt
notary-*.json
if-no-files-found: ignore

macos27-app-start:
Expand Down Expand Up @@ -677,6 +791,17 @@ jobs:
ZENODO_TOKEN: ${{ secrets.ZENODO_TOKEN }}

steps:
- name: Require a signed and notarized DMG
shell: bash
env:
MACOS_SIGNING_VERIFIED: ${{ needs.build-macos-dmg.outputs.signing-verified }}
run: |
set -euo pipefail
if [[ "${MACOS_SIGNING_VERIFIED:-}" != 'true' ]]; then
echo "::error::macOS publishing requires a signed, notarized and verified DMG. Unsigned builds remain available as workflow artifacts."
exit 1
fi

- name: Checkout
uses: actions/checkout@v7

Expand Down
23 changes: 21 additions & 2 deletions docs/code-signing-policy.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,33 @@ Free code signing provided by [SignPath.io](https://signpath.io), certificate by
- **Windows MSIX from GitHub releases and Zenodo**: Authenticode-signed through
SignPath.io with a certificate issued to SignPath Foundation, from v1.5.0 on.
Earlier builds are unsigned.
- **macOS DMG from Zenodo**: the app and the disk image are signed with the
Apple Developer ID Application certificate
`Developer ID Application: Juergen Fleiss (ZC3J2Z2Q96)`, with hardened runtime, and
notarized by Apple; the ticket is stapled to both. Earlier macOS builds are
not notarized.
- **Microsoft Store**: packages are signed by Microsoft as part of Store
publishing.
- **Flathub**: Flatpak packages carry no Authenticode signature; integrity is
handled by Flathub's build and distribution infrastructure.

Signing runs in the release workflow (`.github/workflows/release.yml`) on a
tagged commit. Every signing request is approved manually by an approver
before the certificate is applied.
tagged commit. Every Windows signing request is approved manually by an
approver before the certificate is applied. macOS signing runs in the same
workflow without a manual approval. It uses the repository secrets
`MACOS_CERTIFICATE_P12`, `MACOS_CERTIFICATE_PASSWORD` and
`MACOS_SIGNING_IDENTITY` (certificate) and `APPLE_API_KEY_P8`,
`APPLE_API_KEY_ID` and `APPLE_API_ISSUER_ID` (App Store Connect API key, role
Developer, for notarization), which only repository admins can change. The
certificate expires after five years and the API key can be revoked; either
shows up as a failing signing step in the release workflow. If they leak,
revoke both at Apple.

macOS publishing requires successful signing, notarization, and verification
of the DMG. If signing secrets are missing, the workflow still builds an
ad-hoc-signed DMG for testing and keeps it as a workflow artifact, but the
macOS publishing job for a tagged release fails before uploading anything
to GitHub or Zenodo.

How to check a downloaded release against the published checksums and this
signature: [Verifying a release](verifying-releases.md).
Expand Down
30 changes: 22 additions & 8 deletions docs/verifying-releases.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ Every release is a git tag `v<version>` and a GitHub release built from it by
| `checksums.txt` | GitHub release and Zenodo | SHA-256 of every file below, computed after signing. Both copies are identical. |
| `aTrain-<version>-windows.cdx.json` | GitHub release | CycloneDX SBOM of the Windows build. |
| `aTrain-<version>-source.tar.gz` | GitHub (tag archive) | Source at the tag; its hash is in `checksums.txt`. |
| `aTrain-<version>-macos-arm64.dmg` | Zenodo | macOS disk image, Apple silicon, unsigned and not notarized. |
| `aTrain-<version>-macos-arm64.dmg` | Zenodo | macOS disk image, Apple silicon, signed with a Developer ID and notarized by Apple. |
| `checksums-macos.txt` | GitHub release and Zenodo | SHA-256 of the DMG and its SBOM. |
| `aTrain-<version>-macos.cdx.json` | GitHub release and Zenodo | CycloneDX SBOM of the macOS build. |

Expand Down Expand Up @@ -59,10 +59,6 @@ matching line in `checksums-macos.txt` from the GitHub release:
shasum -a 256 aTrain-<version>-macos-arm64.dmg
```

The macOS build is unsigned for now, so there is no signature to verify, and
Gatekeeper blocks the first launch. Only after the checksum matches, open it
through System Settings → Privacy & Security → Open Anyway.

A mismatch means a corrupted download or a file that is not the released one.
Do not install it; download again, then report if it persists (see below).

Expand All @@ -77,7 +73,24 @@ Get-AuthenticodeSignature .\aTrain-<version>.msix | Format-List Status, SignerCe
applied through SignPath.io. The policy page also lists who may approve a
signing request.

## 3. Verify the source
## 3. Check the Developer ID signature (macOS app)

Open the DMG, then check the app on the mounted volume:

```bash
codesign --verify --deep --strict --verbose=2 /Volumes/aTrain/aTrain.app
codesign -dv --verbose=4 /Volumes/aTrain/aTrain.app 2>&1 | grep -E '^(Authority|TeamIdentifier)='
spctl -a -vvv -t exec /Volumes/aTrain/aTrain.app
```

`codesign --verify` must pass. The first `Authority` must be the Developer ID
named in the [Code signing policy](code-signing-policy.md) and
`TeamIdentifier` its Team ID. `spctl` must print `accepted` and
`source=Notarized Developer ID`. The notarization ticket is stapled to the DMG
and the app, so Gatekeeper accepts them without an online check. A build
without these (for example a fork's test build) is not a release.

## 4. Verify the source

For builds from source (Flatpak, distribution packages), work from the tag,
not from a branch:
Expand All @@ -89,13 +102,14 @@ git clone --branch v<version> https://github.com/aTrainTranscription/aTrain
The tag archive `aTrain-<version>-source.tar.gz` is listed in `checksums.txt`,
so a downloaded tarball can be checked the same way as the installer.

## 4. Read the SBOM
## 5. Read the SBOM

`aTrain-<version>-windows.cdx.json` lists every Python package in the Windows
build with version and licence, plus the bundled ML models and their
Hugging Face revisions. Use it to scan for known vulnerabilities, for example
with `pip-audit`, `grype`, or `trivy sbom`, or to review licences before
redistribution.
redistribution. `aTrain-<version>-macos.cdx.json` does the same for the macOS
build.

## Reporting a mismatch

Expand Down
4 changes: 2 additions & 2 deletions entitlements.plist
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<?xml version="1.2.0" encoding="UTF-8"?>
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.2.0">
<plist version="1.0">
<!--
These are required for binaries built by PyInstaller.
For more info, see:
Expand Down
Loading
Loading