Skip to content

release v0.4.0

release v0.4.0 #9

Workflow file for this run

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.' }}