Skip to content

release v0.5.0

release v0.5.0 #10

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