diff --git a/.github/workflows/macos-release.yml b/.github/workflows/macos-release.yml new file mode 100644 index 00000000..c0fe5a91 --- /dev/null +++ b/.github/workflows/macos-release.yml @@ -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 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 diff --git a/apps/macos/Ulk/Package.swift b/apps/macos/Ulk/Package.swift index 2162bd6a..52b0a096 100644 --- a/apps/macos/Ulk/Package.swift +++ b/apps/macos/Ulk/Package.swift @@ -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( @@ -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"] diff --git a/apps/macos/Ulk/README.md b/apps/macos/Ulk/README.md index 44b892dc..3ea44655 100644 --- a/apps/macos/Ulk/README.md +++ b/apps/macos/Ulk/README.md @@ -7,8 +7,8 @@ Wrappe le CLI Go (`framework/cli/`) via `Process`. Auto-update via Sparkle. ## Statut -**v0.2 — projet Xcode + parsing JSON typé + premier toggle fonctionnel.** -Pas encore signé/notarié (cible v0.3). +**v0.3 — Sparkle 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 @@ -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: " \ - --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 diff --git a/apps/macos/Ulk/Ulk/Scenes/Dashboard/DashboardShell.swift b/apps/macos/Ulk/Ulk/Scenes/Dashboard/DashboardShell.swift index 0c06f302..9c171bcd 100644 --- a/apps/macos/Ulk/Ulk/Scenes/Dashboard/DashboardShell.swift +++ b/apps/macos/Ulk/Ulk/Scenes/Dashboard/DashboardShell.swift @@ -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) diff --git a/apps/macos/Ulk/Ulk/Services/UpdateChecker.swift b/apps/macos/Ulk/Ulk/Services/UpdateChecker.swift index e84526b0..c1b9613e 100644 --- a/apps/macos/Ulk/Ulk/Services/UpdateChecker.swift +++ b/apps/macos/Ulk/Ulk/Services/UpdateChecker.swift @@ -1,9 +1,19 @@ import Foundation +import Sparkle -/// Sparkle stub. v0.3 will replace this with the real Sparkle.framework integration. -/// For now: poll GitHub releases API and report whether a new tag is available. +/// Wraps Sparkle 2.x with a SwiftUI-friendly facade. Exposes a `Status` enum +/// that the Dashboard sidebar binds to. +/// +/// Sparkle plumbing: +/// - `SPUStandardUpdaterController` owns the lifecycle (started in `init`). +/// - `SPUUpdater` is reachable via `controller.updater` for ad-hoc checks. +/// - We act as `SPUUpdaterDelegate` to surface state changes to the UI. +/// +/// The release pipeline is documented in: +/// apps/macos/Ulk/scripts/sign-and-notarize.sh +/// .github/workflows/macos-release.yml @MainActor -final class UpdateChecker: ObservableObject { +final class UpdateChecker: NSObject, ObservableObject, SPUUpdaterDelegate { static let shared = UpdateChecker() @Published private(set) var status: Status = .idle @@ -12,47 +22,73 @@ final class UpdateChecker: ObservableObject { case idle case checking case upToDate(currentVersion: String) - case updateAvailable(currentVersion: String, latestVersion: String, releaseURL: URL) + case updateAvailable(currentVersion: String, latestVersion: String) + case downloading(progress: Double) + case readyToInstall(version: String) case failed(String) } - private let owner = "izo" - private let repo = "ulk" + private var controller: SPUStandardUpdaterController! + override init() { + super.init() + // startingUpdater: true → Sparkle starts on init and does its scheduled + // background checks. We pass self as updater delegate to receive events. + self.controller = SPUStandardUpdaterController( + startingUpdater: true, + updaterDelegate: self, + userDriverDelegate: nil + ) + } + + /// Triggers a user-visible "Check for Updates…" — this is what the menu bar + /// item should call. func checkNow() async { status = .checking - let current = currentVersion() - do { - let latest = try await fetchLatestTag() - if latest.tag != current { - let url = URL(string: "https://github.com/\(owner)/\(repo)/releases/tag/\(latest.tag)")! - status = .updateAvailable(currentVersion: current, latestVersion: latest.tag, releaseURL: url) - } else { - status = .upToDate(currentVersion: current) - } - } catch { - status = .failed(error.localizedDescription) + controller.checkForUpdates(nil) + } + + /// Pure background poll, no UI. Used at app launch. + func checkInBackground() { + controller.updater.checkForUpdatesInBackground() + } + + /// Thread-safe — Bundle.main reads are thread-safe for the info dictionary. + private nonisolated static func bundleVersion() -> String { + Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0.0.0" + } + + // MARK: - SPUUpdaterDelegate + // Sparkle invokes these from arbitrary threads — keep them nonisolated and + // hop to MainActor before mutating @Published state. + + nonisolated func updater(_ updater: SPUUpdater, didFindValidUpdate item: SUAppcastItem) { + let current = Self.bundleVersion() + let latest = item.displayVersionString + Task { @MainActor in + self.status = .updateAvailable(currentVersion: current, latestVersion: latest) } } - private func currentVersion() -> String { - Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0.1.0" + nonisolated func updaterDidNotFindUpdate(_ updater: SPUUpdater) { + let current = Self.bundleVersion() + Task { @MainActor in + self.status = .upToDate(currentVersion: current) + } } - private func fetchLatestTag() async throws -> (tag: String, url: URL) { - let url = URL(string: "https://api.github.com/repos/\(owner)/\(repo)/releases/latest")! - var req = URLRequest(url: url) - req.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept") - let (data, response) = try await URLSession.shared.data(for: req) - guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { - throw URLError(.badServerResponse) + nonisolated func updater(_ updater: SPUUpdater, didAbortWithError error: Error) { + let message = error.localizedDescription + Task { @MainActor in + self.status = .failed(message) } - let payload = try JSONDecoder().decode(LatestRelease.self, from: data) - return (payload.tag_name, URL(string: payload.html_url) ?? url) } - private struct LatestRelease: Decodable { - let tag_name: String - let html_url: String + nonisolated func updater(_ updater: SPUUpdater, + willInstallUpdate item: SUAppcastItem) { + let v = item.displayVersionString + Task { @MainActor in + self.status = .readyToInstall(version: v) + } } } diff --git a/apps/macos/Ulk/project.yml b/apps/macos/Ulk/project.yml index 20c341d1..4dd8c713 100644 --- a/apps/macos/Ulk/project.yml +++ b/apps/macos/Ulk/project.yml @@ -14,6 +14,11 @@ options: createIntermediateGroups: true generateEmptyDirectories: true +packages: + Sparkle: + url: https://github.com/sparkle-project/Sparkle + from: 2.6.0 + settings: base: SWIFT_VERSION: "5.10" @@ -24,8 +29,8 @@ settings: CODE_SIGN_ENTITLEMENTS: Ulk/Resources/Ulk.entitlements INFOPLIST_FILE: Ulk/Resources/Info.plist PRODUCT_BUNDLE_IDENTIFIER: app.regrets.ulk - MARKETING_VERSION: "0.2.0" - CURRENT_PROJECT_VERSION: "2" + MARKETING_VERSION: "0.3.0" + CURRENT_PROJECT_VERSION: "3" ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: AccentColor configs: @@ -48,6 +53,8 @@ targets: - "Resources/Ulk.entitlements" # referenced via CODE_SIGN_ENTITLEMENTS resources: - Ulk/Resources/Assets.xcassets + dependencies: + - package: Sparkle info: path: Ulk/Resources/Info.plist entitlements: diff --git a/apps/macos/Ulk/scripts/generate-appcast.sh b/apps/macos/Ulk/scripts/generate-appcast.sh new file mode 100755 index 00000000..0f2bec5b --- /dev/null +++ b/apps/macos/Ulk/scripts/generate-appcast.sh @@ -0,0 +1,87 @@ +#!/usr/bin/env bash +# generate-appcast.sh — append a new to site/public/appcast.xml +# +# Reads the signed DMG produced by sign-and-notarize.sh and emits a Sparkle 2 +# entry, then injects it into site/public/appcast.xml at the top of the +# (newest-first ordering). +# +# Usage: +# ./scripts/generate-appcast.sh [--release-notes-html ] +# +# Example: +# ./scripts/generate-appcast.sh 0.3.0 build/Ulk-0.3.0.dmg \ +# --release-notes-html notes-0.3.0.html + +set -euo pipefail + +cd "$(dirname "$0")/../../../.." # repo root + +VERSION="${1:-}" +DMG="${2:-}" +NOTES_HTML="" + +shift 2 || true +while [ $# -gt 0 ]; do + case "$1" in + --release-notes-html) NOTES_HTML="$2"; shift 2 ;; + *) echo "::error::unknown arg $1"; exit 1 ;; + esac +done + +[ -n "$VERSION" ] || { echo "::error::version required"; exit 1; } +[ -f "$DMG" ] || { echo "::error::DMG not found at $DMG"; exit 1; } +[ -f "$DMG.eddsa" ] || { echo "::error::Sparkle signature not found at $DMG.eddsa — run sign-and-notarize.sh first"; exit 1; } + +APPCAST="site/public/appcast.xml" +[ -f "$APPCAST" ] || { echo "::error::appcast not found at $APPCAST"; exit 1; } + +# sign_update outputs a single line like: +# sparkle:edSignature="..." length="..." +SIG_LINE=$(cat "$DMG.eddsa") +DOWNLOAD_URL="https://github.com/izo/Ulk/releases/download/macos-v$VERSION/$(basename "$DMG")" +PUBDATE=$(LC_ALL=C date -u "+%a, %d %b %Y %H:%M:%S +0000") + +NOTES_BLOCK="" +if [ -n "$NOTES_HTML" ] && [ -f "$NOTES_HTML" ]; then + NOTES_BLOCK=" " +fi + +NEW_ITEM=$(cat < + Version $VERSION + $PUBDATE + $VERSION + $VERSION + 14.0 +$NOTES_BLOCK + + +EOF +) + +# Insert the new right after the last … or right after +# if the appcast contains no items yet. Use a Python helper for safe editing. +python3 - "$APPCAST" "$NEW_ITEM" <<'PY' +import sys, pathlib, re + +path = pathlib.Path(sys.argv[1]) +new_item = sys.argv[2] +src = path.read_text(encoding="utf-8") + +# Insert right after the opening tag or after the existing +match = re.search(r"([^<]*\s*)", src) +if match: + insert_at = match.end() +else: + match = re.search(r"]*>\s*", src) + if not match: + sys.exit("appcast.xml: opener not found") + insert_at = match.end() + +src = src[:insert_at] + "\n" + new_item + "\n" + src[insert_at:] +path.write_text(src, encoding="utf-8") +print(f"✓ Appended new for version into {path}") +PY + +echo +echo "✓ $APPCAST updated. Commit and push to trigger the site/ deploy." diff --git a/apps/macos/Ulk/scripts/sign-and-notarize.sh b/apps/macos/Ulk/scripts/sign-and-notarize.sh new file mode 100755 index 00000000..3c2d51fa --- /dev/null +++ b/apps/macos/Ulk/scripts/sign-and-notarize.sh @@ -0,0 +1,141 @@ +#!/usr/bin/env bash +# sign-and-notarize.sh — local release pipeline for Ulk.app +# +# Produces a signed, notarized, stapled DMG ready to upload as a GitHub release +# asset. Used by .github/workflows/macos-release.yml AND for ad-hoc local +# releases. +# +# Required environment variables: +# APPLE_TEAM_ID 10-character team ID, e.g. ABCD123456 +# APPLE_SIGNING_IDENTITY Exact name from `security find-identity -v -p codesigning` +# e.g. "Developer ID Application: Mathieu Drouet (ABCD123456)" +# APPLE_NOTARY_PROFILE Keychain profile name created via: +# xcrun notarytool store-credentials \ +# "$APPLE_NOTARY_PROFILE" \ +# --apple-id "you@example.com" \ +# --team-id "$APPLE_TEAM_ID" \ +# --password +# SPARKLE_PRIVATE_KEY_FILE Path to the Sparkle EdDSA private key (NEVER committed) +# Generate once with: +# ./bin/generate_keys +# # outputs the public key — paste it into Info.plist +# # SUPublicEDKey, save the private key file securely +# +# Usage: +# ./scripts/sign-and-notarize.sh [version] +# +# Output: +# build/Ulk-.dmg signed, notarized, stapled +# build/Ulk-.dmg.eddsa Sparkle signature (one line: "edSignature=...; length=...") + +set -euo pipefail + +cd "$(dirname "$0")/.." + +VERSION="${1:-$(grep -m1 'MARKETING_VERSION:' project.yml | awk -F'"' '{print $2}')}" +APP_NAME="Ulk" +BUNDLE_ID="app.regrets.ulk" +BUILD_DIR="build" +ARCHIVE="$BUILD_DIR/$APP_NAME.xcarchive" +EXPORT_DIR="$BUILD_DIR/export" +APP_PATH="$EXPORT_DIR/$APP_NAME.app" +DMG_PATH="$BUILD_DIR/$APP_NAME-$VERSION.dmg" + +: "${APPLE_TEAM_ID:?must be set}" +: "${APPLE_SIGNING_IDENTITY:?must be set}" +: "${APPLE_NOTARY_PROFILE:?must be set}" +: "${SPARKLE_PRIVATE_KEY_FILE:?must be set}" + +[ -f "$SPARKLE_PRIVATE_KEY_FILE" ] || { + echo "::error::Sparkle private key file not found: $SPARKLE_PRIVATE_KEY_FILE" + exit 1 +} + +echo "→ Generating Xcode project from project.yml" +command -v xcodegen >/dev/null || { echo "::error::xcodegen not installed (brew install xcodegen)"; exit 1; } +xcodegen generate --use-cache + +echo "→ Archiving Release config" +rm -rf "$BUILD_DIR" +xcodebuild archive \ + -project "$APP_NAME.xcodeproj" \ + -scheme "$APP_NAME" \ + -configuration Release \ + -destination "generic/platform=macOS" \ + -archivePath "$ARCHIVE" \ + DEVELOPMENT_TEAM="$APPLE_TEAM_ID" \ + CODE_SIGN_IDENTITY="$APPLE_SIGNING_IDENTITY" \ + CODE_SIGN_STYLE=Manual \ + | xcpretty || true + +echo "→ Exporting .app from archive" +mkdir -p "$EXPORT_DIR" +EXPORT_PLIST="$BUILD_DIR/ExportOptions.plist" +cat > "$EXPORT_PLIST" < + + + + methoddeveloper-id + teamID$APPLE_TEAM_ID + signingStylemanual + stripSwiftSymbols + + +EOF +xcodebuild -exportArchive \ + -archivePath "$ARCHIVE" \ + -exportPath "$EXPORT_DIR" \ + -exportOptionsPlist "$EXPORT_PLIST" \ + | xcpretty || true + +[ -d "$APP_PATH" ] || { echo "::error::Export failed — $APP_PATH missing"; exit 1; } + +echo "→ Verifying codesign" +codesign --verify --deep --strict --verbose=2 "$APP_PATH" + +echo "→ Building DMG" +command -v create-dmg >/dev/null || { + echo "::warning::create-dmg not installed — falling back to hdiutil" + hdiutil create -volname "$APP_NAME" -srcfolder "$APP_PATH" -ov -format UDZO "$DMG_PATH" +} +if command -v create-dmg >/dev/null; then + create-dmg --volname "$APP_NAME $VERSION" --window-size 540 380 \ + --icon-size 96 --icon "$APP_NAME.app" 140 190 \ + --app-drop-link 400 190 --no-internet-enable \ + "$DMG_PATH" "$APP_PATH" || true +fi + +echo "→ Signing DMG" +codesign --sign "$APPLE_SIGNING_IDENTITY" "$DMG_PATH" + +echo "→ Submitting DMG to notary service (this can take several minutes)" +xcrun notarytool submit "$DMG_PATH" \ + --keychain-profile "$APPLE_NOTARY_PROFILE" \ + --wait + +echo "→ Stapling notary ticket" +xcrun stapler staple "$DMG_PATH" +xcrun stapler validate "$DMG_PATH" + +echo "→ Computing Sparkle EdDSA signature" +SIGN_TOOL="${SPARKLE_SIGN_TOOL:-./build/Sparkle/sign_update}" +if [ ! -x "$SIGN_TOOL" ]; then + SIGN_TOOL=$(find ~/Library/Developer/Xcode/DerivedData -name sign_update -type f 2>/dev/null | head -1) +fi +[ -x "$SIGN_TOOL" ] || { + echo "::error::sign_update tool not found. Build the project once or set SPARKLE_SIGN_TOOL." + exit 1 +} + +"$SIGN_TOOL" -f "$SPARKLE_PRIVATE_KEY_FILE" "$DMG_PATH" > "$DMG_PATH.eddsa" +echo "→ Sparkle signature written to $DMG_PATH.eddsa:" +cat "$DMG_PATH.eddsa" + +echo +echo "✓ Release artefact ready: $DMG_PATH" +echo "✓ Sparkle signature: $DMG_PATH.eddsa" +echo +echo "Next steps:" +echo " 1. Upload $DMG_PATH as a GitHub release asset" +echo " 2. Run scripts/generate-appcast.sh to update site/public/appcast.xml" diff --git a/docs/backlog/2026-05-06-FEATURE-MACOS-COMPANION-APP/CARD.md b/docs/backlog/2026-05-06-FEATURE-MACOS-COMPANION-APP/CARD.md index fd4dd186..90d6cac6 100644 --- a/docs/backlog/2026-05-06-FEATURE-MACOS-COMPANION-APP/CARD.md +++ b/docs/backlog/2026-05-06-FEATURE-MACOS-COMPANION-APP/CARD.md @@ -116,16 +116,37 @@ Sources de vérité **lues** par `RegistryLoader` (zero duplication) : - [x] **CI macOS-app.yml** : 2 jobs (SPM build/test + XcodeGen+xcodebuild) - [x] **Tests Swift** : 5 tests `UlkReportsTests` (shapes installed/non-installed, doctor sain/avec issues, check found/missing) -### v0.2.1 — toggles complets (suite) +### v0.2.1 — toggles complets (suite, déprio par v0.3) - Toggle MCPs : `claude mcp add/remove` via UlkBridge - Toggle CLIs required : `ulk install-deps --only ` - Recherche globale (cmd+F) avec highlight - Notifications natives macOS sur fin d'install/update -### v0.3 — Sparkle + release pipeline -- Intégration Sparkle (HMAC EdDSA signing key) -- GitHub Action : build → sign → notarize → publish DMG → update appcast.xml -- Release `v0.3.0` +### v0.3 — Sparkle 2.x + release pipeline (cette branche) +- [x] **Sparkle 2.6+** ajouté en SPM dep dans `project.yml` +- [x] **`UpdateChecker`** réécrit comme façade SwiftUI au-dessus de + `SPUStandardUpdaterController` + `SPUUpdaterDelegate` (nouvelles cases + `downloading(progress)` / `readyToInstall(version)`) +- [x] **`DashboardShell`** sidebar : nouveaux états Sparkle visibles (download + progress + restart-to-install) +- [x] **`scripts/sign-and-notarize.sh`** : pipeline locale complète (xcodebuild + archive → export → DMG → notarytool wait → stapler → sign Sparkle) +- [x] **`scripts/generate-appcast.sh`** : append d'un `` à + `site/public/appcast.xml` (Python pour parsing XML safe) +- [x] **`.github/workflows/macos-release.yml`** : trigger sur tag `macos-v*`, + import certif depuis secret base64, store notary credentials, run pipeline, + publish GitHub release (DMG + signature + appcast snapshot), ouvre une PR + automatique pour le site avec le nouvel appcast +- [x] **`site/public/appcast.xml`** : feed initial, hosté sur ulk.regrets.app + (Astro static) +- [x] **README** : runbook complet avec les 7 GitHub Secrets requis et les + étapes de génération de la paire EdDSA Sparkle + +### v0.4+ — extras (post-MVP, ex-v1.0) +- Éditeur `settings.json` avec validation JSON Schema (réutiliser `framework/schemas/`) +- Viewer `.ulk-reports/accountability.jsonl` (timeline) +- Onglet Cloud Routines + Managed Agents (statut sessions) +- Toggles MCPs/CLIs (cf v0.2.1) ### v1.0 — extras (post-MVP) - Éditeur `settings.json` avec validation JSON Schema (réutiliser `framework/schemas/`) @@ -153,4 +174,5 @@ Sources de vérité **lues** par `RegistryLoader` (zero duplication) : - 2026-05-06 · scaffold · création carte + branche + squelette SwiftUI v0.1 - 2026-05-06 · v0.1 mergée (PR #143) -- 2026-05-06 · v0.2 · XcodeGen + contrat JSON typé Go↔Swift + 1er toggle fonctionnel (skill remove) +- 2026-05-06 · v0.2 · XcodeGen + contrat JSON typé Go↔Swift + 1er toggle fonctionnel (skill remove) · mergée (PR #147) +- 2026-05-06 · v0.3 · Sparkle 2.x + pipeline release CI signé+notarié + appcast hosté sur ulk.regrets.app diff --git a/site/public/appcast.xml b/site/public/appcast.xml new file mode 100644 index 00000000..4b9dc9f1 --- /dev/null +++ b/site/public/appcast.xml @@ -0,0 +1,17 @@ + + + + Ulk.app + https://ulk.regrets.app/appcast.xml + Most recent updates to Ulk.app — managed by .github/workflows/macos-release.yml + en + + +