How do you let a model help with a release without handing it the levers that cannot be pulled back? A pushed tag is fetched by a consumer and is gone for good. A stray file swept into a release commit is published before anyone reads it. adrelease keeps those levers inside deterministic Swift code and gives the model only two opinions to form. The engine decides what happens and in what order. The model decides two things, returns them as values, and is never in the room when git runs.
Releasing one Swift package across a graph of packages that depend on it is mostly mechanical work, with two points where a real judgement is needed: how large is this version bump, and how to mend the downstream code the bump just broke. adrelease is a release engine built around that split. A deterministic core owns the order and timing of every irreversible action (committing, tagging, pushing, and opening the pull request), along with the version resolution that feeds them, and the design admits a language model at exactly those two judgement points and nowhere else. The model proposes; the engine disposes. The model returns plain data, never a command, so it cannot tag, push, or commit even by accident. This piece is honest about the price. The payoff only lands if you actually run a modular, frequently changing package graph, and in the first shipping version the downstream repair step concedes rather than fixes, so a major break halts the cascade for a person instead of mending it.
The design started with a failure. A parallel automation distributing a lint hook to 13 repositories committed a dirty index into a pull request, because git commit commits the whole index, not just what was staged. That was mechanical hygiene gone wrong, not a reasoning mistake, and the distinction decides the whole architecture: the failure-prone parts have to be real code with assertions, not prose a model is trusted to follow.
Releasing a foundational package by hand has the same shape, scaled up. You walk the cascade in dependency order, editing each manifest, resolving, testing, tagging, and opening a pull request for every consumer, holding the graph in your head as you go. One common shape is a diamond: a package that depends on two others, each of which depends on a shared fourth package. Bump that shared package and the one at the top of the diamond must not tag until both of the packages beneath it have settled first. A flat per-repository loop gets exactly this case wrong.
There are two conventional ways out, and both leak. The first is “be careful”: a checklist that a human, or a model, follows step by step. One slip and a broken or dirty release is published. The second is to hand the whole job to an agent with a shell. Now a model holds git, tag, and push, and the irreversible levers are one confident-but-wrong command away from being pulled. Neither option treats the dangerous parts as dangerous.
So the work divides cleanly. Two things are genuine judgements a script cannot make: what the semver level should be, and how to fix downstream code against a changed API. Everything else is mechanics that belong in code with real exit codes and real assertions. This piece is about drawing that line and enforcing it structurally, the same stance the studio takes in its other engineering case studies, including the deep-linking series: put the cleverness where it is testable, and make the dangerous failures impossible rather than carefully avoided.
In a single run, adrelease released three packages from the studio’s managed graph. All three were tagged in about a hundred seconds, one after another in dependency order; their release pull requests merged a few minutes later.
The release/<package>-<version> branch-and-title naming is the engine’s own pull-request signature, distinct from a hand-authored chore(release): cut X commit, so each release is identifiable as the engine’s work rather than taken on trust.
The live run is not the only check. A hermetic integration test drives the real cascade executor over a two-package dependency chain with file:// git origins, faking only the pull-request opener and the version planner, so the ordering and propagation guarantees are exercised on every build, not just on release day.
One honest detail belongs here rather than buried later. In that same run, two further packages had to be released by hand, because the verifier failed on packages that ship no tests. That limitation has since been fixed, and the cost section below names what it cost.
adrelease is built from three targets, with a fourth holding the tests, and the split between the three is the whole point.
The engine, AdReleaseKit, imports nothing at all. No Foundation, no Process, no git. A search for import statements across the module returns zero results. It is pure orchestration, and because it cannot shell out, it cannot perform an irreversible action even if a bug told it to.
The I/O layer, AdReleaseLive, is the only place a subprocess runs, through a single primitive (Subprocess.run). Every git verb, every swift package call, and the gh call funnel through it. Tagging happens here, in LiveGitGateway, and only here.
A thin adrelease command-line tool sits on top of the two.
The engine builds the internal dependency graph, finds the affected set with a breadth-first reverse-reachability walk seeded by the changed packages (AffectedSet), topo-sorts it, and drives each node through a fixed order (NodeReleaseSequencer): provision a clean worktree from origin/main, assert it is clean, stage and propagate, verify, commit with an explicit pathspec, tag, push the tag, push the branch, open the pull request. That order is fixed and atomic within one call. The engine owns the order and the guarantee that nothing downstream is tagged out of turn; the live seams own the actual git.
The dirty-index guard, the failure that started all of this, lives here as a blocking precondition rather than an afterthought. A fresh worktree is asserted clean before any edit, and the commit stages an explicit set of paths whose staged set must equal the intended set exactly, or it aborts (SafeCommitter, CommitGuard). The stray file that was swept into a pull request the first time would now show up in the staged set, fail the assertion, and block the commit.
Then the model. The architecture admits it at exactly two seams, both pure Swift protocols that take and return data values. In the first shipping version one seam is wired to a live model and the other to a deterministic stub:
SemverDecider), the live seam today. The model proposes a level and the engine floors it. The final level is max(deterministic floor, proposed level), where a reported API break sets the floor to major (SemverModel, SemverFloor). The model can raise above the floor but can never sink below it, so a breaking change cannot ship as a patch on a bad model’s say-so.CompileRepairer), the one step the code annotates as the only model step. After a post-bump verify fails, this seam is where a model would propose exact-string edits for the engine to apply and re-verify. In the first shipping version it ships a conceding stub rather than a live model (LiveRepair defaults to StubCompileRepairer), so today a break is not mended. It halts the cascade and hands back to a person. The seam exists; v1 wires it to a stand-in, not a model. The cost section says what that costs.Both seams return only data, so the model is never handed a GitGateway or a subprocess. Git is impossible to call from where the model sits. As belt-and-braces, the driver also instructs the agent never to type git itself, but that instruction is moot, because the capability is never given in the first place. The actual call out to a language model lives in the driver skill, not in the engine; the engine ships the protocols plus deterministic stand-ins.
The same structure decides what happens when a repair is attempted. The loop returns “repaired” only when a re-verify passes. Any other outcome restores the worktree and returns before the tag step, and the executor then stops and attempts nothing downstream. The model cannot talk the engine into tagging a package that no longer compiles.
The engine owns every verb that changes the world. The model owns two opinions, and returns them as data.
Nothing here is free. The safety comes from moving judgement to two narrow seams and refusing it everywhere else, and it is worth being precise about where the bill lands.
The payoff is conditional. The value is proportional to how modular and how often-changing the package graph is. Run a genuinely modular, frequently released graph and the cost of a shared release drops to near zero. Leave the packages mostly still and adrelease is well-built insurance sitting dormant.
In the first shipping version the repair step concedes rather than fixes. The downstream compile-repairer is a stub, so a major bump that breaks a consumer halts the cascade and hands back to a person. The halt-without-tagging guarantee is real and proven in code. The mend-and-continue story is designed but not yet wired.
The semver floor is only as sharp as the breakage report it rests on. The floor is raised by what swift package diagnose-api-breaking-changes reports; an inconclusive run does not raise it, so a break the tool misses can still ship as a patch. The guarantee holds strictly for a reported break.
“Every downstream consumer” means every consumer in the managed graph. The affected-set walk is complete over the packages named in the configuration. A consumer outside that set is invisible to it, so keeping the configuration honest as the graph grows is its own standing task.
The first real run still needed two manual releases. Packages with no tests failed the verifier and were released by hand. That specific limitation is fixed now, but it is a fair measure of how much of this is settled and how much is still hardening.
The interesting part was never the release tool. It is the shape. A deterministic core that owns every irreversible action, with a model invited in at exactly the points where a judgement is genuinely needed and kept out everywhere else, is a pattern that reuses well beyond cutting package versions. The release engine is simply where the studio needed it first, and where it had demonstrably run.
That stance runs through the studio’s other engineering writing too. The deep-linking series makes the same move on a different problem: keep the clever decision pure and testable, and let the structure, not care, rule out the bad outcome. The shared idea is plain enough to state once. Put the model where the judgement is. Keep it away from the levers that cannot be pulled back.