8.1 KiB
Executable File
Hex Release Pipeline - GitHub Actions Plan
Context
Need to set up automated release pipeline for macOS app distribution via:
- Sparkle (in-app updates via S3)
- Homebrew Cask (requires ZIP artifact)
- GitHub Releases (visibility/download page)
Current State
What Works ✅
scripts/tool.py- Python script, works locallytools/release.ts- TypeScript/Effect version, works locally- Both use local notarytool keychain profile (
AC_PASSWORD) - Both upload to S3, generate Sparkle appcast
What's Broken ❌
.github/workflows/release.yml- Incomplete, has TODO stubs- No ZIP creation (Homebrew needs this)
- No GitHub release integration
Standard Approaches (2025)
Option A: Pure GitHub Actions YAML
How most projects do it:
- uses: apple-actions/import-codesign-certs@v2
- run: xcodebuild ...
- uses: lando/notarize-action@v2
- run: create-dmg ...
- uses: actions/create-release@v1
Pros: Standard, lots of examples, community actions Cons: Verbose YAML, hard to test locally, logic spread across steps
Option B: Call Local Script (What You Have)
Use existing release.ts from workflow:
- uses: oven-sh/setup-bun@v2
- run: bun run tools/release.ts --bucket hex-updates
Pros: Test locally, type-safe, reusable, Effect composability Cons: Need env var auth fallback for CI
Option C: Hybrid
Use actions for signing/notarization, script for build/upload
Recommended: Option B (Enhanced)
Why? You already built a complete, working TypeScript tool. Just need CI auth.
Architecture
GitHub Actions Workflow
├─ Setup (checkout, certs, Bun)
├─ Call: bun run tools/release.ts
│ ├─ Detects CI via env
│ ├─ Uses APPLE_ID/PASSWORD instead of keychain
│ ├─ Builds, signs, notarizes
│ ├─ Creates DMG + ZIP
│ ├─ Uploads to S3 (Sparkle)
│ └─ Returns artifacts paths
└─ Create GitHub Release (upload DMG + ZIP)
Required Changes
1. Modify release.ts
Add CI detection:
const getNotarizeArgs = () => {
if (process.env.GITHUB_ACTIONS) {
return [
"--apple-id", process.env.APPLE_ID!,
"--password", process.env.APPLE_ID_PASSWORD!,
"--team-id", process.env.TEAM_ID!
]
}
return ["--keychain-profile", "AC_PASSWORD"]
}
Add ZIP creation:
// After DMG creation
yield* runCommandCheck("ditto", "-c", "-k", "--keepParent",
appBundle, join(updatesDir, `Hex-${newVersion}.zip`))
2. Update Workflow
Replace .github/workflows/release.yml with:
name: Release
on:
workflow_dispatch:
inputs:
version_bump:
type: choice
options: [patch, minor, major]
default: patch
jobs:
release:
runs-on: macos-15
steps:
- uses: actions/checkout@v4
- name: Setup Xcode
run: sudo xcode-select -s /Applications/Xcode_16.2.app
- name: Setup Bun
uses: oven-sh/setup-bun@v2
- name: Install dependencies
run: cd tools && bun install
- name: Import signing certificate
env:
MACOS_CERTIFICATE: ${{ secrets.MACOS_CERTIFICATE }}
MACOS_CERTIFICATE_PWD: ${{ secrets.MACOS_CERTIFICATE_PWD }}
run: |
# Decode and import cert to default keychain
echo "$MACOS_CERTIFICATE" | base64 --decode > /tmp/cert.p12
security import /tmp/cert.p12 -P "$MACOS_CERTIFICATE_PWD" -A
security set-key-partition-list -S apple-tool:,apple: -s -k "" login.keychain-db
rm /tmp/cert.p12
- name: Build and release
run: bun run tools/release.ts --bucket hex-updates
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
TEAM_ID: ${{ secrets.TEAM_ID }}
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
- name: Create GitHub Release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
VERSION=$(cat Hex/Info.plist | grep -A1 CFBundleShortVersionString | tail -1 | sed 's/.*<string>\(.*\)<\/string>.*/\1/')
# release.ts already tags + creates the release with the aggregated changelog.
# Keep this step only if we ever skip that behavior in CI.
gh release create "v$VERSION" \
--title "Hex v$VERSION" \
--notes-file build/release-notes-$VERSION.md \
updates/Hex-$VERSION.dmg \
updates/Hex-$VERSION.zip
Required Secrets (9)
Set via: gh secret set SECRET_NAME
# Apple Developer
APPLE_ID # your@email.com
APPLE_ID_PASSWORD # xxxx-xxxx-xxxx-xxxx (app-specific)
TEAM_ID # QC99C9JE59
DEVELOPMENT_TEAM # QC99C9JE59 (same as TEAM_ID)
# Code Signing
MACOS_CERTIFICATE # base64 encoded .p12
MACOS_CERTIFICATE_PWD # .p12 password
# S3 / Sparkle
AWS_ACCESS_KEY_ID # S3 access
AWS_SECRET_ACCESS_KEY # S3 secret
# Auto-provided
GITHUB_TOKEN # Auto-available in actions
How to Generate
App-specific password:
# 1. Go to appleid.apple.com
# 2. Sign in > Security > App-Specific Passwords
# 3. Generate for "Hex GitHub Actions"
# 4. Copy xxxx-xxxx-xxxx-xxxx
Certificate base64:
# Export from Keychain Access as .p12
base64 -i DeveloperID.p12 | pbcopy
# Paste into secret
Comparison: What Others Do
Electron Apps (Sparkle, DMG)
- VSCode: Custom Azure Pipelines + scripts
- Obsidian: Electron-builder in Actions
- Raycast: Similar to our Option B (script from workflow)
Native macOS Apps
- Bartender: Manual releases
- Alfred: Private infrastructure
- Rectangle: GitHub Actions with notarize action
Trend: Moving toward script-based (easier to test/debug locally)
Homebrew Cask Setup
After first release:
# 1. Create cask
brew create --cask hex
# 2. Test
brew install --cask hex
# 3. Submit PR to homebrew/cask
# OR create personal tap: homebrew-hex
Cask needs:
- ZIP artifact (not DMG) - universal convention
- GitHub release URL
- SHA256 (auto-calculated by brew)
Testing Strategy
Local Testing
# Full release (dry-run flag needed?)
bun run tools/release.ts --bucket hex-updates-test
# Test specific steps
bun run tools/release.ts --skip-upload
CI Testing
# Trigger workflow
gh workflow run release.yml
# Monitor
gh run list
gh run view <run-id>
Migration Path
- ✅ Modify
release.ts- add CI auth - ✅ Add ZIP creation to
release.ts - ✅ Replace workflow YAML
- ✅ Set all 9 secrets
- ✅ Test release to staging S3 bucket
- ✅ First real release
- ✅ Submit Homebrew cask
✅ IMPLEMENTED (Tag-Based Approach)
Version Management
Solved: Git tags determine version
- Push
v0.2.12→ builds version0.2.12 - Auto-increments build number
- Updates all version files
Configuration
Solved: Effect Config system
- Auto-detects CI vs local environment
- Uses keychain locally, env vars in CI
- Type-safe configuration
Artifacts
Solved: Creates both DMG + ZIP
- DMG for direct download / Sparkle
- ZIP for Homebrew cask
- Both notarized and signed
Changelog Automation
Solved: Changesets CLI drives SemVer + release notes
- Contributors run
bunx changesetto capture a patch/minor/major summary - Release tool enforces pending changesets, runs
changeset version, and copies the aggregatedCHANGELOG.mdintoHex/Resources/changelog.md - GitHub releases reuse the generated changelog section via
--notes-file, so Sparkle, the in-app sheet, and GitHub stay identical
Remaining Questions
- S3 bucket: Staging bucket for pre-release testing?
- Rollback: Failed notarization handling?
- Certificate expiry: Track and rotate Developer ID cert
Completed Tasks
- Update
release.tswith Effect Config - Add CI detection (Apple creds optional)
- Add ZIP creation for Homebrew
- Rewrite workflow for tag-based releases
- Document release process
- Configure secrets (manual step)
- First release test
- Submit Homebrew cask