Skip to content

ci(release): accumulate Sparkle notes within a minor; lead with break… #27

ci(release): accumulate Sparkle notes within a minor; lead with break…

ci(release): accumulate Sparkle notes within a minor; lead with break… #27

Workflow file for this run

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