Merge pull request #7903 from utmapp/agents/utm-release

agents: add /utm-test and /utm-release workflows
This commit is contained in:
osy
2026-09-23 10:29:13 -05:00
committed by GitHub
10 changed files with 845 additions and 110 deletions
+211
View File
@@ -0,0 +1,211 @@
---
name: utm-release
description: Publish a new UTM release on GitHub. Drafts the release notes from the commits and pull requests since the last release, has the user approve them, bumps the version in Build.xcconfig, commits and tags it, pushes, and creates the GitHub release with a discussion in the "Releases" category, which starts the release pipeline. Use this when the user wants to cut, publish, tag, or draft a UTM release or beta, or runs /utm-release.
---
# utm-release
This is the canonical copy. The per-agent entries in `.claude/commands/` and
`.opencode/command/` point here. `$SCRATCH` means your agent's scratch directory,
or `mktemp -d` if you don't have one.
Arguments, both optional and in this order:
1. **Version** `x.y.z`. The default is the next revision after the current
`MARKETING_VERSION` in `Build.xcconfig` (5.0.5 → 5.0.6).
2. **Prerelease** `yes` or `no`. The default is `yes`.
Read `Documentation/Release.md` first. Its "Release notes" section describes how
the app parses the notes, and how to version and write them. Its "Release notes
template" gives the layout and the footer (from `## Issues` to the end), which
you copy verbatim. This skill doesn't repeat those rules, so apply them.
## Ground rules
- **Nothing leaves the machine until the user approves the notes.** Pushing
the tag and creating the release start the public pipeline, which signs the
build and submits it to App Store Connect. It can't be undone cleanly.
- The GitHub secrets and variables are already set up. Don't read, change, or
check them.
- Commit `Build.xcconfig` only. Leave any other changes in the working tree
alone.
## Step 1: Preflight
```sh
git fetch origin --tags
git status --short
git rev-parse --abbrev-ref HEAD # must be main
git rev-list --left-right --count HEAD...origin/main # must be 0 0
grep -E '^(MARKETING_VERSION|CURRENT_PROJECT_VERSION)' Build.xcconfig
gh release list -R utmapp/UTM -L 10
```
- Stop and ask if you're not on `main`, if `main` differs from `origin/main`, or
if `Build.xcconfig` has uncommitted changes. Other dirty files are fine.
- If `gh` isn't authenticated, ask the user to run `gh auth login`.
- The **previous release** is the newest entry in `gh release list`. Its tag
should match the current `MARKETING_VERSION`. If it doesn't, tell the user
before you continue.
- Work out the new version and check that it is greater than the previous one.
If tag `vx.y.z` already exists, locally or on GitHub, stop. The user probably
wants a re-release. Point them to "Re-release" in `Release.md` and ask how to
proceed.
- The new build number is the current `CURRENT_PROJECT_VERSION` + 1.
## Step 2: Start the notes
- **Revision** (same `x.y` as the previous release, z > 0): start from the
previous release's full notes:
`gh release view <prev-tag> -R utmapp/UTM --json body -q .body | tr -d '\r'`.
Keep every section, including all the older `## Changes` sections.
- **New minor or major version**: start from the template in `Release.md`.
Carry nothing over from the previous series.
Either way, replace the footer with the one in `Release.md`, so the release uses
the current template.
## Step 3: Collect the changes
List everything that landed since the previous release:
```sh
git log --first-parent --reverse --format='%H %an <%ae>%n %s' <prev-tag>..origin/main
```
Each entry is one of these:
- **A merge commit** (`Merge pull request #N from owner/branch`): one PR.
- **A squash merge** (subject ends in `(#N)`): one PR.
- **A direct commit to `main`**: its own change.
For each PR, fetch its details:
```sh
gh pr view N -R utmapp/UTM --json title,body,author,commits,files,closingIssuesReferences
gh api repos/utmapp/UTM/pulls/N --jq '.user.login + " " + .author_association'
```
For a direct commit, read `git show --stat <sha>`, and get the author's GitHub
login with `gh api repos/utmapp/UTM/commits/<sha> --jq .author.login`.
Then write one bullet per PR or direct commit, in the format that `Release.md`
gives. Work out each part from the code and the discussion, not only from the
title:
- **What changed.** Describe what users see, and start with a verb (Fixed,
Added, Support, …). If several PRs or commits build one feature, merge them
into one bullet.
- **Platform.** Judge from the files touched and from `#if os(...)`,
`WITH_QEMU_TCI`, `@available`, or `#available` checks. The code in
`Platform/macOS/`, the Apple Virtualization backend (`UTMAppleVirtualMachine`
and `Configuration/UTMAppleConfiguration*`), `QEMUHelper`, `QEMULauncher`, and
`utmctl` is macOS-only. If a change only applies from some OS version, add the
version: `(macOS 26)`. Use only prefixes that the table in `Release.md` accepts.
- **Component.** Use the same names as earlier notes (the list in `Release.md`,
and whatever the previous notes used). Translations are always
`Localization:`, with each language named in English (`German`, not `de`).
Work the language out from the `.lproj` folders that the change touches (for
example `Platform/de.lproj` is German, and `zh-HK` plus `zh-Hans` is
`Chinese (Hong Kong, Simplified)`).
- **Issue.** If the change fixes a bug or resolves a request that has an issue,
add `(#N)`. Find it in `closingIssuesReferences` or a `Fixes`, `Resolves`, or
`Closes #N` line in the PR body or the commit messages. Don't cite the PR
number itself.
- **Credit.** If `author_association` isn't `OWNER` or `MEMBER`, add
`(thanks @login)`. Do the same for a direct commit by someone other than a
maintainer. Don't credit bots.
- **Leave out** changes that users can't see: docs, CI, agent skills, tests,
refactors, and build scripts that don't change what ships. Keep build fixes
that do change the shipped app. For example, a dependency link fix that makes
UTM run on older macOS is user-visible. Keep a list of what you left out, to
show the user.
Put the new bullets in `## Changes (vx.y.z)`, directly above the previous
`## Changes` section. Sort them into the groups that `Release.md` describes:
changes for every platform first, then each platform's own changes, and within
each of those, every bullet for the same component next to the others. Check the
order again after the user's edits in Step 5. Leave the older sections in the
order they were published, but offer to sort any that aren't sorted yet.
## Step 4: Highlights and notes
- **Highlights.** Review the list with the whole release, or series, in mind.
Add a highlight only for a major feature or change, or a theme that several
changes share. Write for a general audience, with marketing terms rather than
technical ones. On a revision, keep the earlier highlights, but you may reword
or merge them when a new change extends one. If nothing is worth a
highlight, don't invent one. Tell the user instead, because the first section
is what the app shows up front.
- **Notes and Known Issues.** Keep the carried-over entries, but flag any that
are now stale, such as a requirement that was reverted or a known issue that
this release fixes. Suggest removing them rather than removing them yourself.
Propose new notes when a change needs action from users (reinstall a driver,
new minimum OS, changed defaults).
## Step 5: Get the notes approved
Write the notes to `$SCRATCH/release-notes-vx.y.z.md`. Show the user:
- the full notes,
- the title (`vx.y.z (Beta)` for a prerelease, otherwise `vx.y.z`), the version,
the build number, and whether it's a prerelease,
- the commits and PRs you left out, and why,
- anything you weren't sure about: platform, issue, credit, or stale notes.
Then **stop and wait**. The user may add notes or highlights, or edit the
changelog. Apply their edits to the file and show the result again. Repeat until
they approve this exact text. If they change the version or the prerelease
setting, go back to Step 1.
## Step 6: Bump, commit, tag, push
Once the user has approved:
1. In `Build.xcconfig`, set `MARKETING_VERSION = x.y.z` and
`CURRENT_PROJECT_VERSION` to the new build number. Change nothing else.
2. Commit only that file. The title is exactly `project: bumped version`, and it
carries the AI-attribution trailer that `AGENTS.md` requires:
```sh
git add Build.xcconfig
git commit -m "project: bumped version" -m "Assisted-by: AGENT:MODEL"
```
Put your own agent and model in the trailer. Don't add `Co-authored-by` or
session links, and remove any that your tools add.
3. Tag the commit and push both. Use a lightweight tag, like earlier releases:
```sh
git tag vx.y.z
git push origin main
git push origin vx.y.z
```
If the push to `main` is rejected, stop and tell the user. Don't force-push or
rebase over new commits on `main`. If new commits landed, their changes are
missing from the notes.
## Step 7: Create the release
```sh
gh release create vx.y.z -R utmapp/UTM --verify-tag \
--title "vx.y.z (Beta)" --prerelease \
--notes-file "$SCRATCH/release-notes-vx.y.z.md" \
--discussion-category "Releases"
```
For a stable release, use `--title "vx.y.z"` and `--latest` instead of
`--prerelease`. Don't create a draft. The pipeline runs on the release's
`created` event, and a published release is what the app downloads its notes
from.
Then check the result, and fix what you can:
```sh
gh release view vx.y.z -R utmapp/UTM --json name,isPrerelease,url
gh api graphql -f query='{repository(owner:"utmapp",name:"UTM"){discussions(first:3,orderBy:{field:CREATED_AT,direction:DESC}){nodes{title url category{name}}}}}'
gh run list -R utmapp/UTM --workflow Build --event release -L 1 # not "build.yml": that filter returns stale runs
```
- The discussion must be titled like the release and be in "Releases".
- A `Build` run titled like the release should have started for the release event.
Report the release URL, the discussion URL, and the pipeline run URL. Don't wait
for the pipeline to finish unless the user asks.
+348
View File
@@ -0,0 +1,348 @@
---
name: utm-test
description: Build, run, and debug a UTM change for real. Covers building UTM, building all dependencies or just one after changing it, creating a disposable test VM by scripting and deleting it afterwards, where logs go, debugging, headless GUI checks, and the iOS simulator. Use this when you need to run UTM, reproduce a bug, or check that a change works in the app and not just that it compiles. Run it before /utm-review.
---
# utm-test
This is the canonical copy. The per-agent entries in `.claude/commands/` and
`.opencode/command/` point here. Commands assume macOS on arm64 and the repo root
as the working directory. `$SCRATCH` means your agent's scratch directory, or
`mktemp -d` if you don't have one.
## Ground rules
- **Test only on VMs you created, and delete them when you're done.** Never start,
change, or delete the user's VMs. Other agent sessions may be using UTM on the
same machine. Ask before you kill a UTM process that you didn't launch.
- **Run one UTM instance: your dev build.** `/Applications/UTM.app` has the same
bundle id. Scripting then goes to whichever instance is running, and it can
launch the release app instead of yours. A stale binary also silently ignores
scripting parameters it doesn't know, so a new parameter can turn into a plain
`start`. Check with `pgrep -fl "MacOS/UTM|QEMULauncher"` before you launch and
after you finish.
- **Report only what you saw.** If your test VM never runs the code path you
changed, say so rather than implying the change was validated. Before you call
a failure pre-existing, build `main` in a worktree and compare, and check the
issue tracker.
## Build UTM
```sh
xcodebuild -project UTM.xcodeproj -scheme macOS -configuration Debug \
-destination 'platform=macOS,arch=arm64' build > "$SCRATCH/build.log" 2>&1
grep -E "error:|^\*\* BUILD" "$SCRATCH/build.log" | sort -u
APP=$(ls -dt ~/Library/Developer/Xcode/DerivedData/UTM-*/Build/Products/Debug/UTM.app | head -1)
```
- Match `^\*\* BUILD`. A bare `BUILD` can match a path. If a build fails, the app
from the last good build is still on disk, so check the result before testing.
- Swift code goes into `Contents/MacOS/UTM.debug.dylib`. To prove your change is in
the binary you run, use `strings "$APP/Contents/MacOS/UTM.debug.dylib" | grep <marker>`.
- The Debug build is signed through the local `CodeSigning.xcconfig`. Keep that
signing when you intend to run the app. `CODE_SIGNING_ALLOWED=NO` drops the
virtualization and networking entitlements. Even when signed, vmnet
(Shared/Bridged) networking may fail locally, so test VMs should use emulated
networking or none.
- Compile-only checks for other platforms:
- Add `CODE_SIGNING_ALLOWED=NO` and a separate `-derivedDataPath "$SCRATCH/dd-<scheme>"`.
- Use `-destination 'generic/platform=iOS'` with scheme `iOS`, `iOS-SE`, or
`iOS-Remote`, or `'generic/platform=visionOS'` with scheme `iOS`.
- Add `ARCHS=arm64` to any `generic/` destination. Otherwise it tries to build
universal and looks for `sysroot-*-arm64_x86_64`.
- If SPM plugin validation fails after a package URL changes, pass
`-skipPackagePluginValidation`.
- **Baseline or per-commit builds:** don't check out over the user's tree.
1. `git worktree add --detach "$SCRATCH/wt" <rev>`.
2. Symlink every `sysroot-*` and `CodeSigning.xcconfig` into the worktree. A
missing sysroot shows up as a misleading "missing resource" error.
3. Build with its own `-derivedDataPath`.
4. Remove the symlinks by name, then run `git worktree remove`.
## Dependencies
QEMU, SPICE, GLib, and the other native dependencies reach the app as frameworks
in `sysroot-<Platform>-<arch>/Frameworks`. Xcode embeds them and re-signs them.
### Full build
Usually you don't need one. Download the `Sysroot-*` artifacts from a recent
GitHub Actions run and extract them at the repo root. Build them yourself only
when you're changing a dependency:
```sh
./scripts/build_dependencies.sh -p macos -a arm64 > "$SCRATCH/deps.log" 2>&1 # -p ios|ios-tci|ios_simulator|visionos|…
grep -a "Building \|All done" "$SCRATCH/deps.log" | tail
```
- The build takes hours and deletes the sysroot before it starts.
- It leaves source trees in `build-<Platform>-<arch>/`. Those trees are what the
single-dependency path below needs, and CI artifacts don't include them.
- A successful run ends with `All done` and writes `build-*/BUILD_SUCCESS`.
- If a build dies from memory pressure (LLVM and Mesa are the heavy parts), set
`NCPU=4`.
- Source versions are pinned in `patches/sources`, and UTM's changes to them are
in `patches/*.patch`. If you change a patch, check that it still applies to a
fresh extract of the tarball: `patch -p1 --dry-run`.
- If a staged sysroot crashes inside a framework on an older macOS, suspect the
sysroot before your change. For example, a weak import of an SDK-new symbol is
NULL on older systems. Check `nm -m <binary> | grep "weak external"`,
`vtool -show-build <binary>`, and `git log -S<symbol> -- scripts/` against the
sysroot's date.
### Rebuild one dependency after a change
1. **Rebuild the library in its tree.** The trees are gitignored, so there is no
git safety net for your edits.
- QEMU: build only the target you need:
```sh
ninja -C build-macOS-arm64/qemu-<ver>-utm/build libqemu-aarch64-softmmu.dylib
```
A small edit takes seconds. `ninja -t targets all | grep <file>` finds object
targets.
- Meson projects such as spice-gtk, spice, and virglrenderer:
`meson install -C build-macOS-arm64/<tree>/utm_build`. This installs into the
sysroot's `lib/`.
- Autotools projects: `make -C build-macOS-arm64/<tree> install`.
2. **Repackage it** into the framework the app embeds:
```sh
scripts/fixup.sh -p macos -s sysroot-macos-arm64 <path/to/libfoo.N.dylib>
```
- This writes `Frameworks/foo.N.framework` (the name drops `lib` and the last
extension), sets the `@rpath` install name, and points imports of other
sysroot libraries at their frameworks.
- It is safe to re-run on a sysroot that has already been fixed up.
- It matches sysroot libraries by file name, so it also works on a sysroot
downloaded from CI, whose install names record the CI machine's paths.
- `-i` rewrites only the imports of an executable, such as
`libexec/virgl_render_server`, in place.
- For iOS, pass `-p ios` and the iOS sysroot. That produces the flat framework
layout.
3. **Check the framework, then rebuild UTM** so Xcode copies it into the app:
```sh
otool -L sysroot-macos-arm64/Frameworks/<name>.framework/Versions/A/<name> | tail -n +2 | grep -v '@rpath\|/System/\|/usr/lib/' # prints nothing
strings "$APP/Contents/Frameworks/<name>.framework/Versions/A/<name>" | grep -c <marker> # after xcodebuild
```
Testing a stale framework and concluding "no effect" is the most common way to
waste time here.
### Swift packages (CocoaSpice, QEMUKit, SwiftTerm)
These are remote packages pinned in `Package.resolved`, not the sibling checkouts.
There are two ways to test a change to one:
- **Quick patch:** edit the checkout in
`~/Library/Developer/Xcode/DerivedData/UTM-*/SourcePackages/checkouts/<Pkg>`.
The files are read-only, so `chmod u+w` first. A normal `xcodebuild` picks the
edit up. Revert afterwards with `git checkout -- <file>` inside the checkout.
- **Local override:** add the local package folder to `project.pbxproj` as a
folder reference (`lastKnownFileType = wrapper; path = ../<Pkg>`). Xcode then
prefers it over the remote package. Remove the reference, and bump the pin once
the package change is pushed.
## Run a disposable test VM
Launch the dev build yourself. Launching the binary directly is also how you
capture its stdout and pass it environment variables. Wait with `is running`.
Anything else sent before the app registers, even `tell application "$APP"`,
launches a second instance.
```sh
caffeinate -d -u -t 3600 & # a sleeping display blocks Apple VM start and hides dialogs
nohup "$APP/Contents/MacOS/UTM" > "$SCRATCH/utm-stdout.log" 2>&1 &
until [ "$(osascript -e "application \"$APP\" is running")" = true ]; do sleep 1; done
utm() { osascript -e "tell application \"$APP\"" -e "$1" -e "end tell"; }
```
Create a QEMU VM that boots to the EDK2 UEFI shell. It has no disk and no NIC (a
NIC would add a PXE delay). It reaches `Shell>` in about 12 s:
```sh
VM="utm-test-$$"
utm "make new virtual machine with properties {backend:qemu, configuration:{name:\"$VM\", architecture:\"aarch64\", drives:{}, network interfaces:{}, displays:{{hardware:\"virtio-gpu-gl-pci\"}}}}"
utm "start virtual machine named \"$VM\""
utm "get status of virtual machine named \"$VM\"" # stopped|starting|started|paused|…
```
- **Shape the VM to the code path you're testing.** The configuration record
accepts `memory`, `cpu cores`, `hypervisor` (default true; use false for TCG),
`uefi`, `drives` (`{{guest size:<MiB>}}`, or `{{removable:true, source:POSIX file "…"}}`
for an ISO), `network interfaces`, `serial ports`, `displays`
(`{}` for headless, or `hardware:"virtio-ramfb"` to use the 2D path instead of
GL), and `qemu additional arguments`.
- **Apple backend:** `{backend:apple, configuration:{name:"…"}}` creates a Linux
VM with no display. The full vocabulary is in `Scripting/UTM.sdef`.
- **Change the configuration** of a stopped VM:
1. `set c to configuration of vm`
2. Edit fields of `c`.
3. `update configuration of vm with c`.
Lists such as `drives` are replaced whole, so any element you leave out is
removed.
- **Other verbs:**
- `start … saving false`: disposable run.
- `stop … by force|kill|request`.
- `suspend … saving true`.
- `duplicate virtual machine named "X" with properties {configuration:{name:"Y"}}`.
`duplicate` returns before the bundle exists on disk.
- `$APP/Contents/MacOS/utmctl` has the same verbs. It exits 0 even when it
fails, so read its output. It hangs if UTM's main thread is blocked.
- **When testing a new verb or parameter, try it on your throwaway VM first.**
Confirm that the new parameter takes effect before you point it anywhere else.
A `delete` on a child object that can't be resolved has been applied to the
parent VM.
- **Bundles** are at `~/Library/Containers/com.utmapp.UTM/Data/Documents/<name>.utm`
(`config.plist`, `Data/`). If you edit `config.plist` by hand, quit UTM first.
For plists that contain dates or data, use `plutil -p` or PlistBuddy; `plutil -convert json`
fails on them.
### Verify it really worked
A `started` status proves only that the process launched.
- **Screenshot the VM window by ID.** This works even when other windows cover it.
Look at the image; don't assume.
```sh
WID=$(osascript -l JavaScript -e "ObjC.import('CoreGraphics'); ObjC.deepUnwrap(ObjC.castRefToObject(\$.CGWindowListCopyWindowInfo(\$.kCGWindowListOptionAll, 0))).filter(w => w.kCGWindowOwnerName == 'UTM' && w.kCGWindowName == '$VM' && w.kCGWindowIsOnscreen).map(w => w.kCGWindowNumber).join('\n')")
screencapture -x -o -l "$WID" "$SCRATCH/vm.png" && sips -Z 1200 "$SCRATCH/vm.png"
```
- A healthy run shows the EDK2 banner, `Mapping table`, and `Shell>`.
- A black, torn, or frozen frame is a real failure.
- Use `kCGWindowName == 'UTM'` for the library window. Don't match on size: a
500×500 offscreen helper window also exists.
- A disposable run's window is titled "<VM> – Disposable Mode" in
Accessibility, but "<VM>" here.
- **Serial I/O without the GUI:** the default ptty serial port carries the
firmware console both ways.
```sh
P=$(utm "get address of serial port 1 of virtual machine named \"$VM\"")
cat "$P" > "$SCRATCH/serial.txt" &
printf 'ver\r' > "$P"
```
The shell's reply lands in `serial.txt`, with ANSI escapes.
- **Check the status again** after the checks above. A guest that crashed can
still report `started` for a moment.
- **Performance:** measure before and after in the same session, and re-run
outliers. Say whether Debug Log was on (see below).
### Clean up
```sh
utm "stop virtual machine named \"$VM\" by force"
until [ "$(utm "get status of virtual machine named \"$VM\"")" = stopped ]; do sleep 1; done
utm "delete virtual machine named \"$VM\"" # no confirmation; refused while running
utm quit; pkill caffeinate
pgrep -fl "MacOS/UTM|QEMULauncher" # expect nothing of yours
```
- `quit` fails with -128 while any VM is running or paused, because a
confirmation dialog blocks it.
- `pkill` on UTM can orphan `QEMULauncher`, which keeps `efi_vars.fd` locked
("Is another process using the image"). Kill any leftover launcher of yours.
- Remove every temporary hook, log line, and dependency-tree edit. `/utm-review`
flags stray diagnostics, and `git status` doesn't see the gitignored `build-*`
trees. Tag temporary code with a marker you can grep for.
## Observe and debug
- **App stdout is the main log.** Everything `QEMULogging` receives is `NSLog`ed
there: the QEMU command line and environment, `[CocoaSpice]`, `GSpice-*`, QMP,
and app logging.
- **`Data/debug.log` in the bundle** only gets the launch header, even with Debug
Log on.
- **The unified log:**
`log show --last 5m --info --debug --style compact --predicate 'process IN {"UTM","QEMUHelper","QEMULauncher"}'`.
Use `/usr/bin/log` in zsh, where `log` is a builtin.
- **Swift `logger.debug` is hidden** at the default level. Temporarily set
`logLevel` to `.debug` in `Services/UTMLoggingSwift.swift`, and don't commit it.
- **To get output from CocoaSpice**, use `g_message()`, which always passes the
handler in `CSMain.m`. `g_debug()` output only appears with SPICE debugging on.
- **QEMU's own stderr reaches no log.** QEMU runs in `QEMULauncher` under
`QEMUHelper` (XPC), sandboxed, with an environment built explicitly by
`Services/UTMQemuSystem.m`. It does not inherit the app's environment.
- To trace inside QEMU, write to a file in
`~/Library/Group Containers/WDNLXAD4W8.com.utmapp.UTM/`.
- To pass an environment variable to QEMU, add it in `UTMQemuSystem.m`
temporarily.
- `ps eww -p <launcher pid>` shows what QEMU actually got.
- **The VM's Debug Log setting** injects `MTL_DEBUG_LAYER`, `MVK_DEBUG`,
`VK_LOADER_DEBUG`, `MESA_DEBUG`, and `VIRGL_LOG_LEVEL` into QEMU, which costs
performance.
- **Hangs:** run `sample <pid> 2 -file "$SCRATCH/utm.sample"` and read the main
thread's stack.
- **Crashes:** reports are at `~/Library/Logs/DiagnosticReports/UTM*.ips`. The file
is JSON after the first line; use `threads[<triggered>].frames` together with
`usedImages`.
- **lldb:** the Debug build has `get-task-allow`, so you can attach and script it:
`lldb -p $(pgrep -f "Debug/UTM.app/Contents/MacOS/UTM$") --batch -o '…' -o detach`.
- Give breakpoints `-G true` (auto-continue). A breakpoint that stops freezes
UTM, and every scripted step then hangs.
- A slow app may simply have a debugger attached.
- **Hard-to-reach UI states:** add a temporary environment-variable hook, such as
selecting a VM or switching a tab in `ContentView.onAppear`. Launch with
`env UTM_TMP_X=… "$APP/Contents/MacOS/UTM"`, then remove the hook.
- **Stale state:** AppKit and user defaults can mask a change. The VM registry is
in `defaults read com.utmapp.UTM Registry`. Toolbars are autosaved under
`NSToolbar Configuration <id>`.
## Headless GUI input (macOS)
- **System Events clicks** don't register on SwiftUI lists, menu extras, and some
toolbar items. Build a small `swiftc` tool instead: post `.mouseMoved`, wait
about 100 ms, then post `.leftMouseDown` and `.leftMouseUp` through
`CGEvent(mouseEventSource:…).post(tap: .cghidEventTap)`.
- **Coordinates are points.** Retina screenshots are 2× that, so downscale with
`sips -Z <screen width in points>` to make image pixels match points.
- **Bring UTM to the front** before each click, and take a screenshot after each
click to check what happened:
`tell application "System Events" to set frontmost of process "UTM" to true`.
- **Typing into guests:** send text slowly. The Virtualization.framework view
drops shifted characters and some punctuation from `keystroke`, so use
`key code` or choose commands that don't need them.
- **Locked screen:** check with `ioreg -n Root -d1 -a | grep -A1 CGSSessionScreenIsLocked`.
- While the screen is locked, input and Accessibility do nothing, and window
capture may fail too.
- Fall back to serial or logs, or ask the user to unlock.
- System consent dialogs belong to `UserNotificationCenter` and don't appear
while the display sleeps.
- **Launch method:** `open -a "$APP"` makes UTM the responsible process for
privacy prompts. Launching the binary from a shell makes the terminal
responsible instead, but `open` loses the stdout log.
## iOS simulator
```sh
xcodebuild -project UTM.xcodeproj -scheme iOS -configuration Debug -destination "platform=iOS Simulator,id=$UDID" \
ARCHS=arm64 ONLY_ACTIVE_ARCH=YES CODE_SIGNING_ALLOWED=NO build # needs sysroot-iOS_Simulator-arm64
IOSAPP=$(ls -dt ~/Library/Developer/Xcode/DerivedData/UTM-*/Build/Products/Debug-iphonesimulator/UTM.app | head -1)
xcrun simctl boot $UDID; xcrun simctl bootstatus $UDID -b; xcrun simctl install $UDID "$IOSAPP"
xcrun simctl spawn $UDID defaults write com.utmapp.UTM ReleaseNotesLastVersion \
"$(defaults read "$IOSAPP/Info.plist" CFBundleShortVersionString)" # skip What's New
```
- **Use a device you created** with `simctl create`, and run one simulator at a
time. Parallel simulators hang `simctl`.
- **Boot errors:** if boot fails with "Invalid argument" or the device wedges,
run `simctl shutdown`, boot again, and wait for `bootstatus`.
- **Seed a VM:** create one on macOS as above, then copy it with `cp -c -R` into
`$(xcrun simctl get_app_container $UDID com.utmapp.UTM data)/Documents/`.
Resolve that path each time, because it changes on reinstall.
- **There is no scripting on iOS.** Drive the app with a temporary
environment-variable hook, passed as `SIMCTL_CHILD_<VAR>=… xcrun simctl launch …`.
For taps, use a throwaway XCUITest target (`bundle.ui-testing`, no host app)
run with `test-without-building`.
- **Observe:** `xcrun simctl io $UDID screenshot out.png`, and
`xcrun simctl spawn $UDID log show --last 2m --style compact --predicate 'process == "UTM"'`.
lldb can't attach, because UTM ptraces itself for JIT.
- **iOS runs QEMU in-process, once per process.** Stopping a VM quits the app.
- **Clean up:** `xcrun simctl shutdown $UDID`, and delete any device you created.
+12
View File
@@ -0,0 +1,12 @@
---
description: Publish a new UTM GitHub release — drafts release notes since the last release, asks for approval, then bumps Build.xcconfig, tags, and creates the release + Releases discussion.
argument-hint: [version x.y.z (default next revision)] [prerelease yes|no (default yes)]
allowed-tools: Bash, Read, Grep, Glob, Edit, Write, AskUserQuestion
---
Read and follow `.agents/skills/utm-release/SKILL.md`. Use your scratchpad
directory as `$SCRATCH`. At the approval step, show the full notes as text,
then use AskUserQuestion to ask whether to publish, and treat any edits the user
asks for as another round. The trailer is `Assisted-by: Claude:<your model ID>`.
Arguments (version, then prerelease): $ARGUMENTS
+11
View File
@@ -0,0 +1,11 @@
---
description: Build, run, and debug a UTM change against a throwaway test VM (also dependency rebuilds via scripts/fixup.sh). Run before /utm-review.
argument-hint: [what to verify]
allowed-tools: Bash, Read, Grep, Glob, Edit, Write, Skill, Task
---
Read and follow `.agents/skills/utm-test/SKILL.md`. Use your scratchpad
directory as `$SCRATCH`, and Read every screenshot you capture: actually look at
the image. Run long builds in the background.
What to verify in this run (if any): $ARGUMENTS
+8 -8
View File
@@ -75,8 +75,8 @@ jobs:
uses: actions/cache/restore@v4
with:
path: ./sysroot-${{ matrix.platform }}-${{ matrix.arch }}
key: ${{ matrix.platform }}-${{ matrix.arch }}-${{ hashFiles('scripts/build_dependencies.sh') }}-${{ hashFiles('patches/**') }}-${{ github.sha }}
restore-keys: ${{ matrix.platform }}-${{ matrix.arch }}-${{ hashFiles('scripts/build_dependencies.sh') }}-${{ hashFiles('patches/**') }}
key: ${{ matrix.platform }}-${{ matrix.arch }}-${{ hashFiles('scripts/build_dependencies.sh', 'scripts/fixup.sh') }}-${{ hashFiles('patches/**') }}-${{ github.sha }}
restore-keys: ${{ matrix.platform }}-${{ matrix.arch }}-${{ hashFiles('scripts/build_dependencies.sh', 'scripts/fixup.sh') }}-${{ hashFiles('patches/**') }}
lookup-only: github.event_name != 'release' && github.event.inputs.test_release != 'true'
- name: Setup Path
shell: bash
@@ -124,8 +124,8 @@ jobs:
uses: actions/cache/restore@v4
with:
path: ./sysroot-macOS-arm64_x86_64
key: macos-universal-${{ hashFiles('scripts/build_dependencies.sh', 'scripts/pack_dependencies.sh') }}-${{ hashFiles('patches/**') }}-${{ github.sha }}
restore-keys: macos-universal-${{ hashFiles('scripts/build_dependencies.sh', 'scripts/pack_dependencies.sh') }}-${{ hashFiles('patches/**') }}
key: macos-universal-${{ hashFiles('scripts/build_dependencies.sh', 'scripts/fixup.sh', 'scripts/pack_dependencies.sh') }}-${{ hashFiles('patches/**') }}-${{ github.sha }}
restore-keys: macos-universal-${{ hashFiles('scripts/build_dependencies.sh', 'scripts/fixup.sh', 'scripts/pack_dependencies.sh') }}-${{ hashFiles('patches/**') }}
lookup-only: github.event_name != 'release' && github.event.inputs.test_release != 'true'
- name: Cache Sysroot (arm64)
if: steps.cache-sysroot-universal.outputs.cache-matched-key == ''
@@ -133,7 +133,7 @@ jobs:
uses: actions/cache/restore@v4
with:
path: ./sysroot-macos-arm64
key: macos-arm64-${{ hashFiles('scripts/build_dependencies.sh') }}-${{ hashFiles('patches/**') }}
key: macos-arm64-${{ hashFiles('scripts/build_dependencies.sh', 'scripts/fixup.sh') }}-${{ hashFiles('patches/**') }}
fail-on-cache-miss: true
- name: Cache Sysroot (x86_64)
if: steps.cache-sysroot-universal.outputs.cache-matched-key == ''
@@ -141,7 +141,7 @@ jobs:
uses: actions/cache/restore@v4
with:
path: ./sysroot-macos-x86_64
key: macos-x86_64-${{ hashFiles('scripts/build_dependencies.sh') }}-${{ hashFiles('patches/**') }}
key: macos-x86_64-${{ hashFiles('scripts/build_dependencies.sh', 'scripts/fixup.sh') }}-${{ hashFiles('patches/**') }}
fail-on-cache-miss: true
- name: Pack Universal Sysroot
if: steps.cache-sysroot-universal.outputs.cache-matched-key == ''
@@ -192,7 +192,7 @@ jobs:
uses: actions/cache/restore@v4
with:
path: ./sysroot-${{ matrix.configuration.platform }}-${{ matrix.configuration.arch }}
key: ${{ matrix.configuration.platform }}-${{ matrix.configuration.arch }}-${{ hashFiles('scripts/build_dependencies.sh') }}-${{ hashFiles('patches/**') }}
key: ${{ matrix.configuration.platform }}-${{ matrix.configuration.arch }}-${{ hashFiles('scripts/build_dependencies.sh', 'scripts/fixup.sh') }}-${{ hashFiles('patches/**') }}
fail-on-cache-miss: true
- name: Setup Xcode
shell: bash
@@ -221,7 +221,7 @@ jobs:
uses: actions/cache/restore@v4
with:
path: ./sysroot-macOS-arm64_x86_64
key: macos-universal-${{ hashFiles('scripts/build_dependencies.sh', 'scripts/pack_dependencies.sh') }}-${{ hashFiles('patches/**') }}
key: macos-universal-${{ hashFiles('scripts/build_dependencies.sh', 'scripts/fixup.sh', 'scripts/pack_dependencies.sh') }}-${{ hashFiles('patches/**') }}
fail-on-cache-miss: true
- name: Setup Xcode
shell: bash
+8
View File
@@ -0,0 +1,8 @@
---
description: Publish a new UTM GitHub release — drafts release notes since the last release, asks for approval, then bumps Build.xcconfig, tags, and creates the release + Releases discussion.
---
Follow this UTM release workflow. Arguments (version x.y.z, then prerelease
yes|no; both optional): $ARGUMENTS
@.agents/skills/utm-release/SKILL.md
+8
View File
@@ -0,0 +1,8 @@
---
description: Build, run, and debug a UTM change against a throwaway test VM (also dependency rebuilds via scripts/fixup.sh). Run before /utm-review.
---
Follow this workflow. View every screenshot you capture: actually look at the
image. What to verify in this run (if any): $ARGUMENTS
@.agents/skills/utm-test/SKILL.md
+85 -33
View File
@@ -1,67 +1,119 @@
# Release Guide
This document details the release procedure for UTM team members. The release procedure is mostly automated by GitHub Actions. In short, when you submit a new "release" on the GitHub repository, the following happens:
This document details the release procedure for UTM team members. The release procedure is mostly automated by GitHub Actions. In short, when you create a new release on the GitHub repository, the following happens:
1. If UTM dependencies are not cached from a previous run, build it for {iOS,macOS,iOS-TCI,iOS Simulator} X {arm64,x86_64}.
2. Build UTM for all configurations and architectures.
3. Take the {iOS-arm64,iOS-TCI-arm64} build and package it into a fakesigned IPA and post it as a release asset.
4. Take the iOS-arm64 build and package it into a Cydia DEB and post it as a release asset.
5. Update the Cydia repository.
6. Update the AltStore repository.
7. Combine dependencies for macOS-arm64 and macOS-x86_64 into universal binaries.
8. Build UTM as a macOS universal binary.
9. Take the universal binary and sign and package it into a DMG.
10. Notarize the DMG with App Store Connect and post it as a release asset.
11. Take the universal binary and sign and package it for the Mac App Store.
12. Submit the signed package to App Store Connect.
1. If UTM dependencies are not cached from a previous run, build them for every platform (iOS, iOS TCI, iOS Simulator, visionOS, visionOS TCI, visionOS Simulator, macOS) and architecture.
2. Build UTM for every scheme (iOS, iOS SE, iOS Remote, macOS) on each platform and architecture.
3. Package the iOS and visionOS builds into fakesigned IPAs (`UTM.ipa`, `UTM-HV.ipa`, `UTM-SE.ipa`, `UTM-Remote.ipa`, and the `-visionOS` variants) and a jailbreak DEB (`UTM.deb`) and post them as release assets.
4. Dispatch updates to the AltStore and Cydia repositories.
5. Combine dependencies for macOS-arm64 and macOS-x86_64 into universal binaries and build UTM as a macOS universal binary.
6. Sign, package, and notarize the universal build into a DMG and post it as a release asset.
7. Sign and package the universal build for the Mac App Store and submit it to App Store Connect.
8. Sign UTM SE and UTM Remote (iOS and visionOS) and submit them to App Store Connect.
For more details see the [build.yml](../.github/workflows/build.yml) file.
## Making a release
Once you are ready to make a new release:
Agents can do all of this with the `/utm-release` skill ([`.agents/skills/utm-release/SKILL.md`](../.agents/skills/utm-release/SKILL.md)), which drafts the release notes and asks for approval before publishing. To do it by hand:
1. Modify [Build.xcconfig](../Build.xcconfig) and bump `MARKETING_VERSION` following [semantic versioning][5] and `CURRENT_PROJECT_VERSION` by 1.
2. Commit the change with the commit message `project: bumped version`. Tag the release with `git tag vx.y.z` corresponding to `MARKETING_VERSION`.
2. Commit only that change with the commit message `project: bumped version`. Tag the commit with `git tag vx.y.z` corresponding to `MARKETING_VERSION`.
3. Push the commit and tag.
4. In GitHub, draft a new release with the template below. Use the title `vx.y.z` or `vx.y.z (Beta)`. Select the tag you just created. Check "This is a pre-release" if it is a beta. Check "Create a discussion for this release" and the "Release" category.
5. When the release is published, the pipeline will run and do everything else.
4. In GitHub, draft a new release with the template below. Use the title `vx.y.z` or `vx.y.z (Beta)`. Select the tag you just created. Check "Set as a pre-release" if it is a beta. Check "Create a discussion for this release" and select the "Releases" category.
5. When the release is created, the pipeline will run and do everything else.
### Release notes
The app downloads the notes for its own version from the GitHub release and shows them as "What's New" the first time it launches, so the notes are parsed as well as read. See [UTMReleaseHelper.swift](../Platform/UTMReleaseHelper.swift) and [VMReleaseNotesView.swift](../Platform/Shared/VMReleaseNotesView.swift).
* Each `## ` heading starts a section. The **first** section is shown up front without its title, so it must be `## Highlights`. "Highlights" and "Installation" are skipped when the user taps "Show All"; every other section is shown.
* Every line is a `* ` bullet. Other lines (including `### ` headings) are shown as raw text, so do not use sub-headings. Inline Markdown (bold, links, code) is rendered.
* A bullet that starts with a parenthesized prefix, `* (prefix) text`, is shown only on matching builds, with the prefix stripped:
| Prefix | Shown in |
|--------|----------|
| starts with `macOS`, e.g. `(macOS)`, `(macOS 26)` | macOS |
| starts with `iOS`, e.g. `(iOS)`, `(iOS 17)`, `(iOS Remote)` (but not `(iOS SE)`) | all iOS and visionOS builds, including UTM SE and UTM Remote |
| `(iOS SE)` exactly | UTM SE only |
| starts with `visionOS` | visionOS builds only |
Any other parenthesized prefix, such as `(iPadOS)`, `(AVF)`, or `(iOS, macOS)`, hides the line on **every** platform. Put components after the platform instead: `* (macOS) Settings: ...`. GitHub still shows every line.
#### Versioning the notes
* **New minor or major version (`x.y.0`)**: start fresh notes from the template below.
* **Revision (`x.y.z`, z > 0)**: copy the full notes of the previous release in the same `x.y` series, then add or update highlights and notes, and add a new `## Changes (vx.y.z)` section above the previous ones. The changes for every release in the series stay in the notes, newest first.
#### Writing the notes
* **Highlights** are the first thing users see, so use them sparingly: major features or changes only, or a theme that several changes share. Write them for a general audience, in marketing terms rather than technical ones: `* **Feature name**: One or two sentences.` Put a platform prefix before the bold title if the feature is only on one platform.
* **Notes** are for anything users must know or do: changed behaviour, requirements, migration steps, recommendations.
* **Known Issues** list major problems in this release, with a workaround if there is one.
* **Changes** list one bullet per commit, or one per pull request if the commits came from a PR. If several commits make up one feature, write one bullet. Leave out changes users can't see (documentation, CI, agent tooling, refactoring). Format each bullet as:
`* (Platform) Component: What changed (#issue) (thanks @contributor)`
* `(Platform)` only if the change affects one platform, or one version of it (`(macOS 26)`); see the prefix table above.
* `Component:` when the change belongs to one area. Commonly used: Home, Config, Wizard, Settings, Toolbar, Scripting, utmctl, AVF, QEMU, SPICE, CocoaSpice, ANGLE, USB, Downloader, Localization.
* `(#issue)` is the issue the change fixes or resolves, not the PR number.
* `(thanks @login)` credits a PR or commit author who is not a UTM maintainer.
* Translation changes use the `Localization:` component and name each language in English, not by its code: `* Localization: Updated German (thanks @contributor)`, not `de`. Add the region or script in parentheses when it matters: `Chinese (Hong Kong, Simplified)`.
* Sort each `## Changes` section into groups:
1. Changes for every platform, then `(iOS…)` changes, then `(visionOS…)` changes, then `(macOS…)` changes. Keep a versioned prefix such as `(macOS 26)` next to the others for that platform.
2. Within each platform group, bullets with no component first, then one run of bullets per component, so every change for the same component on the same platform sits together.
### Release notes template
Make sure to copy all the changes for all beta releases after the last non-beta release.
`## Notes` and `## Known Issues` are optional; remove them if they are empty. Use the footer (from `## Issues` to the end) verbatim.
```
## Installation
Visit [https://getutm.app/install/][1] for the most up to date installation instructions.
## Highlights
* Key features
* Keep these bullets short
* **Feature name**: One or two sentences for a general audience.
* (macOS) **Platform-only feature**: Prefix the line if the feature is only on one platform.
## Notes
* List any important changes here
* Include anything that deviates significantly from previously defined behaviour
* List any important changes here.
* Include anything that deviates significantly from previously defined behaviour.
## Known Issues
* List any known major issues here.
## Changes (vx.y.1)
* Third change
* (iOS) iOS only change
* (iOS) Another iOS only change
* (macOS) A macOS only change
* A change on every platform (#1234)
* Component: A change in one component (thanks @contributor)
* Component: Another change in the same component
* Localization: Updated German (thanks @contributor)
* (iOS) An iOS-only change
* (macOS) A macOS-only change
* (macOS) Component: A macOS-only change in one component
## Changes (vx.y.0)
* One change
* Another change
## Issues
Please check the full list on [Github][2] and help report any bug you find that is not listed.
Please check the full list on [Github](https://github.com/utmapp/UTM/issues) and help report any bug you find that is not listed.
### Known Issues
## Installation
* Optional section. List any known major issues here in bullet points.
* [iOS](https://docs.getutm.app/installation/ios/)
* [macOS](https://docs.getutm.app/installation/macos/)
| File | Description | Installation | JIT | Hypervisor | USB |
|------|------------|--------------|-----|-----------|-----|
| UTM.deb | Jailbroken iOS version | Open in Cydia, dpkg, or Sileo | Yes | Yes(1) | Yes |
| UTM.dmg | macOS version | Mounting and copying UTM.app to /Applications | Yes | Yes | Yes |
| UTM.ipa | Non-jailbroken iOS version (sideloading) | AltStore, etc (see guide) | Yes(2) | No | No |
| UTM-HV.ipa | Non-jailbroken iOS version (TrollStore) | TrollStore | Yes | Yes(1) | Yes |
| UTM-SE.ipa | Non-jailbroken iOS version (sideloading) | AltStore, enterprise signing, etc | No | No | No |
| UTM-Remote.ipa | Remote client | Any | No | No | No |
1. Hypervisor on iOS requires an M1 iPad or newer.
2. Enabling JIT may require a separate JIT enabler such as [Jitterbug][2] or Jitstreamer.
[1]: https://getutm.app/install/
[2]: https://github.com/utmapp/UTM/issues
[2]: https://github.com/osy/Jitterbug
```
### Beta release
+4 -69
View File
@@ -919,80 +919,15 @@ build_d3d_drivers () {
fi
}
fixup_imports() {
_file=$1
_list=$(otool -L "$_file" | tail -n +2 | cut -d ' ' -f 1 | awk '{$1=$1};1')
for g in $_list
do
base=$(basename "$g")
basefilename=${base%.*}
libname=${basefilename#lib*}
dir=$(dirname "$g")
if [ "$dir" == "$PREFIX/lib" ] || [ "$dir" == "@rpath" ]; then
if [ "$PLATFORM" == "macos" ]; then
newname="@rpath/$libname.framework/Versions/A/$libname"
else
newname="@rpath/$libname.framework/$libname"
fi
install_name_tool -change "$g" "$newname" "$_file"
fi
done
}
fixup_dylib () {
FILE=$1
BASE=$(basename "$FILE")
BASEFILENAME=${BASE%.*}
LIBNAME=${BASEFILENAME#lib*}
BUNDLE_ID="com.utmapp.${LIBNAME//_/-}"
FRAMEWORKNAME="$LIBNAME.framework"
BASEFRAMEWORKPATH="$PREFIX/Frameworks/$FRAMEWORKNAME"
if [ "$PLATFORM" == "macos" ]; then
FRAMEWORKPATH="$BASEFRAMEWORKPATH/Versions/A"
INFOPATH="$FRAMEWORKPATH/Resources"
else
FRAMEWORKPATH="$BASEFRAMEWORKPATH"
INFOPATH="$FRAMEWORKPATH"
fi
NEWFILE="$FRAMEWORKPATH/$LIBNAME"
OLDIFS=$IFS
IFS=$'\n'
mkdir -p "$FRAMEWORKPATH"
mkdir -p "$INFOPATH"
cp -a "$FILE" "$NEWFILE"
MINOSVER=$(vtool -show-build-version "$FILE" 2>/dev/null | awk '/minos/ {print $2; exit}')
[ -n "$MINOSVER" ] || MINOSVER="$SDKMINVER"
/usr/libexec/PlistBuddy -c "Add :CFBundleExecutable string $LIBNAME" "$INFOPATH/Info.plist" || true
/usr/libexec/PlistBuddy -c "Add :CFBundleIdentifier string $BUNDLE_ID" "$INFOPATH/Info.plist" || true
/usr/libexec/PlistBuddy -c "Add :MinimumOSVersion string $MINOSVER" "$INFOPATH/Info.plist" || true
/usr/libexec/PlistBuddy -c "Add :CFBundleVersion string 1" "$INFOPATH/Info.plist" || true
/usr/libexec/PlistBuddy -c "Add :CFBundleShortVersionString string 1.0" "$INFOPATH/Info.plist" || true
if [ "$PLATFORM" == "macos" ]; then
ln -sf "A" "$BASEFRAMEWORKPATH/Versions/Current"
ln -sf "Versions/Current/Resources" "$BASEFRAMEWORKPATH/Resources"
ln -sf "Versions/Current/$LIBNAME" "$BASEFRAMEWORKPATH/$LIBNAME"
fi
newname="@rpath/$FRAMEWORKNAME/$LIBNAME"
install_name_tool -id "$newname" "$NEWFILE"
fixup_imports "$NEWFILE"
IFS=$OLDIFS
}
fixup_all () {
OLDIFS=$IFS
IFS=$'\n'
FILES=$(find "$SYSROOT_DIR/lib" -type f -maxdepth 1 -name "*.dylib")
for f in $FILES
do
echo "${GREEN}Fixing up $f...${NC}"
fixup_dylib $f
done
"$BASEDIR/fixup.sh" -p "$PLATFORM" -s "$PREFIX" -m "$SDKMINVER" $FILES
FILES=$(find "$SYSROOT_DIR/libexec" -type f -maxdepth 1 -perm +111)
for f in $FILES
do
echo "${GREEN}Fixing up $f...${NC}"
fixup_imports $f
done
if [ -n "$FILES" ]; then
"$BASEDIR/fixup.sh" -p "$PLATFORM" -s "$PREFIX" -i $FILES
fi
IFS=$OLDIFS
}
+150
View File
@@ -0,0 +1,150 @@
#!/bin/sh
#
# Copyright © 2026 Turing Software, LLC. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# Wraps dylibs from a sysroot's lib/ into the sysroot's Frameworks/ so they can
# be embedded in the app. Safe to re-run on a sysroot that was already fixed up,
# which lets a single rebuilt dependency be repackaged without a full rebuild.
set -e
GREEN='\033[0;32m'
NC='\033[0m'
usage () {
echo "Usage: $(basename $0) -p platform -s sysroot [-m minver] [-i] file..."
echo ""
echo " -p platform Target platform, as passed to build_dependencies.sh. Only 'macos' changes the layout."
echo " -s sysroot Sysroot to write Frameworks/ into and to resolve imports against."
echo " -m minver MinimumOSVersion to use when the binary does not record one."
echo " -i Only rewrite the imports of each file in place (for executables)."
echo ""
echo " Each file becomes <sysroot>/Frameworks/<name>.framework, where <name> is"
echo " the file name without the 'lib' prefix and the last extension."
exit 1
}
# rewrites imports of sysroot libraries to their frameworks
fixup_imports () {
_file=$1
_list=$(otool -L "$_file" | tail -n +2 | cut -d ' ' -f 1 | awk '{$1=$1};1')
for g in $_list
do
base=$(basename "$g")
basefilename=${base%.*}
libname=${basefilename#lib*}
dir=$(dirname "$g")
case "$g" in
/usr/lib/* | /System/* )
continue
;;
esac
# a sysroot staged from elsewhere (such as a CI artifact) records its
# own absolute path in install names, so also match by file name
if [ "$dir" == "$PREFIX/lib" ] || [ "$dir" == "@rpath" ] || [ -f "$PREFIX/lib/$base" ] || [ -d "$PREFIX/Frameworks/$libname.framework" ]; then
if [ "$PLATFORM" == "macos" ]; then
newname="@rpath/$libname.framework/Versions/A/$libname"
else
newname="@rpath/$libname.framework/$libname"
fi
install_name_tool -change "$g" "$newname" "$_file"
fi
done
}
plist_set () {
/usr/libexec/PlistBuddy -c "Set :$1 $3" "$4" 2>/dev/null || \
/usr/libexec/PlistBuddy -c "Add :$1 $2 $3" "$4"
}
fixup_dylib () {
FILE=$1
BASE=$(basename "$FILE")
BASEFILENAME=${BASE%.*}
LIBNAME=${BASEFILENAME#lib*}
BUNDLE_ID="com.utmapp.${LIBNAME//_/-}"
FRAMEWORKNAME="$LIBNAME.framework"
BASEFRAMEWORKPATH="$PREFIX/Frameworks/$FRAMEWORKNAME"
if [ "$PLATFORM" == "macos" ]; then
FRAMEWORKPATH="$BASEFRAMEWORKPATH/Versions/A"
INFOPATH="$FRAMEWORKPATH/Resources"
else
FRAMEWORKPATH="$BASEFRAMEWORKPATH"
INFOPATH="$FRAMEWORKPATH"
fi
NEWFILE="$FRAMEWORKPATH/$LIBNAME"
mkdir -p "$FRAMEWORKPATH"
mkdir -p "$INFOPATH"
cp -a "$FILE" "$NEWFILE"
MINOSVER=$(vtool -show-build-version "$FILE" 2>/dev/null | awk '/minos/ {print $2; exit}')
[ -n "$MINOSVER" ] || MINOSVER="$SDKMINVER"
if [ -z "$MINOSVER" ]; then
echo "$FILE does not record a minimum OS version, pass one with -m" >&2
exit 1
fi
plist_set CFBundleExecutable string "$LIBNAME" "$INFOPATH/Info.plist"
plist_set CFBundleIdentifier string "$BUNDLE_ID" "$INFOPATH/Info.plist"
plist_set MinimumOSVersion string "$MINOSVER" "$INFOPATH/Info.plist"
plist_set CFBundleVersion string 1 "$INFOPATH/Info.plist"
plist_set CFBundleShortVersionString string 1.0 "$INFOPATH/Info.plist"
if [ "$PLATFORM" == "macos" ]; then
# -h replaces an existing symlink instead of creating a link inside its target
ln -sfh "A" "$BASEFRAMEWORKPATH/Versions/Current"
ln -sfh "Versions/Current/Resources" "$BASEFRAMEWORKPATH/Resources"
ln -sfh "Versions/Current/$LIBNAME" "$BASEFRAMEWORKPATH/$LIBNAME"
fi
install_name_tool -id "@rpath/$FRAMEWORKNAME/$LIBNAME" "$NEWFILE"
fixup_imports "$NEWFILE"
}
PLATFORM=
PREFIX=
SDKMINVER=
IMPORTS_ONLY=
while getopts "p:s:m:i" opt; do
case $opt in
p )
PLATFORM=$(echo "$OPTARG" | tr '[:upper:]' '[:lower:]')
;;
s )
PREFIX=$(cd "$OPTARG" && pwd)
;;
m )
SDKMINVER="$OPTARG"
;;
i )
IMPORTS_ONLY=y
;;
* )
usage
;;
esac
done
shift $((OPTIND - 1))
if [ -z "$PLATFORM" ] || [ -z "$PREFIX" ] || [ $# -eq 0 ]; then
usage
fi
OLDIFS=$IFS
IFS=$'\n'
for f in "$@"
do
echo "${GREEN}Fixing up $f...${NC}"
if [ -z "$IMPORTS_ONLY" ]; then
fixup_dylib "$f"
else
fixup_imports "$f"
fi
done
IFS=$OLDIFS