Sign releases with free Apple Development identity #5
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |