Skip to content

Sign releases with free Apple Development identity #5

Sign releases with free Apple Development identity

Sign releases with free Apple Development identity #5

Workflow file for this run

name: Release
on:
push:
tags:
- "v*"
permissions:
contents: write
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
# Required repository configuration before pushing a v* tag:
# Variables:
# APPLE_TEAM_ID: free Personal Team identifier used by the Apple Development certificate.
# SPARKLE_PUBLIC_ED_KEY: output from Sparkle's generate_keys tool.
# Secrets:
# APPLE_DEVELOPMENT_CERTIFICATE: base64-encoded Apple Development .p12 export.
# APPLE_DEVELOPMENT_CERTIFICATE_PASSWORD: password chosen when exporting the .p12.
# SPARKLE_PRIVATE_ED_KEY: contents of the file exported by generate_keys -x.
# The public and private Sparkle keys must be generated as one pair.
# Releases are Apple Development-signed (not notarized) and Sparkle EdDSA-authenticated.
jobs:
release:
# Defaults 9.0.8 requires Swift tools 6.2, and Icon Composer's asset compiler
# requires the macOS 26 runtime. Pin the matching hosted image and Xcode.
runs-on: macos-26
env:
DEVELOPER_DIR: /Applications/Xcode_26.3.app/Contents/Developer
APPLE_TEAM_ID: ${{ vars.APPLE_TEAM_ID }}
SPARKLE_PUBLIC_ED_KEY: ${{ vars.SPARKLE_PUBLIC_ED_KEY }}
APPLE_DEVELOPMENT_CERTIFICATE: ${{ secrets.APPLE_DEVELOPMENT_CERTIFICATE }}
APPLE_DEVELOPMENT_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_DEVELOPMENT_CERTIFICATE_PASSWORD }}
SPARKLE_PRIVATE_ED_KEY: ${{ secrets.SPARKLE_PRIVATE_ED_KEY }}
GH_TOKEN: ${{ github.token }}
steps:
- uses: actions/checkout@v5
- name: Verify Xcode toolchain
shell: bash
run: |
set -euo pipefail
xcodebuild -version
swift_version="$(xcrun swift --version)"
printf '%s\n' "$swift_version"
[[ "$swift_version" == *"Swift version 6.2"* ]] || {
echo "Release requires Swift 6.2, but selected toolchain reports: $swift_version" >&2
exit 1
}
- name: Validate release configuration
shell: bash
run: |
set -euo pipefail
missing=()
for name in APPLE_TEAM_ID SPARKLE_PUBLIC_ED_KEY APPLE_DEVELOPMENT_CERTIFICATE APPLE_DEVELOPMENT_CERTIFICATE_PASSWORD SPARKLE_PRIVATE_ED_KEY; do
if [[ -z "${!name:-}" ]]; then
missing+=("$name")
fi
done
if (( ${#missing[@]} )); then
printf 'Missing release configuration: %s\n' "${missing[*]}" >&2
exit 1
fi
if [[ "$SPARKLE_PUBLIC_ED_KEY" == "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" ]]; then
echo "SPARKLE_PUBLIC_ED_KEY must not use the local-development placeholder." >&2
exit 1
fi
decoded_length="$(printf '%s' "$SPARKLE_PUBLIC_ED_KEY" | /usr/bin/base64 -D | wc -c | tr -d ' ')"
[[ "$decoded_length" == "32" ]] || {
echo "SPARKLE_PUBLIC_ED_KEY must decode to 32 bytes, found $decoded_length." >&2
exit 1
}
[[ "$APPLE_TEAM_ID" =~ ^[A-Z0-9]{10}$ ]] || {
echo "APPLE_TEAM_ID must be a 10-character team identifier." >&2
exit 1
}
- name: Import Apple Development certificate
shell: bash
run: |
set -euo pipefail
keychain="$RUNNER_TEMP/release-signing.keychain-db"
certificate="$RUNNER_TEMP/apple-development.p12"
keychain_password="$(openssl rand -hex 24)"
printf '%s' "$APPLE_DEVELOPMENT_CERTIFICATE" | /usr/bin/base64 -D > "$certificate"
test -s "$certificate"
security create-keychain -p "$keychain_password" "$keychain"
security set-keychain-settings -lut 21600 "$keychain"
security unlock-keychain -p "$keychain_password" "$keychain"
security import "$certificate" \
-k "$keychain" \
-P "$APPLE_DEVELOPMENT_CERTIFICATE_PASSWORD" \
-T /usr/bin/codesign \
-T /usr/bin/security
security set-key-partition-list \
-S apple-tool:,apple: \
-s \
-k "$keychain_password" \
"$keychain"
security list-keychains -d user -s "$keychain"
identity_output="$(security find-identity -v -p codesigning "$keychain")"
printf '%s\n' "$identity_output"
grep -q '"Apple Development:' <<< "$identity_output" || {
echo "Imported .p12 does not contain a usable Apple Development identity." >&2
exit 1
}
echo "RELEASE_KEYCHAIN=$keychain" >> "$GITHUB_ENV"
- name: Verify tag matches app version
shell: bash
run: |
set -euo pipefail
version="$(sed -n 's/.*MARKETING_VERSION = \([^;]*\);/\1/p' ElapseRecorder.xcodeproj/project.pbxproj | sort -u)"
build="$(sed -n 's/.*CURRENT_PROJECT_VERSION = \([^;]*\);/\1/p' ElapseRecorder.xcodeproj/project.pbxproj | sort -u)"
tag_version="${GITHUB_REF_NAME#v}"
[[ "$version" == "$tag_version" ]] || {
echo "Tag $GITHUB_REF_NAME does not match MARKETING_VERSION $version." >&2
exit 1
}
[[ "$build" =~ ^[0-9]+$ ]] || {
echo "CURRENT_PROJECT_VERSION must be one integer, found: $build" >&2
exit 1
}
echo "APP_VERSION=$version" >> "$GITHUB_ENV"
echo "APP_BUILD=$build" >> "$GITHUB_ENV"
- name: Build Apple Development-signed application
shell: bash
run: |
set -euo pipefail
mkdir -p build
env GIT_CONFIG_COUNT=1 \
GIT_CONFIG_KEY_0=safe.bareRepository \
GIT_CONFIG_VALUE_0=all \
xcodebuild build \
-project ElapseRecorder.xcodeproj \
-scheme ElapseRecorder \
-configuration Release \
-destination 'generic/platform=macOS' \
-derivedDataPath "$PWD/build/DerivedData" \
-clonedSourcePackagesDirPath "$PWD/build/SourcePackages" \
-skipMacroValidation \
-skipPackagePluginValidation \
-onlyUsePackageVersionsFromResolvedFile \
CODE_SIGN_STYLE=Manual \
CODE_SIGN_IDENTITY='Apple Development' \
DEVELOPMENT_TEAM="$APPLE_TEAM_ID" \
OTHER_CODE_SIGN_FLAGS="--keychain $RELEASE_KEYCHAIN" \
SPARKLE_PUBLIC_ED_KEY="$SPARKLE_PUBLIC_ED_KEY"
app="$PWD/build/DerivedData/Build/Products/Release/Elapse Recorder.app"
[[ -d "$app" ]] || {
echo "Signed app was not produced at $app" >&2
exit 1
}
codesign --verify --deep --strict --verbose=2 "$app"
signature_info="$(codesign -dv --verbose=4 "$app" 2>&1)"
printf '%s\n' "$signature_info"
grep -q '^Authority=Apple Development:' <<< "$signature_info" || {
echo "Built app is not signed with an Apple Development identity." >&2
exit 1
}
embedded_team="$(awk -F= '/^TeamIdentifier=/ { print $2 }' <<< "$signature_info")"
[[ "$embedded_team" == "$APPLE_TEAM_ID" ]] || {
echo "Built app team $embedded_team does not match APPLE_TEAM_ID $APPLE_TEAM_ID." >&2
exit 1
}
embedded_key="$(/usr/libexec/PlistBuddy -c 'Print SUPublicEDKey' "$app/Contents/Info.plist")"
[[ "$embedded_key" == "$SPARKLE_PUBLIC_ED_KEY" ]] || {
echo "Built app does not contain the configured Sparkle public key." >&2
exit 1
}
embedded_version="$(/usr/libexec/PlistBuddy -c 'Print CFBundleShortVersionString' "$app/Contents/Info.plist")"
embedded_build="$(/usr/libexec/PlistBuddy -c 'Print CFBundleVersion' "$app/Contents/Info.plist")"
[[ "$embedded_version" == "$APP_VERSION" && "$embedded_build" == "$APP_BUILD" ]] || {
echo "Built app version $embedded_version ($embedded_build) does not match $APP_VERSION ($APP_BUILD)." >&2
exit 1
}
echo "SIGNED_APP=$app" >> "$GITHUB_ENV"
- name: Package Sparkle update
shell: bash
run: |
set -euo pipefail
mkdir -p build/updates
archive="build/updates/ElapseRecorder-${APP_VERSION}.zip"
ditto -c -k --sequesterRsrc --keepParent "$SIGNED_APP" "$archive"
echo "UPDATE_ARCHIVE=$archive" >> "$GITHUB_ENV"
- name: Create draft GitHub release
shell: bash
run: |
set -euo pipefail
notes_file="ReleaseNotes/${APP_VERSION}.md"
if [[ -f "$notes_file" ]]; then
notes_args=(--notes-file "$notes_file")
else
notes_args=(--generate-notes)
fi
if gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1; then
gh release upload "$GITHUB_REF_NAME" "$UPDATE_ARCHIVE" --clobber
if [[ -f "$notes_file" ]]; then
gh release edit "$GITHUB_REF_NAME" --notes-file "$notes_file"
fi
else
gh release create "$GITHUB_REF_NAME" "$UPDATE_ARCHIVE" \
--draft \
--title "ElapseRecorder ${APP_VERSION}" \
"${notes_args[@]}"
fi
if [[ -f "$notes_file" ]]; then
gh release upload "$GITHUB_REF_NAME" "$notes_file" --clobber
fi
gh release view "$GITHUB_REF_NAME" --json body --jq .body \
> "build/updates/ElapseRecorder-${APP_VERSION}.md"
- name: Generate signed Sparkle appcast
shell: bash
run: |
set -euo pipefail
tool="$PWD/build/SourcePackages/artifacts/sparkle/Sparkle/bin/generate_appcast"
[[ -x "$tool" ]] || {
echo "Sparkle generate_appcast tool was not resolved at $tool" >&2
exit 1
}
printf '%s' "$SPARKLE_PRIVATE_ED_KEY" | "$tool" \
--ed-key-file - \
--download-url-prefix "https://github.com/deltaiota/ElapseRecorder/releases/download/${GITHUB_REF_NAME}/" \
--embed-release-notes \
--link "https://github.com/deltaiota/ElapseRecorder" \
--maximum-versions 3 \
--maximum-deltas 0 \
build/updates
test -s build/updates/appcast.xml
xmllint --noout build/updates/appcast.xml
grep -q '<!-- sparkle-signatures:' build/updates/appcast.xml
grep -q '^edSignature:' build/updates/appcast.xml
- name: Publish release and appcast
shell: bash
run: |
set -euo pipefail
gh release upload "$GITHUB_REF_NAME" build/updates/appcast.xml --clobber
gh release edit "$GITHUB_REF_NAME" --draft=false
- name: Remove release keychain
if: always()
shell: bash
run: |
if [[ -n "${RELEASE_KEYCHAIN:-}" ]]; then
security delete-keychain "$RELEASE_KEYCHAIN" || true
fi