Skip to content

ci: add signed + notarized release workflow #1

ci: add signed + notarized release workflow

ci: add signed + notarized release workflow #1

Workflow file for this run

name: Release
on:
push:
tags:
- 'v*'
workflow_dispatch:
inputs:
tag:
description: 'Tag to release (e.g. v1.0.0)'
required: true
type: string
permissions:
contents: write
env:
SIGN_IDENTITY: "Developer ID Application: FOURNINE CLOUD SOLUTIONS PRIVATE LIMITED (XUKA94C5G4)"
PYTHON_VERSION: "3.13"
jobs:
build:
name: Build, sign, notarize & publish
runs-on: macos-15
timeout-minutes: 60
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ github.event.inputs.tag || github.ref }}
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: ${{ env.PYTHON_VERSION }}
- name: Show toolchain
run: |
python3 --version
xcrun --version
uname -m
- name: Install build dependencies
run: |
python3 -m pip install --upgrade pip setuptools wheel
python3 -m pip install py2app pyobjc
python3 -m pip install -r requirements.txt
- name: Import Developer ID certificate
env:
P12_BASE64: ${{ secrets.MACOS_CERTIFICATE_P12_BASE64 }}
P12_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }}
KEYCHAIN_PASSWORD: ${{ secrets.MACOS_KEYCHAIN_PASSWORD }}
run: |
set -euo pipefail
KEYCHAIN_PATH="$RUNNER_TEMP/build.keychain-db"
CERT_PATH="$RUNNER_TEMP/cert.p12"
P12_PASSWORD="$(printf '%s' "$P12_PASSWORD" | tr -d '\r\n')"
PWD_LEN=${#P12_PASSWORD}
PWD_FP=$(printf '%s' "$P12_PASSWORD" | shasum -a 256 | cut -c1-12)
echo "Password length: $PWD_LEN"
echo "Password sha256 prefix: $PWD_FP"
printf '%s' "$P12_BASE64" | base64 --decode > "$CERT_PATH"
P12_SIZE=$(wc -c < "$CERT_PATH")
echo "Decoded .p12 is $P12_SIZE bytes."
if [ "$P12_SIZE" -lt 1000 ]; then
echo "::error::Decoded .p12 looks too small — secret MACOS_CERTIFICATE_P12_BASE64 may be truncated or mis-encoded."
exit 1
fi
if /usr/bin/openssl pkcs12 -in "$CERT_PATH" -nokeys \
-passin "env:P12_PASSWORD" >/dev/null 2>&1; then
echo "Password accepted by openssl."
elif /usr/bin/openssl pkcs12 -in "$CERT_PATH" -nokeys -legacy \
-passin "env:P12_PASSWORD" >/dev/null 2>&1; then
echo "Password accepted by openssl (-legacy)."
else
echo "::error::Password rejected by openssl. The CI sha256 prefix above must match printf '%s' 'YOUR_PASSWORD' | shasum -a 256 | cut -c1-12 on your machine."
exit 1
fi
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 "$CERT_PATH" \
-P "$P12_PASSWORD" \
-A -t cert -f pkcs12 \
-k "$KEYCHAIN_PATH" \
-T /usr/bin/codesign -T /usr/bin/security
security set-key-partition-list \
-S apple-tool:,apple:,codesign: \
-s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
security list-keychains -d user -s "$KEYCHAIN_PATH" $(security list-keychains -d user | tr -d '"')
security default-keychain -s "$KEYCHAIN_PATH"
rm "$CERT_PATH"
echo "Available signing identities:"
security find-identity -v -p codesigning
- name: Determine version
id: version
env:
INPUT_TAG: ${{ github.event.inputs.tag }}
REF_NAME: ${{ github.ref_name }}
run: |
set -euo pipefail
TAG="${INPUT_TAG:-$REF_NAME}"
VERSION="${TAG#v}"
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "Releasing $TAG (CFBundle version $VERSION)"
- name: Build .app via py2app
run: |
set -euo pipefail
rm -rf build dist
python3 setup.py py2app
ls -la dist/
- name: Stamp version into Info.plist
env:
VERSION: ${{ steps.version.outputs.version }}
run: |
set -euo pipefail
PLIST="dist/TimeTracker.app/Contents/Info.plist"
plutil -replace CFBundleShortVersionString -string "$VERSION" "$PLIST"
plutil -replace CFBundleVersion -string "$VERSION" "$PLIST"
plutil -replace LSApplicationCategoryType -string "public.app-category.productivity" "$PLIST"
echo "--- Info.plist ---"
plutil -p "$PLIST"
- name: Sign app bundle (hardened runtime)
run: |
set -euo pipefail
APP="dist/TimeTracker.app"
ENTITLEMENTS="$GITHUB_WORKSPACE/entitlements.plist"
echo "::group::Signing nested binaries"
find "$APP" -type f \( -name "*.so" -o -name "*.dylib" \) -print0 | \
while IFS= read -r -d '' f; do
codesign --force --options runtime --timestamp \
--sign "$SIGN_IDENTITY" "$f"
done
echo "::endgroup::"
PYTHON_FW=$(find "$APP/Contents/Frameworks" -path "*/Python.framework/Versions/*/Python" -type f 2>/dev/null | head -1 || true)
if [ -n "${PYTHON_FW:-}" ]; then
echo "Signing embedded Python: $PYTHON_FW"
codesign --force --options runtime --timestamp \
--sign "$SIGN_IDENTITY" "$PYTHON_FW"
fi
for f in "$APP/Contents/MacOS/"*; do
[ -f "$f" ] || continue
codesign --force --options runtime --timestamp \
--sign "$SIGN_IDENTITY" "$f"
done
codesign --force --options runtime --timestamp \
--entitlements "$ENTITLEMENTS" \
--sign "$SIGN_IDENTITY" "$APP"
echo "::group::Verify signature"
codesign --verify --deep --strict --verbose=2 "$APP"
codesign -dvv "$APP"
echo "::endgroup::"
- name: Package zip for notarization
run: |
set -euo pipefail
cd dist
ditto -c -k --sequesterRsrc --keepParent TimeTracker.app TimeTracker.zip
ls -la TimeTracker.zip
- name: Notarize and staple
env:
NOTARY_APPLE_ID: ${{ secrets.NOTARY_APPLE_ID }}
NOTARY_PASSWORD: ${{ secrets.NOTARY_PASSWORD }}
NOTARY_TEAM_ID: ${{ secrets.NOTARY_TEAM_ID }}
run: |
set -euo pipefail
cd dist
echo "Submitting to notary..."
xcrun notarytool submit TimeTracker.zip \
--apple-id "$NOTARY_APPLE_ID" \
--password "$NOTARY_PASSWORD" \
--team-id "$NOTARY_TEAM_ID" \
--wait
echo "Stapling..."
xcrun stapler staple TimeTracker.app
xcrun stapler validate TimeTracker.app
rm -f TimeTracker.zip
ditto -c -k --sequesterRsrc --keepParent TimeTracker.app TimeTracker.zip
echo "Stapled and re-zipped."
- name: Verify Gatekeeper acceptance
run: |
xcrun stapler validate dist/TimeTracker.app
spctl -a -vvv -t install dist/TimeTracker.app || true
- name: Compute checksums
run: |
cd dist
shasum -a 256 TimeTracker.zip > SHA256SUMS.txt
cat SHA256SUMS.txt
- name: Clean up keychain
if: always()
run: |
security delete-keychain "$RUNNER_TEMP/build.keychain-db" || true
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.version.outputs.tag }}
name: ${{ steps.version.outputs.tag }}
draft: false
prerelease: false
generate_release_notes: true
fail_on_unmatched_files: true
body: |
## TimeTracker ${{ steps.version.outputs.tag }}
Local-first macOS activity tracker with menu-bar UI and Flask dashboard.
### Downloads
| Asset | Description |
|---|---|
| `TimeTracker.zip` | Signed and notarized macOS app bundle |
| `SHA256SUMS.txt` | Checksums |
### Install
```bash
unzip TimeTracker.zip -d /Applications/
open /Applications/TimeTracker.app
```
Signed with Apple Developer ID and notarized — Gatekeeper accepts on first launch with no warnings. macOS will still prompt for Apple Events, Accessibility, and (optionally) Screen Recording permissions; see the [README](https://github.com/FournineCS/timetracker#permissions) for what each one is for.
files: |
dist/TimeTracker.zip
dist/SHA256SUMS.txt