Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
156 changes: 156 additions & 0 deletions .github/workflows/macos-release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
name: macOS App Release

# Triggered by pushing a `macos-v*` tag (e.g. macos-v0.3.0). Builds, signs,
# notarizes and publishes Ulk.app as a DMG, attached to a GitHub Release.
# The matching appcast.xml entry is generated and uploaded as a release asset
# (Sparkle reads from https://github.com/izo/Ulk/releases/latest/download/appcast.xml
# OR from https://ulk.regrets.app/appcast.xml — see Info.plist SUFeedURL).
#
# Required GitHub repository secrets:
# APPLE_TEAM_ID 10-char team ID (e.g. ABCD123456)
# APPLE_SIGNING_IDENTITY Exact name from `security find-identity`
# (e.g. "Developer ID Application: Mathieu Drouet (ABCD123456)")
# APPLE_CERTIFICATE_BASE64 base64 of the .p12 signing cert export
# APPLE_CERTIFICATE_PASSWORD password for the .p12
# APPLE_NOTARY_APPLE_ID Apple ID email for notarytool
# APPLE_NOTARY_APP_SPECIFIC_PASSWORD app-specific password (appleid.apple.com)
# SPARKLE_PRIVATE_KEY base64 of the EdDSA private key file
#
# To cut a release locally:
# git tag macos-v0.3.0 && git push origin macos-v0.3.0

on:
push:
tags:
- 'macos-v*'
workflow_dispatch:
inputs:
version:
description: 'Version to release (without leading v, e.g. 0.3.0)'
required: true

permissions:
contents: write # publish releases

concurrency:
group: macos-release-${{ github.ref }}
cancel-in-progress: false

jobs:
release:
name: Build, sign, notarize, publish (macos-14)
runs-on: macos-14
timeout-minutes: 45
defaults:
run:
working-directory: apps/macos/Ulk
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0

- name: Resolve version
id: version
run: |
if [ -n "${{ inputs.version }}" ]; then
VERSION="${{ inputs.version }}"
else
VERSION="${GITHUB_REF_NAME#macos-v}"
fi
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "tag=macos-v$VERSION" >> "$GITHUB_OUTPUT"
echo "Releasing version=$VERSION"

- name: Show toolchain
run: |
sw_vers
xcodebuild -version
swift --version

- name: Install build tools
run: |
brew install xcodegen create-dmg
brew install --cask xcpretty || true

- name: Import signing certificate
env:
CERT_BASE64: ${{ secrets.APPLE_CERTIFICATE_BASE64 }}
CERT_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
KEYCHAIN_PASSWORD: ulk-temporary-keychain-pwd
run: |
[ -n "$CERT_BASE64" ] || { echo "::error::APPLE_CERTIFICATE_BASE64 secret missing"; exit 1; }
CERT_PATH="$RUNNER_TEMP/cert.p12"
KC_PATH="$RUNNER_TEMP/build.keychain-db"
echo "$CERT_BASE64" | base64 --decode > "$CERT_PATH"
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KC_PATH"
security set-keychain-settings -lut 21600 "$KC_PATH"
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KC_PATH"
security import "$CERT_PATH" -P "$CERT_PASSWORD" -A -t cert -f pkcs12 -k "$KC_PATH"
security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PASSWORD" "$KC_PATH"
security list-keychains -d user -s "$KC_PATH" $(security list-keychains -d user | tr -d '"')
security find-identity -v -p codesigning "$KC_PATH"

- name: Store notary credentials
env:
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
APPLE_NOTARY_APPLE_ID: ${{ secrets.APPLE_NOTARY_APPLE_ID }}
APPLE_NOTARY_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_NOTARY_APP_SPECIFIC_PASSWORD }}
run: |
xcrun notarytool store-credentials "ulk-notarize" \
--apple-id "$APPLE_NOTARY_APPLE_ID" \
--team-id "$APPLE_TEAM_ID" \
--password "$APPLE_NOTARY_APP_SPECIFIC_PASSWORD"

- name: Write Sparkle private key
env:
SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }}
run: |
[ -n "$SPARKLE_PRIVATE_KEY" ] || { echo "::error::SPARKLE_PRIVATE_KEY secret missing"; exit 1; }
KEY_PATH="$RUNNER_TEMP/sparkle_priv.pem"
echo "$SPARKLE_PRIVATE_KEY" | base64 --decode > "$KEY_PATH"
chmod 600 "$KEY_PATH"
echo "SPARKLE_PRIVATE_KEY_FILE=$KEY_PATH" >> "$GITHUB_ENV"

- name: Run sign + notarize pipeline
env:
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
APPLE_NOTARY_PROFILE: ulk-notarize
run: ./scripts/sign-and-notarize.sh ${{ steps.version.outputs.version }}

