release v0.5.0 #10
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 | |
| # Guardrail: the private key secret must match the SUPublicEDKey pinned in | |
| # App/Info.plist, or installed apps could never verify the appcast we ship. | |
| # Sparkle key files decode to 32 bytes (Sparkle 2 format: the Ed25519 seed; | |
| # the public key is derived per RFC 8032) or 96 bytes (legacy format: | |
| # 64-byte expanded private key followed by the 32-byte public key). | |
| python3 - <<'PY' | |
| import base64, hashlib, os, plistlib, sys | |
| def fail(msg): | |
| print(f"::error::{msg}") | |
| sys.exit(1) | |
| def ed25519_pub_from_seed(seed): | |
| # RFC 8032 Ed25519 public-key derivation, stdlib only. | |
| p = 2**255 - 19 | |
| d = (-121665 * pow(121666, p - 2, p)) % p | |
| def add(P, Q): | |
| x1, y1, z1, t1 = P; x2, y2, z2, t2 = Q | |
| A = ((y1 - x1) * (y2 - x2)) % p | |
| B = ((y1 + x1) * (y2 + x2)) % p | |
| C = (2 * t1 * t2 * d) % p | |
| D = (2 * z1 * z2) % p | |
| E, F, G, H = B - A, D - C, D + C, B + A | |
| return ((E * F) % p, (G * H) % p, (F * G) % p, (E * H) % p) | |
| g_y = (4 * pow(5, p - 2, p)) % p | |
| xx = (g_y * g_y - 1) * pow(d * g_y * g_y + 1, p - 2, p) % p | |
| x = pow(xx, (p + 3) // 8, p) | |
| if (x * x - xx) % p != 0: | |
| x = (x * pow(2, (p - 1) // 4, p)) % p | |
| if x % 2 != 0: | |
| x = p - x | |
| P = (x, g_y, 1, (x * g_y) % p) | |
| h = hashlib.sha512(seed).digest() | |
| a = int.from_bytes(h[:32], "little") | |
| a &= (1 << 254) - 8 | |
| a |= 1 << 254 | |
| Q = (0, 1, 1, 0) | |
| while a > 0: | |
| if a & 1: | |
| Q = add(Q, P) | |
| P = add(P, P) | |
| a >>= 1 | |
| zi = pow(Q[2], p - 2, p) | |
| return ((Q[1] * zi) % p | (((Q[0] * zi) % p & 1) << 255)).to_bytes(32, "little") | |
| try: | |
| key = base64.b64decode(os.environ["SPARKLE_ED_PRIVATE_KEY"].strip(), validate=True) | |
| except Exception: | |
| fail("SPARKLE_ED_PRIVATE_KEY is not valid base64") | |
| if len(key) == 32: | |
| pub = ed25519_pub_from_seed(key) | |
| elif len(key) == 96: | |
| pub = key[64:] | |
| else: | |
| fail(f"SPARKLE_ED_PRIVATE_KEY decodes to {len(key)} bytes; expected 32 (Sparkle 2 seed) or 96 (legacy key)") | |
| derived = base64.b64encode(pub).decode() | |
| with open("App/Info.plist", "rb") as f: | |
| pinned = plistlib.load(f)["SUPublicEDKey"].strip() | |
| if derived != pinned: | |
| fail(f"SPARKLE_ED_PRIVATE_KEY does not match SUPublicEDKey in App/Info.plist (secret derives public key {derived}, plist pins {pinned}); installed apps could not verify this appcast. Fix the secret or the plist before releasing.") | |
| print(f"Sparkle key check OK: secret matches SUPublicEDKey {pinned}") | |
| PY | |
| 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: | |
| # Dry runs (workflow_dispatch) build from whatever ref is checked out, so | |
| # their artifact is suffixed to avoid confusion with a real tag build. | |
| name: TinyForge-${{ steps.version.outputs.tag }}-dmg${{ github.event_name == 'workflow_dispatch' && '-dryrun' || '' }} | |
| 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.' }} |