release v0.4.0 #9
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 DMG | |
| # Builds the macOS .dmg when a version tag is pushed (e.g. `v0.1.0`) and attaches | |
| # it to the GitHub Release for that tag. Can also be run manually for a dry run. | |
| # | |
| # Signing + notarization are OPTIONAL and gated on repository secrets being set: | |
| # DEVELOPER_ID_APPLICATION "Developer ID Application: Your Name (TEAMID)" | |
| # DEVELOPER_ID_CERT_P12 base64 of the exported .p12 certificate | |
| # DEVELOPER_ID_CERT_PASSWORD password for that .p12 | |
| # APPLE_ID / APPLE_APP_PASSWORD / APPLE_TEAM_ID notarytool credentials | |
| # With those set, the DMG is signed with Developer ID + notarized + stapled. | |
| # Without them, only workflow_dispatch dry runs may proceed (producing an | |
| # ad-hoc-signed DMG artifact for testing); tag pushes fail instead of | |
| # publishing an unsigned build to the Sparkle update channel. | |
| # | |
| # Sparkle auto-updates additionally REQUIRE (tag pushes fail without it): | |
| # SPARKLE_ED_PRIVATE_KEY EdDSA private key from Sparkle's generate_keys; | |
| # signs the DMG and the published appcast.xml. | |
| # See docs/ci.md. | |
| on: | |
| push: | |
| tags: | |
| - "v*" | |
| workflow_dispatch: | |
| inputs: | |
| tag: | |
| description: "Version tag to build (e.g. v0.1.0)" | |
| required: true | |
| permissions: | |
| contents: write # create/update the GitHub Release and upload the DMG | |
| concurrency: | |
| group: release-${{ github.ref }} | |
| cancel-in-progress: false | |
| jobs: | |
| build: | |
| # Apple Silicon runner with Xcode 26+ (Swift 6.3) — required by MLX / MLX-Swift. | |
| runs-on: macos-15 | |
| timeout-minutes: 90 | |
| env: | |
| DEVELOPER_ID: ${{ secrets.DEVELOPER_ID_APPLICATION }} | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Resolve version | |
| id: version | |
| run: | | |
| REF="${{ github.event.inputs.tag || github.ref_name }}" | |
| echo "tag=$REF" >> "$GITHUB_OUTPUT" | |
| echo "version=${REF#v}" >> "$GITHUB_OUTPUT" | |
| echo "Building $REF (version ${REF#v})" | |
| # Guardrail: the in-repo version must match the tag, so a release can never | |
| # ship out of sync. Bump with scripts/release.sh, which keeps them aligned. | |
| # Skipped for workflow_dispatch dry runs (the checked-out tree may differ). | |
| - name: Verify in-repo version matches tag | |
| if: github.event_name == 'push' | |
| run: | | |
| want="${{ steps.version.outputs.version }}" | |
| py=$(sed -n -E 's/^version = "(.*)"/\1/p' backend/pyproject.toml | head -1) | |
| mv=$(sed -n -E 's/.*MARKETING_VERSION: "(.*)"/\1/p' App/project.yml | head -1) | |
| iv=$(sed -n -E 's/^__version__ = "(.*)"/\1/p' backend/tinyforge/__init__.py | head -1) | |
| echo "tag=$want pyproject=$py project.yml=$mv __init__=$iv" | |
| rc=0 | |
| [ "$py" = "$want" ] || { echo "::error::backend/pyproject.toml version '$py' != tag '$want'"; rc=1; } | |
| [ "$mv" = "$want" ] || { echo "::error::App/project.yml MARKETING_VERSION '$mv' != tag '$want'"; rc=1; } | |
| [ "$iv" = "$want" ] || { echo "::error::backend/tinyforge/__init__.py __version__ '$iv' != tag '$want'"; rc=1; } | |
| if [ "$rc" -ne 0 ]; then | |
| echo "Use scripts/release.sh $want to bump + tag in sync, then retry." >&2 | |
| exit 1 | |
| fi | |
| - name: Select Xcode | |
| uses: maxim-lobanov/setup-xcode@v1 | |
| with: | |
| xcode-version: latest-stable | |
| - name: Install build tools (xcodegen, uv) | |
| run: | | |
| brew install xcodegen | |
| curl -LsSf https://astral.sh/uv/install.sh | sh | |
| echo "$HOME/.local/bin" >> "$GITHUB_PATH" | |
| - name: Download Metal Toolchain (MLX-Swift shaders) | |
| run: xcodebuild -downloadComponent MetalToolchain | |
| - name: Cache bundled Python runtime | |
| uses: actions/cache@v4 | |
| with: | |
| path: build/python-runtime | |
| key: pyruntime-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('backend/uv.lock', 'backend/pyproject.toml', 'scripts/bundle_python.sh') }} | |
| - name: Configure signing | |
| id: signing | |
| run: | | |
| if [ -n "$DEVELOPER_ID" ]; then | |
| echo "Developer ID present — will sign + notarize." | |
| echo "identity=$DEVELOPER_ID" >> "$GITHUB_OUTPUT" | |
| echo "signed=true" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "No Developer ID secret — building an ad-hoc (unsigned) DMG." | |
| echo "identity=-" >> "$GITHUB_OUTPUT" | |
| echo "signed=false" >> "$GITHUB_OUTPUT" | |
| fi | |
| # Tag pushes publish to the Sparkle update channel, so they must never | |
| # proceed unsigned: installed apps verify only the EdDSA signature, and | |
| # would auto-update onto an ad-hoc, un-notarized build. Unsigned builds | |
| # are for workflow_dispatch dry runs only. | |
| - name: Require signing for tag pushes | |
| if: github.event_name == 'push' && steps.signing.outputs.signed != 'true' | |
| run: | | |
| echo "::error::DEVELOPER_ID_APPLICATION secret is missing or empty — refusing to build a release from a tag push unsigned. Configure the signing secrets (docs/ci.md) or use workflow_dispatch for a dry run." | |
| exit 1 | |
| - name: Import Developer ID certificate | |
| if: steps.signing.outputs.signed == 'true' | |
| env: | |
| CERT_P12: ${{ secrets.DEVELOPER_ID_CERT_P12 }} | |
| CERT_PASSWORD: ${{ secrets.DEVELOPER_ID_CERT_PASSWORD }} | |
| KEYCHAIN_PASSWORD: tinyforge-ci-temp | |
| run: | | |
| CERT_PATH="$RUNNER_TEMP/cert.p12" | |
| KEYCHAIN_PATH="$RUNNER_TEMP/signing.keychain-db" | |
| echo -n "$CERT_P12" | base64 --decode -o "$CERT_PATH" | |
| security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" | |
| security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" | |
| security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" | |
| security import "$CERT_PATH" -P "$CERT_PASSWORD" -A -t cert -f pkcs12 -k "$KEYCHAIN_PATH" | |
| security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" | |
| security list-keychain -d user -s "$KEYCHAIN_PATH" | |
| rm -f "$CERT_PATH" | |
| - name: Build DMG | |
| env: | |
| MARKETING_VERSION: ${{ steps.version.outputs.version }} | |
| SIGN_IDENTITY: ${{ steps.signing.outputs.identity }} | |
| run: | | |
| scripts/build_release.sh | |
| DMG="build/TinyForge-${{ steps.version.outputs.tag }}.dmg" | |
| mv build/TinyForge.dmg "$DMG" | |
| echo "DMG=$DMG" >> "$GITHUB_ENV" | |
| - name: Notarize + staple | |
| if: steps.signing.outputs.signed == 'true' | |
| env: | |
| APPLE_ID: ${{ secrets.APPLE_ID }} | |
| APPLE_APP_PASSWORD: ${{ secrets.APPLE_APP_PASSWORD }} | |
| APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} | |
| run: | | |
| xcrun notarytool submit "$DMG" \ | |
| --apple-id "$APPLE_ID" --password "$APPLE_APP_PASSWORD" --team-id "$APPLE_TEAM_ID" \ | |
| --wait | |
| xcrun stapler staple "$DMG" | |
| xcrun stapler validate "$DMG" | |
| # EdDSA-sign the DMG and emit the Sparkle appcast. Must run AFTER | |
| # notarization: stapling mutates the DMG, and the signature covers the | |
| # final bytes. The appcast is published as a release asset; installed apps | |
| # fetch it via the stable releases/latest/download/appcast.xml redirect. | |
| - name: Generate Sparkle appcast | |
| if: github.event_name == 'push' && steps.signing.outputs.signed == 'true' | |
| env: | |
| SPARKLE_ED_PRIVATE_KEY: ${{ secrets.SPARKLE_ED_PRIVATE_KEY }} | |
| run: | | |
| test -n "$SPARKLE_ED_PRIVATE_KEY" || { echo "::error::SPARKLE_ED_PRIVATE_KEY secret missing — run Sparkle's generate_keys and 'gh secret set SPARKLE_ED_PRIVATE_KEY' (docs/ci.md)"; exit 1; } | |
| if grep -q "REPLACE_WITH_SPARKLE_PUBLIC_ED_KEY" App/Info.plist; then | |
| echo "::error::App/Info.plist still has the SUPublicEDKey placeholder — installed apps could never verify this update"; exit 1 | |
| fi | |
| SPARKLE_VERSION=2.9.3 | |
| curl -fsSL -o "$RUNNER_TEMP/Sparkle.tar.xz" \ | |
| "https://github.com/sparkle-project/Sparkle/releases/download/$SPARKLE_VERSION/Sparkle-$SPARKLE_VERSION.tar.xz" | |
| mkdir -p "$RUNNER_TEMP/sparkle-tools" | |
| tar -xJf "$RUNNER_TEMP/Sparkle.tar.xz" -C "$RUNNER_TEMP/sparkle-tools" | |
| mkdir -p build/appcast | |
| ln "$DMG" build/appcast/ # hardlink — no second ~1 GB copy | |
| KEYFILE="$RUNNER_TEMP/sparkle_ed_key" | |
| printf '%s' "$SPARKLE_ED_PRIVATE_KEY" > "$KEYFILE" | |
| "$RUNNER_TEMP/sparkle-tools/bin/generate_appcast" \ | |
| --ed-key-file "$KEYFILE" \ | |
| --download-url-prefix "https://github.com/skundu42/tinyforge/releases/download/${{ steps.version.outputs.tag }}/" \ | |
| --link "https://github.com/skundu42/tinyforge/releases" \ | |
| build/appcast | |
| rm -f "$KEYFILE" | |
| echo "==> generated appcast:" | |
| cat build/appcast/appcast.xml | |
| - name: Upload workflow artifact | |
| if: ${{ !cancelled() && env.DMG != '' }} | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: TinyForge-${{ steps.version.outputs.tag }}-dmg | |
| path: ${{ env.DMG }} | |
| if-no-files-found: error | |
| - name: Publish to GitHub Release | |
| if: github.event_name == 'push' && steps.signing.outputs.signed == 'true' | |
| uses: softprops/action-gh-release@v2 | |
| with: | |
| tag_name: ${{ steps.version.outputs.tag }} | |
| name: TinyForge ${{ steps.version.outputs.tag }} | |
| files: | | |
| ${{ env.DMG }} | |
| build/appcast/appcast.xml | |
| generate_release_notes: true | |
| append_body: true # keep hand-written release notes; append the DMG note + changelog | |
| fail_on_unmatched_files: true | |
| body: | | |
| macOS (Apple Silicon) build of TinyForge ${{ steps.version.outputs.tag }}. | |
| Installed copies update themselves automatically via Sparkle. Installs older than v0.3.0 predate the updater — install this DMG manually once and you're on the train. | |
| ${{ steps.signing.outputs.signed == 'true' && '✅ Signed with Developer ID and notarized.' || '⚠️ Unsigned (ad-hoc) build — right-click → Open, or sign locally. Configure signing secrets to ship a notarized DMG.' }} |