From 6b01e1ab60f379167cfaed88dc9f91897b0cfe06 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 6 May 2026 22:09:38 +0000 Subject: [PATCH 1/4] feat(macos): integrate Sparkle 2.x for in-app auto-update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the GitHub-API stub UpdateChecker with a real Sparkle 2.x integration. UpdateChecker is now a SwiftUI-friendly facade over SPUStandardUpdaterController that surfaces six states (idle, checking, upToDate, updateAvailable, downloading, readyToInstall, failed) via @Published — DashboardShell sidebar binds directly to those. - project.yml : adds sparkle-project/Sparkle ≥ 2.6 SPM package and declares it as a dependency of the Ulk app target. Bumps MARKETING_VERSION to 0.3.0 / CURRENT_PROJECT_VERSION to 3. - Package.swift : same Sparkle dep on UlkCore so the SPM CI job that syntax-checks the library still builds (UpdateChecker imports Sparkle). - UpdateChecker.swift : rewritten as ObservableObject + NSObject conforming to SPUUpdaterDelegate. Exposes checkNow() (user-triggered) and checkInBackground() (silent at launch). Delegate callbacks are nonisolated and hop to @MainActor before mutating state. bundleVersion helper is nonisolated static for thread safety. - DashboardShell.swift : sidebar update badge handles the two new states (downloading with progress + readyToInstall with restart hint). Real release pipeline lives in scripts/sign-and-notarize.sh and .github/workflows/macos-release.yml — see README for the 7 GitHub secrets to configure. https://claude.ai/code/session_01TyhPjizanX1g8xSQ6chwTu --- apps/macos/Ulk/Package.swift | 17 +++- .../Ulk/Scenes/Dashboard/DashboardShell.swift | 16 ++- .../Ulk/Ulk/Services/UpdateChecker.swift | 98 +++++++++++++------ apps/macos/Ulk/project.yml | 11 ++- 4 files changed, 102 insertions(+), 40 deletions(-) diff --git a/apps/macos/Ulk/Package.swift b/apps/macos/Ulk/Package.swift index 2162bd6..52b0a09 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/Ulk/Scenes/Dashboard/DashboardShell.swift b/apps/macos/Ulk/Ulk/Scenes/Dashboard/DashboardShell.swift index 0c06f30..9c171bc 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 e84526b..c1b9613 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 20c341d..4dd8c71 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: From 531304599e4e4d2c66876faac6a7eea701ab004e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 6 May 2026 22:09:48 +0000 Subject: [PATCH 2/4] build(macos): add release pipeline scripts (sign + notarize + appcast) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two scripts that produce a publishable Ulk.app DMG: - scripts/sign-and-notarize.sh — xcodegen → xcodebuild archive → exportArchive (developer-id) → create-dmg → codesign → notarytool submit --wait → stapler staple → sign with Sparkle EdDSA key. Reads APPLE_TEAM_ID, APPLE_SIGNING_IDENTITY, APPLE_NOTARY_PROFILE, SPARKLE_PRIVATE_KEY_FILE from env. - scripts/generate-appcast.sh — appends a fully-formed Sparkle 2 to site/public/appcast.xml using a Python helper for safe XML edits (regex + str.replace are too brittle for nested CDATA notes). Pulls the Sparkle signature from the .eddsa sidecar produced above. Both scripts are idempotent and can be invoked locally or by the new .github/workflows/macos-release.yml workflow (next commit). https://claude.ai/code/session_01TyhPjizanX1g8xSQ6chwTu --- apps/macos/Ulk/scripts/generate-appcast.sh | 87 ++++++++++++ apps/macos/Ulk/scripts/sign-and-notarize.sh | 141 ++++++++++++++++++++ 2 files changed, 228 insertions(+) create mode 100755 apps/macos/Ulk/scripts/generate-appcast.sh create mode 100755 apps/macos/Ulk/scripts/sign-and-notarize.sh diff --git a/apps/macos/Ulk/scripts/generate-appcast.sh b/apps/macos/Ulk/scripts/generate-appcast.sh new file mode 100755 index 0000000..0f2bec5 --- /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 0000000..3c2d51f --- /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" From 087563c170da9de2c840991beebbda54d917b0ef Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 6 May 2026 22:10:01 +0000 Subject: [PATCH 3/4] ci(macos): tag-triggered release workflow + appcast hosted on site/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pushing a tag matching macos-v* (e.g. macos-v0.3.0) now triggers a full end-to-end release on macos-14 : 1. Resolve version from tag (or workflow_dispatch input) 2. Install xcodegen + create-dmg 3. Import .p12 cert into a temporary keychain (APPLE_CERTIFICATE_BASE64 + APPLE_CERTIFICATE_PASSWORD) 4. Store notary credentials via xcrun notarytool store-credentials (APPLE_NOTARY_APPLE_ID + APPLE_NOTARY_APP_SPECIFIC_PASSWORD + APPLE_TEAM_ID) 5. Decode Sparkle private key from SPARKLE_PRIVATE_KEY (base64) 6. Run scripts/sign-and-notarize.sh → produces signed DMG + .eddsa 7. Run scripts/generate-appcast.sh → updates site/public/appcast.xml 8. Publish GitHub release with DMG, .eddsa and appcast as assets 9. Open a chore/appcast- PR with the updated appcast so the site (Astro static at https://ulk.regrets.app) deploys the new feed site/public/appcast.xml seeded as an empty Sparkle 2 RSS skeleton ; each release inserts a new right after the . Required GitHub Secrets are documented in workflow comments and in apps/macos/Ulk/README.md (Release pipeline section). https://claude.ai/code/session_01TyhPjizanX1g8xSQ6chwTu --- .github/workflows/macos-release.yml | 156 ++++++++++++++++++++++++++++ site/public/appcast.xml | 17 +++ 2 files changed, 173 insertions(+) create mode 100644 .github/workflows/macos-release.yml create mode 100644 site/public/appcast.xml diff --git a/.github/workflows/macos-release.yml b/.github/workflows/macos-release.yml new file mode 100644 index 0000000..c0fe5a9 --- /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/site/public/appcast.xml b/site/public/appcast.xml new file mode 100644 index 0000000..4b9dc9f --- /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 + + + From ea8007193abb32bd4fa0b67ccbc96e3cfe77085a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 6 May 2026 22:10:11 +0000 Subject: [PATCH 4/4] docs(macos): release runbook + spec card v0.3 changelog - README : new "Auto-update (Sparkle 2.x)" + "Release pipeline" sections detailing the 7 GitHub Secrets, how to generate the EdDSA keypair with Sparkle's generate_keys tool, how to cut a release (`git tag macos-v0.3.0 && git push`), and how to do a manual local release as a fallback. - Spec card : v0.3 roadmap entry promoted to "in this branch" with full checklist done ; v0.4+ section absorbs the post-MVP extras formerly labelled v1.0 (settings.json editor, accountability viewer, Cloud Routines / Managed Agents tabs, plus deferred MCP/CLI toggles). https://claude.ai/code/session_01TyhPjizanX1g8xSQ6chwTu --- apps/macos/Ulk/README.md | 81 ++++++++++++++++--- .../CARD.md | 34 ++++++-- 2 files changed, 97 insertions(+), 18 deletions(-) diff --git a/apps/macos/Ulk/README.md b/apps/macos/Ulk/README.md index 44b892d..3ea4465 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/docs/backlog/2026-05-06-FEATURE-MACOS-COMPANION-APP/CARD.md b/docs/backlog/2026-05-06-FEATURE-MACOS-COMPANION-APP/CARD.md index fd4dd18..90d6cac 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