9 min read

Running asbmutil in Ubuntu CI to Drive Apple School & Business Manager

Here’s something cool I’ve been using recently: talking to Apple School and Business Manager (ASBM) from a headless Linux job. No Mac in the loop, no Keychain, no clicking through the portal — just API calls from a cloud function that pull the fleet’s device-to-server assignments as JSON, flip them, and whatever else you need.

The tool is asbmutil, a CLI I wrote for the Apple School and Business Manager API. It’s a Swift program — SwiftUI app, credentials in the macOS Keychain, signed and notarized through Apple’s toolchain. You’d assume all that pins it to a Mac. Nope.

Every release ships a static Linux binary as a first-class sibling to the macOS build. The Mac binary and its SwiftUI app matter just as much — that’s how you use asbmutil locally. The Linux sibling exists for the other half of the job: headless automation — an Azure Function, an AWS Lambda, a CI job, anything that runs Linux with no one watching. This post is about that Linux side — how I run it in Ubuntu, what the moving parts are, and the things it’s good for.

Copy-paste-able CI samples

The whole pattern is in the repo as runnable samples, so you can lift it straight into your own CI: examples/ci/.

  • setup-asbmutil.sh — downloads the latest Linux release and loads credentials from three env vars (steps 1 and 2 below).
  • asbmutil-report.yml — a read-only GitHub Actions workflow that pulls the ASBM picture on a daily schedule and uploads it as an artifact: a standing “is ASBM drifting from my inventory?” heartbeat.
  • asbmutil-assign.yml — reassigns a set of serials to a target MDM server: manual-trigger, dry-run by default, showing the current assignment before it changes anything.

We run the real thing in Azure DevOps Pipelines, but the steps are identical on any runner — the GitHub Actions files are just the most copy-pasteable form. The rest of this post is the walkthrough behind them.

How the Linux build works

Swift cross-compiles to Linux cleanly, and the whole build comes down to one flag:

swift build -c release --product asbmutil --static-swift-stdlib

--static-swift-stdlib folds the Swift runtime into the binary, so the result is a single self-contained asbmutil that runs on a bare Ubuntu image with no toolchain installed. The release workflow builds one on every tag and attaches it next to the signed macOS build as asbmutil-<version>-linux-amd64.tar.gz.

The only genuinely Mac-specific thing in the whole program is credential storage, and that’s behind a compile-time fork. When the Security framework isn’t importable — i.e. you’re not on Apple’s platforms — it swaps the Keychain for a file store:

#if !canImport(Security)
// File-based credential storage for Linux (no macOS Keychain available).
// Credentials are stored as JSON files in ~/.config/asbmutil/ with 0600 permissions.

That fork is the only Linux-specific line in the whole thing. Everything else is plain Swift that compiles as-is, so from here it’s just three steps.

Step 1 — get the binary into CI

The setup script asks the GitHub API for the latest release, grabs the asset whose name contains linux, and unpacks it onto PATH:

asset_url=$(curl -fsSL https://api.github.com/repos/rodchristiansen/asbmutil/releases/latest \
  | python3 -c "import sys,json; a=json.load(sys.stdin)['assets']; print(next(x['browser_download_url'] for x in a if 'linux' in x['name']))")
curl -fsSL -o asbmutil.tar.gz "$asset_url"
tar xzf asbmutil.tar.gz -C bin
chmod +x bin/asbmutil

That’s it — no apt install, no Swift on the runner. On our actual deploy pipeline it rides into the function zip alongside mdmctl (for the MicroMDM side), two little binaries in the same bin/ folder.

Step 2 — credentials without a Keychain

This is the step that bites you, and it bit me for an afternoon. On macOS you’d run asbmutil config set once and forget it; the secrets go in the Keychain. On Linux there’s no Keychain, so the file store expects its JSON in ~/.config/asbmutil/. Fine — except the store has to find that directory, and on Linux with musl, FileManager.homeDirectoryForCurrentUser calls getpwuid(), which returns the passwd home (for a service account that’s often /nonexistent) and flatly ignores $HOME. So the store reads $HOME explicitly first:

let homePath = ProcessInfo.processInfo.environment["HOME"]
    ?? FileManager.default.homeDirectoryForCurrentUser.path

The practical upshot: make sure your CI job has HOME set (GitHub and Azure runners do by default), and then the cleanest way to load credentials is to let the tool write its own store. Keep the ASBM client id, key id, and PEM private key as CI secrets, write the PEM to a temp file, and run config set:

printf '%s' "$ASBM_PRIVATE_KEY_PEM" > key.pem
asbmutil config set --client-id "$ASBM_CLIENT_ID" --key-id "$ASBM_KEY_ID" --pem-path key.pem
rm key.pem

No secrets in the repo, nothing persisted on the runner beyond the job. (In our production path — a Python Azure Function — I skip config set and lay the store’s JSON files down directly from app settings, 0600, because the function is already handling secrets from Key Vault. Both roads end at the same ~/.config/asbmutil/.)

Step 3 — script the commands

What makes asbmutil pleasant to drive from any script is a boring discipline: clean JSON on stdout, every diagnostic on stderr. Piping into jq or json.loads never breaks on a progress line. A handful of read-only commands cover most of what you’d want to watch — every MDM server and its id, the devices grouped by server, which server a given set of serials is on, and full per-device attributes (including AppleCare and assigned MDM):

asbmutil list-mdm-servers
asbmutil list-devices-servers --all
asbmutil list-devices-servers --serials A,B,C
asbmutil get-devices-info --serials A,B,C

