ci(release): accumulate Sparkle notes within a minor; lead with break… #27
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 | |
| # Push a tag like `v1.2.0` to cut a release: builds, signs, notarizes, | |
| # packages a DMG, signs it for Sparkle, regenerates appcast.xml, publishes a | |
| # GitHub Release, and updates the appcast on GitHub Pages. | |
| on: | |
| push: | |
| tags: | |
| - "v*" | |
| permissions: | |
| contents: write | |
| pages: write | |
| id-token: write | |
| concurrency: | |
| group: release-${{ github.ref }} | |
| cancel-in-progress: false | |
| jobs: | |
| release: | |
| runs-on: macos-26 | |
| env: | |
| TUIST_DEVELOPMENT_TEAM: ${{ secrets.DEVELOPMENT_TEAM }} | |
| TUIST_SPARKLE_PUBLIC_ED_KEY: ${{ secrets.SPARKLE_PUBLIC_ED_KEY }} | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - name: Resolve version | |
| id: version | |
| run: | | |
| set -euo pipefail | |
| TAG_VERSION="${GITHUB_REF_NAME#v}" | |
| APP_VERSION="$(grep -E '^let appVersion' Project.swift | sed -E 's/.*"([^"]+)".*/\1/')" | |
| if [ "$TAG_VERSION" != "$APP_VERSION" ]; then | |
| echo "::error::Tag ($TAG_VERSION) does not match Project.swift appVersion ($APP_VERSION). Bump appVersion and retag." >&2 | |
| exit 1 | |
| fi | |
| echo "version=$TAG_VERSION" >> "$GITHUB_OUTPUT" | |
| - uses: jdx/mise-action@v4 | |
| - name: Import Developer ID certificate | |
| env: | |
| CERT_P12_BASE64: ${{ secrets.DEVELOPER_ID_P12_BASE64 }} | |
| CERT_PASSWORD: ${{ secrets.DEVELOPER_ID_P12_PASSWORD }} | |
| KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} | |
| run: | | |
| set -euo pipefail | |
| KEYCHAIN="$RUNNER_TEMP/build.keychain-db" | |
| security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN" | |
| security set-keychain-settings -lut 21600 "$KEYCHAIN" | |
| security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN" | |
| echo "$CERT_P12_BASE64" | base64 --decode > "$RUNNER_TEMP/cert.p12" | |
| security import "$RUNNER_TEMP/cert.p12" -k "$KEYCHAIN" -P "$CERT_PASSWORD" \ | |
| -T /usr/bin/codesign -T /usr/bin/productsign | |
| security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN" | |
| security list-keychains -d user -s "$KEYCHAIN" $(security list-keychains -d user | tr -d '"') | |
| - name: Generate Xcode project | |
| env: | |
| TUIST_BUILD_NUMBER: ${{ github.run_number }} | |
| run: | | |
| tuist install | |
| tuist generate --no-open | |
| - name: Archive (Developer ID, Release) | |
| run: | | |
| set -euo pipefail | |
| xcodebuild archive \ | |
| -workspace SwiftyCrow.xcworkspace \ | |
| -scheme SwiftyCrow \ | |
| -configuration Release \ | |
| -destination 'generic/platform=macOS' \ | |
| -archivePath "$RUNNER_TEMP/SwiftyCrow.xcarchive" \ | |
| CODE_SIGN_STYLE=Manual \ | |
| CODE_SIGN_IDENTITY="Developer ID Application" \ | |
| DEVELOPMENT_TEAM="$TUIST_DEVELOPMENT_TEAM" \ | |
| OTHER_CODE_SIGN_FLAGS="--timestamp --options=runtime" | |
| - name: Export app | |
| run: | | |
| set -euo pipefail | |
| cp -R "$RUNNER_TEMP/SwiftyCrow.xcarchive/Products/Applications/SwiftyCrow.app" "$RUNNER_TEMP/SwiftyCrow.app" | |
| - name: Re-sign Sparkle nested helpers | |
| run: | | |
| set -euo pipefail | |
| # xcodebuild re-signs Sparkle.framework itself but leaves its nested | |
| # helper bundles/binaries (Updater.app, Autoupdate, XPCServices) with | |
| # Sparkle's original signature, which notarization rejects. Re-sign | |
| # them inside-out with our Developer ID, hardened runtime + timestamp. | |
| APP="$RUNNER_TEMP/SwiftyCrow.app" | |
| SPARKLE="$APP/Contents/Frameworks/Sparkle.framework" | |
| sign() { | |
| codesign --force --options runtime --timestamp \ | |
| --preserve-metadata=entitlements \ | |
| --sign "Developer ID Application" "$1" | |
| } | |
| sign "$SPARKLE/Versions/Current/XPCServices/Installer.xpc" | |
| sign "$SPARKLE/Versions/Current/XPCServices/Downloader.xpc" | |
| sign "$SPARKLE/Versions/Current/Autoupdate" | |
| sign "$SPARKLE/Versions/Current/Updater.app" | |
| sign "$SPARKLE" | |
| sign "$APP" | |
| codesign --verify --deep --strict --verbose=2 "$APP" | |
| - name: Create DMG | |
| run: | | |
| set -euo pipefail | |
| brew install create-dmg | |
| create-dmg \ | |
| --volname "SwiftyCrow" \ | |
| --app-drop-link 420 180 \ | |
| --window-size 600 360 \ | |
| "$RUNNER_TEMP/SwiftyCrow-${{ steps.version.outputs.version }}.dmg" \ | |
| "$RUNNER_TEMP/SwiftyCrow.app" | |
| - name: Notarize + staple | |
| env: | |
| AC_API_KEY_ID: ${{ secrets.AC_API_KEY_ID }} | |
| AC_API_ISSUER_ID: ${{ secrets.AC_API_ISSUER_ID }} | |
| AC_API_KEY_P8: ${{ secrets.AC_API_KEY_P8 }} | |
| run: | | |
| set -euo pipefail | |
| echo "$AC_API_KEY_P8" > "$RUNNER_TEMP/ac_api_key.p8" | |
| DMG="$RUNNER_TEMP/SwiftyCrow-${{ steps.version.outputs.version }}.dmg" | |
| SUBMIT_JSON="$(xcrun notarytool submit "$DMG" \ | |
| --key "$RUNNER_TEMP/ac_api_key.p8" \ | |
| --key-id "$AC_API_KEY_ID" \ | |
| --issuer "$AC_API_ISSUER_ID" \ | |
| --output-format json --wait)" | |
| echo "$SUBMIT_JSON" | |
| SUB_ID="$(echo "$SUBMIT_JSON" | python3 -c 'import sys, json; print(json.load(sys.stdin)["id"])')" | |
| SUB_STATUS="$(echo "$SUBMIT_JSON" | python3 -c 'import sys, json; print(json.load(sys.stdin)["status"])')" | |
| if [ "$SUB_STATUS" != "Accepted" ]; then | |
| echo "Notarization failed with status: $SUB_STATUS. Fetching log:" | |
| xcrun notarytool log "$SUB_ID" \ | |
| --key "$RUNNER_TEMP/ac_api_key.p8" \ | |
| --key-id "$AC_API_KEY_ID" \ | |
| --issuer "$AC_API_ISSUER_ID" || true | |
| exit 1 | |
| fi | |
| xcrun stapler staple "$DMG" | |
| - name: Sign for Sparkle + build appcast | |
| env: | |
| SPARKLE_PRIVATE_ED_KEY: ${{ secrets.SPARKLE_PRIVATE_ED_KEY }} | |
| run: | | |
| set -euo pipefail | |
| SPARKLE_BIN="$(find Tuist/.build -path '*/Sparkle/bin' -type d | head -1)" | |
| mkdir -p "$RUNNER_TEMP/release" | |
| cp "$RUNNER_TEMP/SwiftyCrow-${{ steps.version.outputs.version }}.dmg" "$RUNNER_TEMP/release/" | |
| echo "$SPARKLE_PRIVATE_ED_KEY" > "$RUNNER_TEMP/sparkle_ed_private.key" | |
| # generate_appcast signs every archive in the folder and emits appcast.xml | |
| "$SPARKLE_BIN/generate_appcast" \ | |
| --ed-key-file "$RUNNER_TEMP/sparkle_ed_private.key" \ | |
| --download-url-prefix "https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/" \ | |
| "$RUNNER_TEMP/release" | |
| - name: Embed release notes in appcast | |
| env: | |
| APPCAST: ${{ runner.temp }}/release/appcast.xml | |
| VERSION: ${{ steps.version.outputs.version }} | |
| run: | | |
| set -euo pipefail | |
| # The appcast is a single <item> (this release), but a user updating | |
| # from an older patch should see everything that landed since. So | |
| # Sparkle's notes accumulate every section from this version down to | |
| # the start of its minor series (2.6.2 → 2.6.1 → 2.6.0), stopping at | |
| # the previous minor (those users already had it). The website / | |
| # GitHub release show only this version's own section — CHANGELOG.md | |
| # stays one-section-per-version, no hand-carried notes. | |
| APPCAST="$APPCAST" VERSION="$VERSION" python3 - <<'PY' | |
| import os, re, html as H | |
| version = os.environ["VERSION"] | |
| minor = ".".join(version.split(".")[:2]) # 2.6.1 -> 2.6 | |
| # Split CHANGELOG into (heading, body-lines) version sections. | |
| sections, cur = [], None | |
| for line in open("CHANGELOG.md", encoding="utf-8").read().splitlines(): | |
| if line.startswith("## "): | |
| cur = {"head": line[3:].strip(), "lines": []} | |
| sections.append(cur) | |
| elif cur is not None: | |
| cur["lines"].append(line) | |
| def secver(head): | |
| m = re.match(r"v?(\d+\.\d+\.\d+)", head) | |
| return m.group(1) if m else None | |
| collected, started = [], False | |
| for s in sections: | |
| v = secver(s["head"]) | |
| if v == version: | |
| started = True | |
| if not started: | |
| continue | |
| if v is None or ".".join(v.split(".")[:2]) != minor: | |
| break | |
| collected.append(s) | |
| def inline(s): | |
| s = H.escape(s) | |
| s = re.sub(r"`([^`]+)`", r"<code>\1</code>", s) | |
| s = re.sub(r"\*\*([^*]+)\*\*", r"<strong>\1</strong>", s) | |
| s = re.sub(r"\[([^\]]+)\]\(([^)]+)\)", r'<a href="\2">\1</a>', s) | |
| s = re.sub(r"(?<!\*)\*([^*]+)\*(?!\*)", r"<em>\1</em>", s) | |
| return s | |
| out, in_ul = [], False | |
| for s in collected: | |
| if in_ul: out.append("</ul>"); in_ul = False | |
| out.append("<h2>" + inline(s["head"]) + "</h2>") | |
| for line in s["lines"]: | |
| if line.startswith("### "): | |
| if in_ul: out.append("</ul>"); in_ul = False | |
| out.append("<h3>" + inline(line[4:].strip()) + "</h3>") | |
| elif line.startswith("- "): | |
| if not in_ul: out.append("<ul>"); in_ul = True | |
| out.append("<li>" + inline(line[2:].strip()) + "</li>") | |
| elif line.strip() == "": | |
| if in_ul: out.append("</ul>"); in_ul = False | |
| else: | |
| if in_ul: out.append("</ul>"); in_ul = False | |
| out.append("<p>" + inline(line.strip()) + "</p>") | |
| if in_ul: out.append("</ul>") | |
| html = "\n".join(out).strip() | |
| appcast = os.environ["APPCAST"] | |
| xml = open(appcast, encoding="utf-8").read() | |
| if html and "<description>" not in xml: | |
| desc = "<description><![CDATA[\n" + html + "\n]]></description>\n " | |
| xml = xml.replace("</item>", desc + "</item>", 1) | |
| open(appcast, "w", encoding="utf-8").write(xml) | |
| print("appcast: embedded release notes (%d section(s))" % len(collected)) | |
| else: | |
| print("appcast: skipped (no notes or description already present)") | |
| PY | |
| - name: Build release notes from CHANGELOG | |
| env: | |
| VERSION: ${{ steps.version.outputs.version }} | |
| TAG: ${{ github.ref_name }} | |
| REPO: ${{ github.repository }} | |
| run: | | |
| set -euo pipefail | |
| NOTES="$RUNNER_TEMP/release-notes.md" | |
| # Pull this version's section from CHANGELOG.md (the source of truth), | |
| # promoting its ### subheadings to ## for the GitHub release. | |
| awk -v h="## ${VERSION} " 'index($0, h) == 1 { g = 1; next } g && /^## / { exit } g { print }' \ | |
| CHANGELOG.md | sed 's/^### /## /' > "$NOTES" | |
| if [ ! -s "$NOTES" ]; then | |
| echo "See the [changelog](https://github.com/${REPO}/blob/main/CHANGELOG.md)." > "$NOTES" | |
| fi | |
| # Append the install/update boilerplate (kept out of CHANGELOG.md). | |
| { | |
| echo "" | |
| echo "## Install / Update" | |
| echo "" | |
| echo "- Homebrew: \`brew upgrade --cask swiftycrow\`" | |
| echo "- Direct: [SwiftyCrow-${VERSION}.dmg](https://github.com/${REPO}/releases/download/${TAG}/SwiftyCrow-${VERSION}.dmg)" | |
| echo "- In-app: Sparkle will pick this up on its next check." | |
| } >> "$NOTES" | |
| - name: Publish GitHub Release | |
| run: | | |
| set -euo pipefail | |
| TAG="${{ github.ref_name }}" | |
| DMG="$RUNNER_TEMP/SwiftyCrow-${{ steps.version.outputs.version }}.dmg" | |
| # Idempotent so a re-run after a partial failure (e.g. a later step | |
| # failing) updates the existing release instead of erroring. | |
| if gh release view "$TAG" >/dev/null 2>&1; then | |
| gh release upload "$TAG" "$DMG" --clobber | |
| gh release edit "$TAG" --title "$TAG" --notes-file "$RUNNER_TEMP/release-notes.md" | |
| else | |
| gh release create "$TAG" "$DMG" \ | |
| --title "$TAG" \ | |
| --notes-file "$RUNNER_TEMP/release-notes.md" | |
| fi | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| - name: Update Homebrew cask | |
| env: | |
| TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} | |
| run: | | |
| set -euo pipefail | |
| VERSION="${{ steps.version.outputs.version }}" | |
| DMG="$RUNNER_TEMP/SwiftyCrow-${VERSION}.dmg" | |
| SHA="$(shasum -a 256 "$DMG" | awk '{print $1}')" | |
| git clone --depth 1 \ | |
| "https://x-access-token:${TAP_TOKEN}@github.com/${{ github.repository_owner }}/homebrew-tap.git" \ | |
| "$RUNNER_TEMP/tap" | |
| CASK="$RUNNER_TEMP/tap/Casks/swiftycrow.rb" | |
| sed -i.bak -E "s|^ version \".*\"| version \"${VERSION}\"|" "$CASK" | |
| sed -i.bak -E "s|^ sha256 \".*\"| sha256 \"${SHA}\"|" "$CASK" | |
| rm -f "${CASK}.bak" | |
| cd "$RUNNER_TEMP/tap" | |
| if git diff --quiet; then | |
| echo "Cask already up to date." | |
| exit 0 | |
| fi | |
| git config user.name "github-actions[bot]" | |
| git config user.email "41898282+github-actions[bot]@users.noreply.github.com" | |
| git commit -am "swiftycrow ${VERSION}" | |
| git push | |
| - name: Assemble GitHub Pages site (landing page + appcast) | |
| env: | |
| VERSION: ${{ steps.version.outputs.version }} | |
| run: | | |
| set -euo pipefail | |
| mkdir -p "$RUNNER_TEMP/site" | |
| # Static pages (configuration.html / releases.html render their source | |
| # of truth at runtime; index.html gets the release version injected). | |
| cp web/*.html web/*.css "$RUNNER_TEMP/site/" | |
| sed "s/__VERSION__/${VERSION}/g" web/index.html > "$RUNNER_TEMP/site/index.html" | |
| cp Resources/Marketing/app-icon.png "$RUNNER_TEMP/site/icon.png" | |
| cp Resources/Marketing/demo.gif "$RUNNER_TEMP/site/demo.gif" | |
| # Cache-bust the stylesheet so a CSS change is never masked by a | |
| # stale browser cache. (macOS sed needs an explicit -i suffix; drop | |
| # the .bak copies so they don't get deployed.) | |
| BUST="${GITHUB_SHA:0:8}" | |
| sed -i.bak "s#\"./style.css\"#\"./style.css?v=${BUST}\"#g" "$RUNNER_TEMP/site"/*.html | |
| rm -f "$RUNNER_TEMP/site"/*.html.bak | |
| # Sparkle feed. | |
| cp "$RUNNER_TEMP/release/appcast.xml" "$RUNNER_TEMP/site/appcast.xml" | |
| - uses: actions/upload-pages-artifact@v5 | |
| with: | |
| path: ${{ runner.temp }}/site | |
| deploy-pages: | |
| needs: release | |
| runs-on: ubuntu-latest | |
| environment: | |
| name: github-pages | |
| url: ${{ steps.deployment.outputs.page_url }} | |
| steps: | |
| - id: deployment | |
| uses: actions/deploy-pages@v5 |