- name: Generate appcast entry
run: |
./scripts/generate-appcast.sh \
${{ steps.version.outputs.version }} \
build/Ulk-${{ steps.version.outputs.version }}.dmg
# Snapshot the current appcast for upload as a release asset.
cp ../../../site/public/appcast.xml build/appcast.xml

- name: Publish GitHub release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.version.outputs.tag }}
name: Ulk.app ${{ steps.version.outputs.version }}
generate_release_notes: true
files: |
apps/macos/Ulk/build/Ulk-${{ steps.version.outputs.version }}.dmg
apps/macos/Ulk/build/Ulk-${{ steps.version.outputs.version }}.dmg.eddsa
apps/macos/Ulk/build/appcast.xml
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

- name: Open PR with updated appcast.xml (so site/ deploys it)
uses: peter-evans/create-pull-request@v7
with:
token: ${{ secrets.GITHUB_TOKEN }}
branch: chore/appcast-${{ steps.version.outputs.version }}
base: main
title: "chore(site): publish appcast for Ulk.app ${{ steps.version.outputs.version }}"
body: |
Automated PR generated by `.github/workflows/macos-release.yml`
for tag `${{ steps.version.outputs.tag }}`.

Adds the new Sparkle <item> to `site/public/appcast.xml`.
Merging deploys the updated appcast to https://ulk.regrets.app/appcast.xml.
commit-message: "chore(site): publish appcast for Ulk.app ${{ steps.version.outputs.version }}"
add-paths: site/public/appcast.xml
17 changes: 12 additions & 5 deletions apps/macos/Ulk/Package.swift
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
// swift-tools-version: 5.10
// Build the Ulk app sources as a Swift Package — this lets us syntax-check
// the codebase from CI (and from non-macOS dev machines, with limits)
// without committing a full Xcode project. The real Xcode project will be
// generated/maintained side-by-side starting v0.2.
// Build the Ulk app sources as a Swift Package — lets CI syntax-check
// Models / Services / Scenes on every push without owning the full Xcode
// project. Real .xcodeproj is generated by XcodeGen (see project.yml).
import PackageDescription

