Release #28
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*.*.*" | |
| release: | |
| types: [published] | |
| workflow_dispatch: | |
| inputs: | |
| tag: | |
| description: "Tag to release (e.g. v0.1.0)" | |
| required: true | |
| permissions: | |
| contents: write | |
| jobs: | |
| check-signing: | |
| name: Check signing secrets | |
| runs-on: ubuntu-latest | |
| outputs: | |
| enabled: ${{ steps.check.outputs.enabled }} | |
| steps: | |
| - name: Verify DEVELOPER_ID_CERT_P12 is configured | |
| id: check | |
| env: | |
| DEVELOPER_ID_CERT_P12: ${{ secrets.DEVELOPER_ID_CERT_P12 }} | |
| run: | | |
| if [[ -z "${DEVELOPER_ID_CERT_P12}" ]]; then | |
| echo "enabled=false" >> "$GITHUB_OUTPUT" | |
| echo "::notice::Signing secrets not yet configured — skipping binary release build. Configure them in repo Settings → Secrets and variables → Actions when you have a Developer ID certificate." | |
| else | |
| echo "enabled=true" >> "$GITHUB_OUTPUT" | |
| fi | |
| release: | |
| name: Build, Sign & Release (arm64) | |
| needs: check-signing | |
| if: needs.check-signing.outputs.enabled == 'true' | |
| runs-on: macos-26 | |
| # arm64-only: Bòcan targets macOS 26+, which runs exclusively on Apple Silicon. | |
| # Intel Macs cannot run macOS 26, so there is no x86_64 user base to support. | |
| # A universal binary would double CI build time with no benefit. FFmpeg dylibs | |
| # and fpcalc in Resources/ are arm64-only as well. Revisit if the deployment | |
| # target is ever lowered to macOS 14 or 15. | |
| env: | |
| ARCHS: arm64 | |
| ONLY_ACTIVE_ARCH: "NO" | |
| MARKETING_VERSION: ${{ github.event.inputs.tag || github.ref_name }} | |
| HAS_SPARKLE_KEY: ${{ secrets.SPARKLE_ED_PRIVATE_KEY != '' && 'true' || 'false' }} | |
| steps: | |
| - name: Checkout (full history for changelog) | |
| uses: actions/checkout@v6 | |
| with: | |
| fetch-depth: 0 | |
| - name: Select Xcode 26 | |
| run: sudo xcode-select -s /Applications/Xcode_26.4.app | |
| - name: Cache Homebrew packages | |
| uses: actions/cache@v5 | |
| with: | |
| path: ~/Library/Caches/Homebrew | |
| key: ${{ runner.os }}-brew-${{ hashFiles('Brewfile.lock.json') }} | |
| restore-keys: | | |
| ${{ runner.os }}-brew- | |
| - name: Cache SPM packages | |
| uses: actions/cache@v5 | |
| with: | |
| path: | | |
| ~/Library/Developer/Xcode/DerivedData/**/SourcePackages | |
| ~/.swiftpm | |
| key: ${{ runner.os }}-spm-release-${{ hashFiles('**/Package.resolved') }} | |
| restore-keys: | | |
| ${{ runner.os }}-spm-release- | |
| ${{ runner.os }}-spm- | |
| - name: Install Brewfile dependencies | |
| run: brew bundle install --no-upgrade | |
| - name: Set pkg-config path for FFmpeg and TagLib | |
| run: | | |
| echo "PKG_CONFIG_PATH=/opt/homebrew/opt/ffmpeg/lib/pkgconfig:/opt/homebrew/opt/taglib/lib/pkgconfig" >> $GITHUB_ENV | |
| - name: Stub Secrets.xcconfig (xcodegen requires it) | |
| env: | |
| ACOUSTID_API_KEY: ${{ secrets.ACOUSTID_API_KEY }} | |
| BOCAN_LASTFM_API_KEY: ${{ secrets.BOCAN_LASTFM_API_KEY }} | |
| BOCAN_LASTFM_SHARED_SECRET: ${{ secrets.BOCAN_LASTFM_SHARED_SECRET }} | |
| run: | | |
| if [[ ! -f Secrets.xcconfig ]]; then | |
| cp Secrets.xcconfig.template Secrets.xcconfig | |
| fi | |
| if [[ -n "${ACOUSTID_API_KEY:-}" ]]; then | |
| /usr/bin/sed -i '' "s|^ACOUSTID_API_KEY.*|ACOUSTID_API_KEY = ${ACOUSTID_API_KEY}|" Secrets.xcconfig | |
| fi | |
| if [[ -n "${BOCAN_LASTFM_API_KEY:-}" ]]; then | |
| /usr/bin/sed -i '' "s|^BOCAN_LASTFM_API_KEY.*|BOCAN_LASTFM_API_KEY = ${BOCAN_LASTFM_API_KEY}|" Secrets.xcconfig | |
| fi | |
| if [[ -n "${BOCAN_LASTFM_SHARED_SECRET:-}" ]]; then | |
| /usr/bin/sed -i '' "s|^BOCAN_LASTFM_SHARED_SECRET.*|BOCAN_LASTFM_SHARED_SECRET = ${BOCAN_LASTFM_SHARED_SECRET}|" Secrets.xcconfig | |
| fi | |
| - name: Generate Xcode project | |
| run: make generate | |
| - name: Run tests before release (fail fast) | |
| run: make test-coverage 2>&1 | xcbeautify | |
| - name: Import Developer ID certificate | |
| env: | |
| DEVELOPER_ID_CERT_P12: ${{ secrets.DEVELOPER_ID_CERT_P12 }} | |
| DEVELOPER_ID_CERT_PASSWORD: ${{ secrets.DEVELOPER_ID_CERT_PASSWORD }} | |
| run: | | |
| KEYCHAIN_PATH="$RUNNER_TEMP/bocan.keychain-db" | |
| KEYCHAIN_PASSWORD=$(openssl rand -base64 32) | |
| security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" | |
| security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" | |
| security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" | |
| echo "$DEVELOPER_ID_CERT_P12" | base64 --decode > "$RUNNER_TEMP/cert.p12" | |
| security import "$RUNNER_TEMP/cert.p12" \ | |
| -k "$KEYCHAIN_PATH" \ | |
| -P "$DEVELOPER_ID_CERT_PASSWORD" \ | |
| -T /usr/bin/codesign | |
| security list-keychain -d user -s "$KEYCHAIN_PATH" $(security list-keychains -d user | xargs) | |
| security set-key-partition-list \ | |
| -S apple-tool:,apple: \ | |
| -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" | |
| - name: Bundle fpcalc + FFmpeg dylibs into Resources/ | |
| env: | |
| SIGNING_IDENTITY: ${{ secrets.DEVELOPER_ID_IDENTITY }} | |
| run: make bundle-fpcalc | |
| - name: Stamp version into Info.plist | |
| run: | | |
| VERSION="${MARKETING_VERSION#v}" | |
| BUILD_NUMBER=$(date +%s) | |
| # CFBundleShortVersionString: stamp the marketing version string directly. | |
| # release-please also updates this, but re-stamping here is safe and ensures | |
| # the archive always matches the tag even on workflow_dispatch runs. | |
| /usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $VERSION" Resources/Info.plist | |
| # CFBundleVersion is NOT stamped here — Info.plist uses $(CURRENT_PROJECT_VERSION) | |
| # which xcodebuild resolves from the build setting we pass on the archive command line. | |
| echo "RELEASE_VERSION=$VERSION" >> "$GITHUB_ENV" | |
| echo "BUILD_NUMBER=$BUILD_NUMBER" >> "$GITHUB_ENV" | |
| - name: Archive (arm64 only) | |
| run: | | |
| set -o pipefail | |
| xcodebuild archive \ | |
| -project Bocan.xcodeproj \ | |
| -scheme Bocan \ | |
| -configuration Release \ | |
| -destination 'generic/platform=macOS' \ | |
| -archivePath build/Bocan.xcarchive \ | |
| ARCHS=arm64 \ | |
| ONLY_ACTIVE_ARCH=NO \ | |
| CURRENT_PROJECT_VERSION="$BUILD_NUMBER" \ | |
| MARKETING_VERSION="$RELEASE_VERSION" \ | |
| CODE_SIGN_STYLE=Manual \ | |
| DEVELOPMENT_TEAM="${{ secrets.APPLE_TEAM_ID }}" \ | |
| CODE_SIGN_IDENTITY="${{ secrets.DEVELOPER_ID_IDENTITY }}" \ | |
| OTHER_CODE_SIGN_FLAGS="--timestamp --options=runtime" \ | |
| ENABLE_HARDENED_RUNTIME=YES \ | |
| | xcbeautify | |
| - name: Export (Developer ID) | |
| env: | |
| APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} | |
| run: | | |
| # Generate ExportOptions with the real team ID ($(VAR) syntax is not | |
| # expanded by xcodebuild -exportArchive, so we substitute it here). | |
| sed "s/\$(APPLE_TEAM_ID)/${APPLE_TEAM_ID}/g" Scripts/ExportOptions.plist \ | |
| > /tmp/ExportOptions.plist | |
| set -o pipefail | |
| xcodebuild -exportArchive \ | |
| -archivePath build/Bocan.xcarchive \ | |
| -exportOptionsPlist /tmp/ExportOptions.plist \ | |
| -exportPath build/export \ | |
| | xcbeautify | |
| echo "=== Export contents ===" | |
| ls -la build/export/ | |
| - name: Bundle Homebrew dylibs into app (TagLib, FFmpeg, etc.) | |
| env: | |
| SIGNING_IDENTITY: ${{ secrets.DEVELOPER_ID_IDENTITY }} | |
| run: bash Scripts/embed-deps.sh build/export/Bocan.app | |
| - name: Verify code signature & hardened runtime | |
| run: | | |
| APP="build/export/Bocan.app" | |
| codesign --verify --deep --strict --verbose=2 "$APP" | |
| codesign --display --verbose=4 "$APP" 2>&1 | tee /tmp/codesign.txt | |
| if ! grep -q "flags=.*runtime" /tmp/codesign.txt; then | |
| echo "::error::Hardened runtime flag missing from signature." | |
| exit 1 | |
| fi | |
| - name: Notarize app | |
| env: | |
| APPLE_ID: ${{ secrets.APPLE_ID }} | |
| APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} | |
| APP_SPECIFIC_PASSWORD: ${{ secrets.APP_SPECIFIC_PASSWORD }} | |
| run: | | |
| # notarytool requires a zip for .app submission. | |
| ditto -c -k --keepParent "build/export/Bocan.app" "build/Bocan-notarize.zip" | |
| RESULT=$(xcrun notarytool submit "build/Bocan-notarize.zip" \ | |
| --apple-id "$APPLE_ID" \ | |
| --team-id "$APPLE_TEAM_ID" \ | |
| --password "$APP_SPECIFIC_PASSWORD" \ | |
| --wait --output-format plist) | |
| echo "$RESULT" | |
| SUBMISSION_ID=$(/usr/libexec/PlistBuddy -c "Print :id" /dev/stdin <<< "$RESULT") | |
| STATUS=$(/usr/libexec/PlistBuddy -c "Print :status" /dev/stdin <<< "$RESULT") | |
| if [[ "$STATUS" != "Accepted" ]]; then | |
| echo "::error::Notarization was not accepted (status: $STATUS). Fetching rejection log..." | |
| xcrun notarytool log "$SUBMISSION_ID" \ | |
| --apple-id "$APPLE_ID" \ | |
| --team-id "$APPLE_TEAM_ID" \ | |
| --password "$APP_SPECIFIC_PASSWORD" || true | |
| exit 1 | |
| fi | |
| - name: Staple app | |
| # Apple's CloudKit CDN can take a moment to propagate the ticket | |
| # even after notarytool --wait returns. Retry up to 5 times. | |
| run: | | |
| for attempt in $(seq 1 5); do | |
| xcrun stapler staple build/export/Bocan.app && break | |
| if [[ $attempt -lt 5 ]]; then | |
| echo "Staple attempt $attempt failed; waiting 30 s before retry..." | |
| sleep 30 | |
| else | |
| echo "::error::xcrun stapler failed after $attempt attempts." | |
| exit 1 | |
| fi | |
| done | |
| - name: Create DMG | |
| run: | | |
| create-dmg \ | |
| --volname "Bòcan ${RELEASE_VERSION}" \ | |
| --background "Resources/Distribution/dmg-background.png" \ | |
| --window-pos 200 120 \ | |
| --window-size 540 380 \ | |
| --icon-size 128 \ | |
| --icon "Bocan.app" 140 195 \ | |
| --hide-extension "Bocan.app" \ | |
| --app-drop-link 400 195 \ | |
| --volicon "Resources/Distribution/VolumeIcon.icns" \ | |
| "build/Bocan.dmg" \ | |
| "build/export/" \ | |
| || (sleep 5 && create-dmg \ | |
| --volname "Bòcan ${RELEASE_VERSION}" \ | |
| --background "Resources/Distribution/dmg-background.png" \ | |
| --window-pos 200 120 \ | |
| --window-size 540 380 \ | |
| --icon-size 128 \ | |
| --icon "Bocan.app" 140 195 \ | |
| --hide-extension "Bocan.app" \ | |
| --app-drop-link 400 195 \ | |
| --volicon "Resources/Distribution/VolumeIcon.icns" \ | |
| "build/Bocan.dmg" \ | |
| "build/export/") | |
| - name: Sign DMG | |
| run: | | |
| codesign --force --sign "${{ secrets.DEVELOPER_ID_IDENTITY }}" \ | |
| --timestamp build/Bocan.dmg | |
| - name: Notarize DMG | |
| env: | |
| APPLE_ID: ${{ secrets.APPLE_ID }} | |
| APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} | |
| APP_SPECIFIC_PASSWORD: ${{ secrets.APP_SPECIFIC_PASSWORD }} | |
| run: | | |
| xcrun notarytool submit "build/Bocan.dmg" \ | |
| --apple-id "$APPLE_ID" \ | |
| --team-id "$APPLE_TEAM_ID" \ | |
| --password "$APP_SPECIFIC_PASSWORD" \ | |
| --wait | |
| xcrun stapler staple build/Bocan.dmg | |
| - name: Gatekeeper assessment | |
| run: spctl --assess --type open --context context:primary-signature --verbose=4 build/Bocan.dmg | |
| - name: Compute checksums | |
| id: checksums | |
| run: | | |
| cd build | |
| shasum -a 256 Bocan.dmg | tee Bocan.dmg.sha256 | |
| SHA256=$(awk '{print $1}' Bocan.dmg.sha256) | |
| echo "sha256=$SHA256" >> "$GITHUB_OUTPUT" | |
| - name: Generate release notes from CHANGELOG | |
| id: notes | |
| run: | | |
| chmod +x Scripts/release-notes.sh | |
| NOTES_FILE=$(mktemp) | |
| Scripts/release-notes.sh "${MARKETING_VERSION#v}" > "$NOTES_FILE" || \ | |
| echo "_See commit history for changes._" > "$NOTES_FILE" | |
| { | |
| echo "notes_path=$NOTES_FILE" | |
| } >> "$GITHUB_OUTPUT" | |
| - name: Sign appcast entry (Sparkle EdDSA) | |
| if: env.HAS_SPARKLE_KEY == 'true' | |
| env: | |
| SPARKLE_ED_PRIVATE_KEY: ${{ secrets.SPARKLE_ED_PRIVATE_KEY }} | |
| run: | | |
| chmod +x Scripts/sparkle-update.sh | |
| Scripts/sparkle-update.sh \ | |
| --dmg build/Bocan.dmg \ | |
| --version "${RELEASE_VERSION}" \ | |
| --build "${BUILD_NUMBER}" \ | |
| --output build/appcast-entry.xml | |
| - name: Create GitHub Release | |
| uses: softprops/action-gh-release@v3 | |
| with: | |
| name: ${{ env.RELEASE_VERSION }} | |
| tag_name: ${{ github.event.inputs.tag || github.ref_name }} | |
| body_path: ${{ steps.notes.outputs.notes_path }} | |
| files: | | |
| build/Bocan.dmg | |
| build/Bocan.dmg.sha256 | |
| build/appcast-entry.xml | |
| fail_on_unmatched_files: false | |
| draft: false | |
| prerelease: ${{ contains(github.ref_name, '-beta') || contains(github.ref_name, '-rc') }} | |
| - name: Update appcast.xml | |
| if: env.HAS_SPARKLE_KEY == 'true' | |
| env: | |
| SPARKLE_ED_PRIVATE_KEY: ${{ secrets.SPARKLE_ED_PRIVATE_KEY }} | |
| run: | | |
| FEED="website/static/appcast.xml" | |
| if [[ "$GITHUB_REF_NAME" == *"-beta"* || "$GITHUB_REF_NAME" == *"-rc"* ]]; then | |
| FEED="website/static/appcast-beta.xml" | |
| fi | |
| chmod +x Scripts/sparkle-update.sh | |
| Scripts/sparkle-update.sh \ | |
| --dmg build/Bocan.dmg \ | |
| --version "${RELEASE_VERSION}" \ | |
| --build "${BUILD_NUMBER}" \ | |
| --prepend-to "$FEED" | |
| echo "APPCAST_FEED=$FEED" >> "$GITHUB_ENV" | |
| - name: Commit and push updated appcast | |
| if: env.HAS_SPARKLE_KEY == 'true' | |
| run: | | |
| git config user.name "github-actions[bot]" | |
| git config user.email "github-actions[bot]@users.noreply.github.com" | |
| git add website/static/appcast.xml website/static/appcast-beta.xml | |
| git diff --cached --quiet && echo "No appcast changes to commit" && exit 0 | |
| git commit -m "chore(release): update appcast for v${RELEASE_VERSION}" | |
| git push origin HEAD:main | |
| - name: Trigger cask update | |
| uses: peter-evans/repository-dispatch@v4 | |
| with: | |
| token: ${{ secrets.HOMEBREW_TAP_TOKEN }} | |
| repository: bocan/homebrew-bocan | |
| event-type: new-release | |
| client-payload: | | |
| { | |
| "version": "${{ env.RELEASE_VERSION }}", | |
| "sha256": "${{ steps.checksums.outputs.sha256 }}" | |
| } |