chore(release): 1.0.6 #161
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 | |
| # Manual release cycle (MAK-58). A release is cut ONLY when a version tag is | |
| # pushed — merges to main no longer publish anything. Cut one with | |
| # `scripts/release.sh [major|minor|patch]`, which bumps the version and pushes a | |
| # tag of the form v<version>+<build> (e.g. v1.0.1+156): | |
| # - <version> the human short version (CFBundleShortVersionString) | |
| # - <build> the monotonic CFBundleVersion Sparkle orders updates by | |
| # The workflow_dispatch button is a manual fallback (re-run a build for the tag | |
| # currently checked out); the normal path is a tag push. | |
| on: | |
| push: | |
| tags: ['v*'] | |
| workflow_dispatch: # manual fallback / re-run | |
| permissions: | |
| contents: write # needed to create releases + push tags | |
| concurrency: | |
| group: release | |
| cancel-in-progress: false | |
| jobs: | |
| release: | |
| runs-on: macos-14 | |
| steps: | |
| - name: Checkout (with whisper.cpp submodule) | |
| uses: actions/checkout@v4 | |
| with: | |
| submodules: recursive | |
| fetch-depth: 0 | |
| # The default runner Xcode (15.4 / Swift 5.10) rejects the app's modern | |
| # concurrency; select the newest stable Xcode (Swift 6). | |
| - name: Select Xcode | |
| uses: maxim-lobanov/setup-xcode@60606e260d2fc5762a71e64e74b2174e8ea3c8bd # v1.6.0 | |
| with: | |
| xcode-version: latest-stable | |
| - name: Show toolchain | |
| run: swift --version | |
| - name: Run unit tests | |
| run: swift test | |
| # Version + build come from the TAG (v<version>+<build>), not the run | |
| # number, so a manually-cut release is fully reproducible: re-running the | |
| # workflow for the same tag rebuilds the same version + build. On a | |
| # workflow_dispatch with no tag ref we fall back to Info.plist + run number | |
| # so the manual button still produces a valid (if throwaway) build. | |
| - name: Compute version | |
| id: version | |
| run: | | |
| REF="${GITHUB_REF#refs/tags/}" | |
| if [[ "$REF" =~ ^v([0-9]+\.[0-9]+\.[0-9]+)\+([0-9]+)$ ]]; then | |
| BASE="${BASH_REMATCH[1]}" | |
| BUILD="${BASH_REMATCH[2]}" | |
| TAG="v${BASE}+${BUILD}" | |
| else | |
| echo "::warning::Not a v<version>+<build> tag ('$GITHUB_REF') — falling back to Info.plist + run number (manual dispatch)." | |
| BASE=$(/usr/libexec/PlistBuddy -c "Print :CFBundleShortVersionString" OpenWhisp/Info.plist) | |
| BUILD="${GITHUB_RUN_NUMBER}" | |
| TAG="v${BASE}+${BUILD}" | |
| fi | |
| echo "base=$BASE" >> "$GITHUB_OUTPUT" | |
| echo "build=$BUILD" >> "$GITHUB_OUTPUT" | |
| echo "tag=$TAG" >> "$GITHUB_OUTPUT" | |
| echo "Release tag: $TAG (commit ${GITHUB_SHA:0:7})" | |
| - name: Stamp version + build number into Info.plist | |
| run: | | |
| /usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString ${{ steps.version.outputs.base }}" OpenWhisp/Info.plist | |
| /usr/libexec/PlistBuddy -c "Set :CFBundleVersion ${{ steps.version.outputs.build }}" OpenWhisp/Info.plist | |
| - name: Build whisper.cpp (submodule) | |
| run: ./scripts/build-whisper.sh | |
| # The built-in AI provider (bundled llama.cpp) is the app default, so the | |
| # release DMG must include its runtime — build-dmg.sh release fails without it. | |
| # | |
| # This build takes ~65 min cold on the runner, so cache the cmake build tree | |
| # keyed on the pinned submodule SHA + the build script (flags live there). | |
| # On a hit, `cmake --build` below is an incremental no-op (~seconds). | |
| - name: Compute llama.cpp cache key | |
| id: llama-key | |
| run: echo "sha=$(git submodule status third_party/llama.cpp | awk '{print $1}' | tr -d '+-')" >> "$GITHUB_OUTPUT" | |
| # Split restore/save (not the combined actions/cache): the combined action | |
| # saves in a post step that is SKIPPED when the job fails — and a failing | |
| # release (signing/notarization flake) is precisely when the next run needs | |
| # the cache. Saving explicitly right after the build guarantees it. | |
| - name: Restore llama.cpp build cache | |
| id: llama-cache | |
| uses: actions/cache/restore@v4 | |
| with: | |
| path: third_party/llama.cpp/build | |
| key: llama-build-${{ runner.os }}-${{ steps.llama-key.outputs.sha }}-${{ hashFiles('scripts/build-llama.sh') }} | |
| - name: Build llama.cpp (submodule) | |
| run: ./scripts/build-llama.sh | |
| - name: Save llama.cpp build cache | |
| if: steps.llama-cache.outputs.cache-hit != 'true' | |
| uses: actions/cache/save@v4 | |
| with: | |
| path: third_party/llama.cpp/build | |
| key: llama-build-${{ runner.os }}-${{ steps.llama-key.outputs.sha }}-${{ hashFiles('scripts/build-llama.sh') }} | |
| # Signing is enabled only when the Developer ID secret is present. Forks and | |
| # setups without the secret still get an ad-hoc DMG (unchanged behavior). | |
| # Fail fast on a HALF-configured setup (cert without notary credentials, or | |
| # missing passwords): a clear error here beats a confusing notarytool failure | |
| # twenty minutes into the build. | |
| - name: Detect signing availability | |
| id: signing | |
| env: | |
| MACOS_CERT_P12_BASE64: ${{ secrets.MACOS_CERT_P12_BASE64 }} | |
| MACOS_CERT_PASSWORD: ${{ secrets.MACOS_CERT_PASSWORD }} | |
| KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} | |
| NOTARY_APPLE_ID: ${{ secrets.NOTARY_APPLE_ID }} | |
| NOTARY_TEAM_ID: ${{ secrets.NOTARY_TEAM_ID }} | |
| NOTARY_PASSWORD: ${{ secrets.NOTARY_PASSWORD }} | |
| run: | | |
| if [ -z "$MACOS_CERT_P12_BASE64" ]; then | |
| echo "enabled=false" >> "$GITHUB_OUTPUT" | |
| echo "No MACOS_CERT_P12_BASE64 secret — building ad-hoc (unsigned) DMG." | |
| exit 0 | |
| fi | |
| MISSING="" | |
| for VAR in MACOS_CERT_PASSWORD KEYCHAIN_PASSWORD NOTARY_APPLE_ID NOTARY_TEAM_ID NOTARY_PASSWORD; do | |
| [ -n "${!VAR:-}" ] || MISSING="$MISSING $VAR" | |
| done | |
| if [ -n "$MISSING" ]; then | |
| echo "::error::MACOS_CERT_P12_BASE64 is set but these signing secrets are missing:$MISSING" | |
| echo "::error::Set them all (see docs/SIGNING_AND_NOTARIZATION.md) or remove MACOS_CERT_P12_BASE64 to build ad-hoc." | |
| exit 1 | |
| fi | |
| echo "enabled=true" >> "$GITHUB_OUTPUT" | |
| - name: Import Developer ID certificate | |
| if: steps.signing.outputs.enabled == 'true' | |
| id: cert | |
| env: | |
| MACOS_CERT_P12_BASE64: ${{ secrets.MACOS_CERT_P12_BASE64 }} | |
| MACOS_CERT_PASSWORD: ${{ secrets.MACOS_CERT_PASSWORD }} | |
| KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} | |
| run: | | |
| # The identity name is NOT masked: it's embedded in the signature of | |
| # every published DMG (anyone can read it with `codesign -dv`), and | |
| # masking it would make the runner drop the step output ("Skip output | |
| # since it may contain secret"), leaving SIGN_IDENTITY empty and | |
| # failing the signed build. | |
| IDENTITY="$(./scripts/ci-import-cert.sh)" | |
| echo "Imported identity: $IDENTITY" | |
| echo "identity=$IDENTITY" >> "$GITHUB_OUTPUT" | |
| - name: Build app + DMG (signed + notarized) | |
| if: steps.signing.outputs.enabled == 'true' | |
| env: | |
| NOTARIZE: "1" | |
| SIGN_IDENTITY: ${{ steps.cert.outputs.identity }} | |
| NOTARY_APPLE_ID: ${{ secrets.NOTARY_APPLE_ID }} | |
| NOTARY_TEAM_ID: ${{ secrets.NOTARY_TEAM_ID }} | |
| NOTARY_PASSWORD: ${{ secrets.NOTARY_PASSWORD }} | |
| run: ./build-dmg.sh release | |
| - name: Build app + DMG (ad-hoc, unsigned) | |
| if: steps.signing.outputs.enabled != 'true' | |
| run: ./build-dmg.sh release | |
| - name: Verify DMG exists | |
| run: | | |
| test -f dist/OpenWhisp.dmg | |
| ls -lh dist/OpenWhisp.dmg | |
| # When signing was supposed to happen, REFUSE to publish a DMG that isn't | |
| # actually Developer ID signed + notarized (e.g. a silent fallback to the | |
| # self-signed/ad-hoc identity). A broken release is better absent than shipped. | |
| - name: Verify DMG is signed + notarized | |
| if: steps.signing.outputs.enabled == 'true' | |
| run: ./scripts/verify-signed-notarized.sh dist/OpenWhisp.dmg | |
| # Sparkle appcast (MAK-56). Only runs when the EdDSA private key secret is | |
| # present — forks without it skip this and publish a DMG with no appcast | |
| # (their users just don't get auto-update, everything else works). The | |
| # enclosure points at the TAG-SPECIFIC asset URL so old appcast entries stay | |
| # valid; the feed is hosted via the stable releases/latest/download redirect | |
| # declared in Info.plist's SUFeedURL. | |
| # | |
| # Gated on Developer ID signing being enabled, same as the verify step: | |
| # an EdDSA-signed appcast for an ad-hoc fallback DMG would make the | |
| # installed, notarized fleet auto-update onto an unsigned build (Sparkle | |
| # only checks the EdDSA signature) — breaking every identity-keyed TCC | |
| # grant (mic, accessibility) app-wide. The DMG gets the "refuse to | |
| # publish" treatment; the feed must too. | |
| - name: Generate signed appcast | |
| id: appcast | |
| if: steps.signing.outputs.enabled == 'true' | |
| env: | |
| SPARKLE_ED_PRIVATE_KEY: ${{ secrets.SPARKLE_ED_PRIVATE_KEY }} | |
| run: | | |
| if [ -z "$SPARKLE_ED_PRIVATE_KEY" ]; then | |
| echo "No SPARKLE_ED_PRIVATE_KEY secret — skipping appcast (auto-update disabled for this build)." | |
| echo "generated=false" >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| # Materialize the pinned Sparkle tools (sign_update) via the fetch script. | |
| eval "$(./scripts/fetch-sparkle.sh)" | |
| TAG="${{ steps.version.outputs.tag }}" | |
| DMG_URL="https://github.com/${GITHUB_REPOSITORY}/releases/download/${TAG}/OpenWhisp.dmg" | |
| NOTES_LINK="https://github.com/${GITHUB_REPOSITORY}/releases/tag/${TAG}" | |
| MIN_SYSTEM="$(/usr/libexec/PlistBuddy -c 'Print :LSMinimumSystemVersion' OpenWhisp/Info.plist)" | |
| ./scripts/gen-appcast.sh \ | |
| --version "${{ steps.version.outputs.base }}" \ | |
| --build "${{ steps.version.outputs.build }}" \ | |
| --dmg dist/OpenWhisp.dmg \ | |
| --url "$DMG_URL" \ | |
| --notes-link "$NOTES_LINK" \ | |
| --sign-tool "$SPARKLE_BIN/sign_update" \ | |
| --min-system "$MIN_SYSTEM" \ | |
| --changelog docs/changelog/changelog.json \ | |
| --out dist/appcast.xml | |
| echo "generated=true" >> "$GITHUB_OUTPUT" | |
| echo "----- appcast.xml -----" | |
| cat dist/appcast.xml | |
| - name: Publish GitHub Release | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| SIGNED: ${{ steps.signing.outputs.enabled }} | |
| APPCAST: ${{ steps.appcast.outputs.generated }} | |
| run: | | |
| TAG="${{ steps.version.outputs.tag }}" | |
| NOTES_FILE="$(mktemp)" | |
| # Auto-generate the changelog from merged PRs / commits since the | |
| # previous tag. GitHub's generate-notes endpoint builds the categorized | |
| # "What's Changed" list and a full changelog compare link. It needs the | |
| # tag to already exist as a ref, which it does — the tag push is what | |
| # triggered this run. `previous_tag_name` is left unset so GitHub picks | |
| # the last release automatically. Best-effort: if the API call fails | |
| # (e.g. first-ever tag), fall back to a plain commit summary so a | |
| # release still publishes with *some* changelog. | |
| CHANGELOG="$(gh api \ | |
| --method POST \ | |
| "repos/${GITHUB_REPOSITORY}/releases/generate-notes" \ | |
| -f tag_name="$TAG" \ | |
| -f target_commitish="$GITHUB_SHA" \ | |
| --jq '.body' 2>/dev/null || true)" | |
| if [ -z "$CHANGELOG" ]; then | |
| echo "::warning::generate-notes returned nothing — using a plain commit list." | |
| PREV_TAG="$(git tag --list 'v*' | grep -v -F "$TAG" | sort -V | tail -1 || true)" | |
| if [ -n "$PREV_TAG" ]; then | |
| CHANGELOG="$(printf '## What'\''s Changed\n\n'; git log --no-merges --pretty='* %s' "${PREV_TAG}..HEAD")" | |
| else | |
| CHANGELOG="$(printf '## What'\''s Changed\n\n'; git log --no-merges --pretty='* %s' -n 30)" | |
| fi | |
| fi | |
| { | |
| echo "$CHANGELOG" | |
| echo "" | |
| echo "---" | |
| echo "" | |
| echo "**Install:** download \`OpenWhisp.dmg\`, open it, drag **OpenWhisp** to Applications." | |
| echo "" | |
| if [ "$SIGNED" = "true" ]; then | |
| echo "> This build is **signed with a Developer ID and notarized by Apple**, so it" | |
| echo "> opens normally — no Gatekeeper warning." | |
| else | |
| echo "> This build is **ad-hoc signed**, so macOS Gatekeeper blocks it on first" | |
| echo "> launch (\"damaged / cannot be opened\"). To open:" | |
| echo "> • **macOS 15+:** launch once, then **System Settings → Privacy & Security → Open Anyway**." | |
| echo "> • **Any version (Terminal):** \`xattr -dr com.apple.quarantine /Applications/OpenWhisp.app\`" | |
| fi | |
| echo "" | |
| if [ "$APPCAST" = "true" ]; then | |
| echo "> Once installed, OpenWhisp keeps itself up to date via **Sparkle** — the" | |
| echo "> update check is EdDSA-signed and the only network call the app makes by" | |
| echo "> default. Turn it off in **Settings → General → Software Update**." | |
| echo "" | |
| fi | |
| echo "See the [README](https://github.com/${GITHUB_REPOSITORY}#readme) for setup and permissions." | |
| } > "$NOTES_FILE" | |
| # Attach the appcast alongside the DMG when it was generated. The | |
| # Info.plist SUFeedURL uses the stable releases/latest/download redirect, | |
| # so this asset becomes the live feed the moment the release publishes. | |
| ASSETS=(dist/OpenWhisp.dmg) | |
| if [ "$APPCAST" = "true" ] && [ -f dist/appcast.xml ]; then | |
| ASSETS+=(dist/appcast.xml) | |
| fi | |
| gh release create "$TAG" \ | |
| "${ASSETS[@]}" \ | |
| --title "OpenWhisp $TAG" \ | |
| --notes-file "$NOTES_FILE" \ | |
| --target "$GITHUB_SHA" | |
| # Homebrew cask bump (MAK-55). After the release is published, recompute the | |
| # DMG sha256, rewrite packaging/homebrew/Casks/openwhisp.rb, and push it to | |
| # the self-hosted tap (initcore0/homebrew-openwhisp) so `brew upgrade` sees | |
| # the new build. | |
| # | |
| # Only meaningful for a real notarized DMG — an ad-hoc/unsigned build (no | |
| # signing secret) must never become a `brew install --cask` target. And the | |
| # push is GUARDED: bump-cask.sh no-ops with a clear log when TAP_PUSH_TOKEN | |
| # is absent (the tap repo isn't published yet), so releases never fail before | |
| # the tap exists. | |
| - name: Bump Homebrew cask + push to tap | |
| if: steps.signing.outputs.enabled == 'true' | |
| env: | |
| TAP_PUSH_TOKEN: ${{ secrets.TAP_PUSH_TOKEN }} | |
| run: | | |
| ./scripts/bump-cask.sh \ | |
| --version "${{ steps.version.outputs.base }}" \ | |
| --build "${{ steps.version.outputs.build }}" \ | |
| --dmg dist/OpenWhisp.dmg |