let package = Package(
Expand All @@ -11,16 +10,24 @@ let package = Package(
products: [
.library(name: "UlkCore", targets: ["UlkCore"]),
],
dependencies: [
// Sparkle 2.x — used by Services/UpdateChecker.swift.
// Same version as project.yml `packages.Sparkle.from`.
.package(url: "https://github.com/sparkle-project/Sparkle", from: "2.6.0"),
],
targets: [
.target(
name: "UlkCore",
dependencies: [
.product(name: "Sparkle", package: "Sparkle"),
],
path: "Ulk",
exclude: [
"Resources/Info.plist",
"Resources/Ulk.entitlements",
"Resources/Assets.xcassets",
// UlkApp.swift carries @main + SwiftUI App — only compiled by the
// Xcode app target (v0.2). SPM library skips it to keep CI clean.
// Xcode app target. SPM library skips it to keep CI clean.
"UlkApp.swift",
],
sources: ["Models", "Services", "Scenes"]
Expand Down
81 changes: 69 additions & 12 deletions apps/macos/Ulk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ Wrappe le CLI Go (`framework/cli/`) via `Process`. Auto-update via Sparkle.

## Statut

**v0.2projet Xcode + parsing JSON typé + premier toggle fonctionnel.**
Pas encore signé/notarié (cible v0.3).
**v0.3Sparkle 2.x intégré + pipeline de release CI signé+notarié.**
Première release publique possible dès que les 7 secrets GitHub sont en place.

## Structure

Expand Down Expand Up @@ -94,20 +94,77 @@ Au lancement, `UlkBridge` cherche `bin/ulk` dans cet ordre :

Si aucun trouvé : Installer démarre en mode **bootstrap** (clone repo + premier build Go).

## Sign + notarize (release v0.3)
## Auto-update (Sparkle 2.x)

L'app embarque [Sparkle 2.6+](https://sparkle-project.org) (SPM dep dans `project.yml`).
`UpdateChecker` est une façade SwiftUI au-dessus de `SPUStandardUpdaterController` —
le badge dans la sidebar montre l'état (idle / checking / update available /
downloading / ready to install / failed).

L'appcast vit à **https://ulk.regrets.app/appcast.xml** (déployé depuis `site/public/appcast.xml`).
Le workflow CI ouvre une PR automatique pour le mettre à jour à chaque release.

## Release pipeline

Tag `macos-v*` (ex `macos-v0.3.0`) → `.github/workflows/macos-release.yml` fait tout :
build Release → sign → DMG → notarize → staple → sign Sparkle → publish GitHub
release → ouvre une PR avec le nouvel appcast pour le site.

### Secrets GitHub à configurer une fois (Settings → Secrets and variables → Actions)

| Secret | Comment l'obtenir |
|--------|-------------------|
| `APPLE_TEAM_ID` | Apple Developer Portal → Membership (10 chars, ex `ABCD123456`) |
| `APPLE_SIGNING_IDENTITY` | `security find-identity -v -p codesigning` → copier la ligne complète : `"Developer ID Application: Prénom Nom (TeamID)"` |
| `APPLE_CERTIFICATE_BASE64` | Exporter le certif Developer ID (Keychain Access → clic-droit → Export en `.p12`), puis : `base64 -i cert.p12 \| pbcopy` |
| `APPLE_CERTIFICATE_PASSWORD` | Mot de passe choisi à l'export du `.p12` |
| `APPLE_NOTARY_APPLE_ID` | Email Apple ID utilisé pour le compte développeur |
| `APPLE_NOTARY_APP_SPECIFIC_PASSWORD` | Créer sur https://appleid.apple.com → "App-Specific Passwords" |
| `SPARKLE_PRIVATE_KEY` | Générer une fois (voir ci-dessous), puis : `base64 -i ed25519_priv.pem \| pbcopy` |

### Générer la paire EdDSA Sparkle (une fois)

Sparkle inclut un outil `generate_keys` (téléchargeable depuis sparkle-project.org) :

```bash
# Une fois le SPM résolu, l'outil est dans DerivedData :
SPARKLE_BIN=$(find ~/Library/Developer/Xcode/DerivedData -name generate_keys -type f | head -1)
"$SPARKLE_BIN"
# Output : "A Sparkle ed25519 private key was generated and stored in your keychain.
# Public key (paste this into your Info.plist SUPublicEDKey):
# Y2hpY2tlbi1raWV2LWltLW5vdC1yZWFsLW9idnk=..."
```

1. Copier la clé publique dans `apps/macos/Ulk/Ulk/Resources/Info.plist` à la place
du placeholder `REPLACE_WITH_REAL_PUBLIC_KEY_BEFORE_RELEASE`
2. Exporter la clé privée du keychain (Keychain Access → "Sparkle Private Key" →
File → Export Items → `.pem`)
3. Sauver la clé privée dans 1Password ET dans le secret GitHub `SPARKLE_PRIVATE_KEY`
(jamais commitée — `ed25519_priv.pem` est dans `.gitignore`)

### Couper une release

```bash
# 1. signer
codesign --deep --force --options runtime \
--sign "Developer ID Application: <Team>" \
--entitlements Ulk/Resources/Ulk.entitlements \
build/Release/Ulk.app
git checkout main && git pull
git tag macos-v0.3.0
git push origin macos-v0.3.0
# Le workflow démarre automatiquement (~10 minutes pour la notarisation).
```

# 2. notariser
xcrun notarytool submit Ulk.dmg --keychain-profile "AC_PASSWORD" --wait
xcrun stapler staple Ulk.dmg
### Release locale (sans CI)

# 3. publier (CI : .github/workflows/macos-release.yml — TODO v0.3)
Pour cas exceptionnel (CI down, debug release pipeline) :

```bash
export APPLE_TEAM_ID="ABCD123456"
export APPLE_SIGNING_IDENTITY="Developer ID Application: Prénom Nom (ABCD123456)"
export APPLE_NOTARY_PROFILE="ulk-notarize" # créé une fois via notarytool store-credentials
export SPARKLE_PRIVATE_KEY_FILE="$HOME/.config/ulk/sparkle_priv.pem"

cd apps/macos/Ulk
./scripts/sign-and-notarize.sh 0.3.0
./scripts/generate-appcast.sh 0.3.0 build/Ulk-0.3.0.dmg
# Upload DMG manuellement, commit appcast.xml
```

## Roadmap
Expand Down
16 changes: 14 additions & 2 deletions apps/macos/Ulk/Ulk/Scenes/Dashboard/DashboardShell.swift
Original file line number Diff line number Diff line change
Expand Up @@ -73,11 +73,23 @@ struct DashboardShell: View {
@ViewBuilder
private var updateBadge: some View {
switch updateChecker.status {
case .updateAvailable(_, let latest, let url):
Link(destination: url) {
case .updateAvailable(_, let latest):
Button {
Task { await updateChecker.checkNow() }
} label: {
Label("Update available: \(latest)", systemImage: "arrow.down.circle.fill")
.foregroundStyle(.orange)
}
.buttonStyle(.borderless)
case .downloading(let progress):
HStack {
ProgressView(value: progress).controlSize(.small).frame(width: 80)
Text("Downloading \(Int(progress * 100))%").font(.caption)
}
.foregroundStyle(.secondary)
case .readyToInstall(let v):
Label("Restart to install \(v)", systemImage: "arrow.triangle.2.circlepath")
.foregroundStyle(.green)
case .upToDate(let v):
Label("Up to date (\(v))", systemImage: "checkmark.seal")
.foregroundStyle(.secondary)
Expand Down
Loading
Loading