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
43 changes: 42 additions & 1 deletion .github/workflows/desktop-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ jobs:
working-directory: website
run: bun run build
macos:
runs-on: macos-15
runs-on: macos-26
timeout-minutes: 45
defaults:
run:
Expand Down Expand Up @@ -68,11 +68,52 @@ jobs:
run: |
version="$(python3 scripts/release.py version)"
python3 scripts/keychain-smoke.py "dist/Umbod-$version.app"
- name: Prepare standalone acceptance harness
run: |
bash scripts/prepare-macos-acceptance.sh "$RUNNER_TEMP/macos-acceptance-kit"
tar -czf "$RUNNER_TEMP/macos-acceptance-kit.tar.gz" -C "$RUNNER_TEMP/macos-acceptance-kit" .
- uses: actions/upload-artifact@v4
with:
name: macos-acceptance-kit
path: ${{ runner.temp }}/macos-acceptance-kit.tar.gz
if-no-files-found: error
- uses: actions/upload-artifact@v4
with:
name: desktop-macos-arm64
path: desktop/dist/Umbod-*-arm64.dmg
if-no-files-found: error
macos-acceptance:
needs: macos
runs-on: macos-26
timeout-minutes: 20
steps:
# Deliberately no checkout or development-tool installation.
- uses: actions/download-artifact@v4
with:
name: desktop-macos-arm64
path: candidate
- uses: actions/download-artifact@v4
with:
name: macos-acceptance-kit
path: kit
- name: Accept the exact release DMG without a source checkout
shell: bash
run: |
set -euo pipefail
test "$(uname -m)" = arm64
mkdir harness
tar -xzf kit/macos-acceptance-kit.tar.gz -C harness
shasum -a 256 candidate/*.dmg > artifact-sha256.txt
env -i HOME="$HOME" TMPDIR="$(getconf DARWIN_USER_TEMP_DIR)" PATH=/usr/bin:/bin:/usr/sbin:/sbin GITHUB_ACTIONS=true RUNNER_ENVIRONMENT=github-hosted \
/bin/bash harness/scripts/accept-macos-artifact.sh "$PWD"/candidate/*.dmg 2>&1 | tee acceptance.log
- uses: actions/upload-artifact@v4
if: always()
with:
name: macos-acceptance-evidence
path: |
acceptance.log
artifact-sha256.txt
if-no-files-found: error
windows:
runs-on: windows-latest
timeout-minutes: 45
Expand Down
2 changes: 1 addition & 1 deletion desktop/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,4 +61,4 @@ OAuth supports native public clients with loopback callbacks, discovery, PKCE an

The [upstream Windows run](https://github.com/kasperjunge/umbod-desktop/actions/runs/37907078176) is historical source evidence supplied for this import. It is **not** a CI result for this target repository or these integration changes.

Release packaging collects dependency inventories and notices through `scripts/dependency-notices.py`; pinned supplemental sources are documented in `licenses/README.md`. `scripts/verify-python-bundle.py` rejects editable installs and imports outside the bundled interpreter before signing and again after installation. `scripts/install-smoke.py` installs the Mac DMG into disposable storage and verifies the packaged connector/agent flow, native window lifecycle, and signature integrity before and after execution. It never uses the login Keychain.
Release packaging collects dependency inventories and notices through `scripts/dependency-notices.py`; pinned supplemental sources are documented in `licenses/README.md`. `scripts/verify-python-bundle.py` rejects editable installs and imports outside the bundled interpreter before signing and again after installation. `scripts/audit-macos-bundle.py` verifies ARM64 slices, the macOS 14 deployment floor, required resources, executable permissions, internal symlinks and relocatable native dependencies. `Resources/build-metadata.json` records source and toolchain inputs. `scripts/prepare-macos-acceptance.sh` packages a precompiled test harness and fixtures; the separate `macos-acceptance` job runs `scripts/accept-macos-artifact.sh` on current macOS ARM64 with no checkout, using the DMG’s interpreter and the unchanged release identity. This required job also runs in the manual release workflow before publication. Local UI tests retain disposable bundle identities to protect user preferences. `scripts/install-smoke.py` installs the Mac DMG into disposable storage and verifies the packaged connector/agent flow, native window lifecycle, and signature integrity before and after execution. It never uses the login Keychain.
13 changes: 12 additions & 1 deletion desktop/docs/RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ Desktop is versioned independently of chart, images and SDK. Update `desktop/VER

1. Resolve the source-license and dependency-notice blockers in [provenance](PROVENANCE.md), review the changes, and merge through the normal process. Do not copy historical personal docs or evidence.
2. Configure the GitHub `desktop-release` environment with required reviewers and a deployment branch rule permitting only `main`. The workflow also validates repository, ref, event, version, rights attestation, and exact confirmation. No signing secret is required for unsigned builds. No credentials belong in website code or manifests.
3. Run native desktop CI on the reviewed source. PR/push CI only uploads artifacts and has read-only permissions. Mac uses an Apple Silicon runner and Swift 6; Windows builds native x64 Rust and WPF, including its packaged smoke test. Both must pass; a local cross-build does not substitute for Windows tests.
3. Run native desktop CI on the reviewed source. PR/push CI only uploads artifacts and has read-only permissions. Mac uses an Apple Silicon runner and Swift 6; Windows builds native x64 Rust and WPF, including its packaged smoke test. The Mac artifact-only acceptance job must also pass: it uses the exact DMG, no checkout or compiler, and an unchanged release app launched through LaunchServices on a fresh hosted runner. Both platform builds must pass; a local cross-build does not substitute for Windows tests.
4. From `main`, explicitly dispatch **Desktop manual prerelease** with version `X.Y.Z`, mode `unsigned-prerelease`, rights confirmation enabled, and confirmation text `release desktop-vX.Y.Z unsigned-prerelease`. The workflow rebuilds/tests the dispatch SHA. It creates a new desktop tag, an explicitly UNSIGNED GitHub prerelease, versioned assets, release notes, SHA256SUMS and desktop-downloads.json. It never promotes repository-wide latest or overwrites existing tags/releases/assets.
5. Download the `website-manifest-update` artifact and follow [APPLY-MANIFEST.md](APPLY-MANIFEST.md). This is a PR-ready file tree; publication does not automatically edit or deploy the website.

Expand All @@ -25,3 +25,14 @@ A realistic future setup is a protected release environment with a dedicated App
The macOS signing implementation must sign nested executables and the app with hardened runtime and timestamp, verify the designated identity and `codesign --verify --deep --strict`, submit the final distribution to `xcrun notarytool submit --wait`, require Accepted, staple, validate the staple, and run Gatekeeper assessment on the distribution. Windows must sign the gateway and UI executables using SHA-256 and an RFC 3161 timestamp, then require successful Authenticode chain/identity verification before packaging. Hashes/manifests must be generated after every signing/stapling mutation. Native clean-machine installation testing is still required. This is a setup specification, not a claim that a production signing path is implemented or verified.

Release notes and website labels must change only after that verified path exists; their schema currently accepts only unsigned prereleases.

## Unsigned first-launch rehearsal

Artifact-only CI checks application behavior and ad-hoc signature integrity. It does not claim notarization or browser/Gatekeeper acceptance. Before recommending an unsigned candidate, use a fresh Apple Silicon Mac account or a reset macOS VM:

1. Download the exact candidate DMG through Safari so quarantine is applied. Record its SHA-256, app version, source commit and macOS version with the release evidence.
2. Open the DMG, drag Umbod to Applications, eject the DMG, and open the installed app through Finder. Expect the unsigned-app warning; verify the system’s per-app Open Anyway flow works. Do not disable Gatekeeper or remove quarantine as a workaround.
3. With disposable test data, verify backend startup, connector discovery and a tool call. Close/reopen, restart the backend and quit. Repeat launch without changing credentials or security settings; record any prompts.
4. Check the installed bundle signature after execution. Preserve failure evidence and fix failures before recommending the candidate.

Current CI runs on macOS 26 ARM64 and checks binary deployment requirements against macOS 14. This is not runtime verification on macOS 14; that lane remains follow-up work. Native dependencies built from source use MACOSX_DEPLOYMENT_TARGET=14.0 and do not reuse potentially incompatible cached build wheels.
2 changes: 1 addition & 1 deletion desktop/native/Sources/Umbod/UmbodApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ import UmbodCore
window.makeKeyAndOrderFront(nil)
reopened = window.isVisible
}
let report: [String: Any] = ["backgroundSurvivedClose":survived,"reopened":reopened].merging( ["backendRunning": backend.isRunning, "backendPID": backend.processID, "bundlePath": Bundle.main.bundlePath, "gateway": backend.gateway,
let report: [String: Any] = ["backgroundSurvivedClose":survived,"reopened":reopened].merging( ["backendRunning": backend.isRunning, "backendPID": backend.processID, "bundlePath": Bundle.main.bundlePath, "bundleIdentifier": Bundle.main.bundleIdentifier ?? "", "gateway": backend.gateway,
"serverCount": servers.count, "windowCount": NSApp.windows.count,
"windows": NSApp.windows.map { ["title": $0.title, "visible": $0.isVisible, "width": $0.frame.width, "height": $0.frame.height] as [String: Any] }]) { _, new in new }
try JSONSerialization.data(withJSONObject: report, options: [.prettyPrinted, .sortedKeys]).write(to: URL(fileURLWithPath: path))
Expand Down
2 changes: 1 addition & 1 deletion desktop/native/Tests/UmbodTests/ContractTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ import UmbodCore
precondition(!config.contains("alice"))
let directory = FileManager.default.temporaryDirectory.appendingPathComponent("umbod-test-" + UUID().uuidString)
let backend = Backend()
let desktop = URL(fileURLWithPath: #filePath).deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent()
let desktop = ProcessInfo.processInfo.environment["UMBOD_TEST_RESOURCES"].map { URL(fileURLWithPath: $0) } ?? URL(fileURLWithPath: #filePath).deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent()
let app = ProcessInfo.processInfo.environment["UMBOD_TEST_APP"].map { URL(fileURLWithPath: $0) } ?? desktop.appendingPathComponent("dist/Umbod Dev.app")
let executable = app.appendingPathComponent("Contents/MacOS/umbod-desktop")
let profile = try String(contentsOf: app.appendingPathComponent("Contents/Resources/umbod-profile"), encoding: .utf8).trimmingCharacters(in: .whitespacesAndNewlines)
Expand Down
2 changes: 1 addition & 1 deletion desktop/native/Tests/UmbodTests/UpdateTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import Foundation
import UmbodCore

func updateTests() throws {
let desktop = URL(fileURLWithPath: #filePath).deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent()
let desktop = ProcessInfo.processInfo.environment["UMBOD_TEST_RESOURCES"].map { URL(fileURLWithPath: $0) } ?? URL(fileURLWithPath: #filePath).deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent()
let data = try Data(contentsOf: desktop.appendingPathComponent("tests/fixtures/update-manifest.json"))
let update = try DesktopUpdate.parse(data, installed: "0.2.0")
precondition(update?.version == "0.10.0", "Compare numeric versions, not strings")
Expand Down
38 changes: 38 additions & 0 deletions desktop/scripts/accept-macos-artifact.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
#!/bin/bash
set -euo pipefail
# No checkout, compiler or externally installed Python is used here.
MODE="${2:-release}"
case "$MODE" in release|local-fixture) ;; *) exit 2 ;; esac
KIT="$(cd "$(dirname "$0")/.." && pwd)"
DMG="${1:?DMG required}"
ROOT="$(mktemp -d "${TMPDIR:-/tmp}/umbod-test-acceptance.XXXXXX")"
MOUNT="$ROOT/volume"
APP="$ROOT/Applications/Umbod.app"
cleanup() {
if mount | grep -Fq " on $MOUNT "; then hdiutil detach "$MOUNT" || true; fi
rm -rf "$ROOT"
}
trap cleanup EXIT
shasum -a 256 "$DMG"
hdiutil verify "$DMG"
hdiutil attach -readonly -nobrowse -mountpoint "$MOUNT" "$DMG"
mkdir -p "$ROOT/Applications"
ditto "$MOUNT/Umbod.app" "$APP"
hdiutil detach "$MOUNT"
codesign --verify --deep --strict "$APP"
PYTHON="$APP/Contents/Resources/python/bin/python3"
cd "$ROOT"
export PATH=/usr/bin:/bin:/usr/sbin:/sbin
unset PYTHONHOME PYTHONPATH VIRTUAL_ENV CONDA_PREFIX DYLD_LIBRARY_PATH DYLD_FRAMEWORK_PATH
"$PYTHON" -I -B "$KIT/scripts/audit-macos-bundle.py" "$APP"
"$PYTHON" -I -B "$KIT/scripts/verify-python-bundle.py"
"$APP/Contents/MacOS/umbod-desktop" --help > /dev/null
UMBOD_TEST_APP="$APP" UMBOD_TEST_RESOURCES="$KIT" "$KIT/UmbodTests"
# This mode refuses to run on a developer account. No bundle ID changes or re-signing.
if [[ "$MODE" == release ]]; then
"$PYTHON" -I -B "$KIT/scripts/native-smoke.py" "$APP" --release-identity
else
"$PYTHON" -I -B "$KIT/scripts/native-smoke.py" "$APP"
fi
codesign --verify --deep --strict "$APP"
printf 'PASS: artifact-only acceptance (%s)\n' "$MODE"
113 changes: 113 additions & 0 deletions desktop/scripts/audit-macos-bundle.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
#!/usr/bin/env python3
"""Audit release layout and Mach-O dependencies without loading application code."""
import json
import os
from pathlib import Path
import plistlib
import subprocess
import sys

MAGICS = {bytes.fromhex(x) for x in ('cffaedfe', 'feedfacf', 'cafebabe', 'bebafeca', 'cafebabf', 'bfbafeca')}
SYSTEM = ('/usr/lib/', '/System/Library/')


def contained(path, root):
if not path.resolve().is_relative_to(root.resolve()):
raise ValueError(f'External bundle reference: {path}')
if not path.exists():
raise ValueError(f'Missing bundle dependency: {path}')


def load_commands(text):
result = []
current = None
for line in text.splitlines():
fields = line.strip().split()
if fields[:1] == ['cmd']:
current = {'cmd': fields[1]}
result.append(current)
elif current is not None and fields and fields[0] in ('name', 'path', 'minos', 'version'):
current[fields[0]] = line.strip().split(' ', 1)[1].split(' (offset')[0]
return result


def expand(value, loader, executable):
return Path(value.replace('@loader_path', str(loader)).replace('@executable_path', str(executable)))


def check_reference(value, loader, executable, rpaths, root):
if value.startswith(SYSTEM):
return # System libraries can live only in dyld's shared cache.
if value.startswith('@rpath/'):
suffix = value[len('@rpath/'):]
for rpath in rpaths:
candidate = expand(rpath, loader, executable) / suffix
if str(candidate).startswith(SYSTEM):
# Swift's OS runtime is supplied by macOS, not the Xcode toolchain.
if str(candidate).startswith('/usr/lib/swift/libswift'):
return
elif candidate.exists():
contained(candidate, root)
return
raise ValueError(f'Unresolved library: {value} in {loader}')
if not value.startswith(('@loader_path/', '@executable_path/')):
raise ValueError(f'Non-relocatable library: {value}')
contained(expand(value, loader, executable), root)


def audit(app):
app = app.resolve()
contents = app / 'Contents'
metadata = plistlib.loads((contents / 'Info.plist').read_bytes())
if metadata['LSMinimumSystemVersion'] != '14.0':
raise ValueError('Expected macOS 14 compatibility target')
required = ['MacOS/Umbod', 'MacOS/umbod-desktop', 'MacOS/umbod-gateway', 'Resources/python/bin/python3']
for name in required:
path = contents / name
contained(path, app)
if not os.access(path, os.X_OK):
raise ValueError(f'Missing executable permission: {name}')
for name in ['Resources/runtime-id', 'Resources/umbod-profile', 'Resources/UmbodBrand.icns', 'Resources/build-metadata.json']:
contained(contents / name, app)
identity = json.loads((contents / 'Resources/build-metadata.json').read_text())
if identity['version'] != metadata['CFBundleShortVersionString']:
raise ValueError('Build metadata and app versions disagree')
binaries = 0
for path in contents.rglob('*'):
if path.is_symlink():
contained(path, app)
continue
if not path.is_file():
continue
with path.open('rb') as stream:
if stream.read(4) not in MAGICS:
continue
binaries += 1
arches = subprocess.check_output(['/usr/bin/lipo', '-archs', str(path)], text=True).split()
if 'arm64' not in arches:
raise ValueError(f'No ARM64 slice: {path}')
commands = load_commands(subprocess.check_output(['/usr/bin/otool', '-arch', 'arm64', '-l', str(path)], text=True))
python = contents / 'Resources/python'
executable = python / 'bin' if path.is_relative_to(python) else contents / 'MacOS'
rpaths = [c['path'] for c in commands if c['cmd'] == 'LC_RPATH']
# Python extension libraries also inherit the interpreter's run path.
if path.is_relative_to(python):
rpaths += ['@executable_path/../lib']
for value in rpaths:
if not value.startswith(SYSTEM):
contained(expand(value, path.parent, executable), app)
for command in commands:
if command['cmd'] in ('LC_BUILD_VERSION', 'LC_VERSION_MIN_MACOSX'):
version = command.get('minos', command.get('version'))
parts = tuple(map(int, version.split('.')))
if (parts + (0, 0))[:2] > (14, 0):
raise ValueError(f'Requires macOS {version}, newer than target: {path}')
if command['cmd'] in ('LC_LOAD_DYLIB', 'LC_LOAD_WEAK_DYLIB', 'LC_REEXPORT_DYLIB', 'LC_LOAD_UPWARD_DYLIB'):
check_reference(command['name'], path.parent, executable, rpaths, app)
if not binaries:
raise ValueError('No native binaries found')
print(f'PASS: {binaries} ARM64 binaries, macOS 14 target, resources, symlinks and library references')


if __name__ == '__main__':
audit(Path(sys.argv[1]))
2 changes: 2 additions & 0 deletions desktop/scripts/build.sh
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ cat > "$APP/Contents/Info.plist" <<PLIST
<key>NSPrincipalClass</key><string>NSApplication</string>
</dict></plist>
PLIST
python3 scripts/record-macos-build.py "$APP"
"$APP/Contents/Resources/python/bin/python3" -I -B scripts/audit-macos-bundle.py "$APP"
# Local/CI builds never search or use a developer's signing identity.
# Production publishing is disabled by release.py until verified signing exists.
while IFS= read -r -d '' binary; do
Expand Down
Loading
Loading