list-devices-servers is the one worth calling out. The naive “which MDM is this Mac on?” is one API call per device; for a few thousand Macs that’s a rate-limit headache. This inverts it — it lists each server’s devices (four or five calls total, regardless of fleet size) and intersects against the serials you asked about.

The worked example: our MDM migration reconciler

Stack those three steps together and you can automate real work. We’re in the middle of an MDM migration right now, and it’s a good example.

I wrote a reconciler to keep the migration on track — a blob-triggered Azure Function. It reads the desired server (old vs. new) for each serial from an inventory CSV (we use Snipe), asks ASBM what’s true right now, diffs the two, and fixes the gaps. That’s four asbmutil verbs:

subprocess.run([ASBMUTIL, "list-devices-servers", "--serials", serials], ...)
subprocess.run([ASBMUTIL, "unassign", "--serials", movers, "--mdm", old_server], ...)
subprocess.run([ASBMUTIL, "assign",   "--serials", movers, "--mdm", new_server], ...)
subprocess.run([ASBMUTIL, "batch-status", activity_id, "--poll", "--interval", "10", ...], ...)

We change a device’s MDM server in our inventory, and the function makes Apple School Manager match.

Read current state, then — for the devices the diff says should move — unassign them from the old server and assign them to the new one, grouped by server so each side is one batch. The server names (old_server, new_server) come straight out of the diff, not hardcoded. Then poll the async batch to completion — one process, one OAuth token, instead of reimplementing the poll loop in Python.

Two things make this safe to run unattended. First, the diff is a four-way comparison — previous ASBM state vs current, previous inventory vs current — so it can tell who moved a device. Inventory changed and ASBM didn’t? Migrate. ASBM changed on its own (an admin did something in the portal)? Leave it, report drift. Both changed and disagree? Conflict — don’t touch it, flag a human. An automated reconciler that blindly enforces one side will happily undo a deliberate manual change every five minutes; the four-way diff is what stops that.

Second, every mutating call is wrapped so a DRY_RUN env flag turns it into a log line — so a full migration run can be rehearsed against real ASBM state without touching a single device.

Don’t take the API’s word for it

Two things about Apple’s API will burn an unattended job if you let them.

First, the activity endpoint accepts any serial you send it. Pass one that isn’t in your ASBM account — a reseller filed a shipment notice but never registered the device, or someone fat-fingered it — and the activity still comes back IN_PROGRESS, then COMPLETED, having done nothing. So assign and unassign pre-flight every serial against GET /v1/orgDevices/{id} and drop the 404s before submitting (pass --skip-verify to opt out).

Second, COMPLETED doesn’t mean the device landed on the server you asked for. --confirm closes that gap: after the batch finishes it re-queries each device’s assigned server, checks it against the intended end state, and exits non-zero if anything’s off — so the function can treat a partial run as the failure it is.

And if you want proof from Apple’s own records rather than your logs, the 2.0 API exposes an org audit log that audit-events queries by time range and type — pulling DEVICE_ASSIGNED_TO_SERVER for the migration window gives you an independent record straight from Apple’s side. The catch: this one is Business Manager only. School Manager’s API is still at 1.5 and doesn’t expose the endpoint, so as a university on ASM I can’t lean on it yet — the tool fails fast with a clear message instead of a raw 404, and on the School side I fall back to --confirm and a re-query of assigned servers. On Business Manager it looks like this:

asbmutil audit-events --start 2026-07-01T00:00:00Z --end 2026-07-14T23:59:59Z --type DEVICE_ASSIGNED_TO_SERVER

What else it’s good for

A clean, scriptable window into ASBM is useful for a lot that has nothing to do with switching MDMs:

  • A nightly ASBM ↔ inventory drift reportlist-devices-servers --all diffed against your source of truth answers “what’s in ASBM that inventory doesn’t know about, and vice versa.” Catches devices bought but never enrolled.
  • Fleet-wide AppleCare coveragelist-devices --include-applecare fans out per-device coverage lookups (Apple has no bulk endpoint, so it does a parallel pass plus a sequential retry for the ones Apple’s HTTP/2 endpoint drops). Feed that to a dashboard and see what’s about to fall out of warranty before a lab tech does.
  • MAC addresses into inventory — recent API versions expose Wi-Fi, Bluetooth, and Ethernet MACs per device via get-devices-info, handy for network access control.
  • Multi-tenant management — the profile system lets one binary drive several ASBM instances (--profile school-east, --profile business-unit-3). Manage more than one org and you’re passing a flag, not juggling logins — which is exactly what you want in a CI job.
  • Bulk pre-staging — assign a cart of freshly-bought Macs to the right MDM server before they’re unboxed, straight from a CSV off the purchase order, so they enroll correctly on first boot with zero touch.
  • Resumable audits of huge fleetslist-devices --resume checkpoints after each page, so a pull across tens of thousands of devices survives a transient timeout instead of starting over.

It’s pretty cool to have Apple School and Business Manager scriptable. Every device-to-server assignment, every server’s roster, whole-fleet AppleCare, MAC addresses, audit history — all of it is JSON you can pipe, diff, schedule, and act on. Point a cloud function or a CI job at it and let reconciliations run on their own. This used to mean clicking through a web portal one device at a time. Scripting it instead still feels a little bit magic.

asbmutil is open source and MIT, and the CI samples are ready to lift — if you run a fleet through ASBM, go automate the boring parts. PRs welcome.