Eight Copies That Each Lost Something
Eight apps in the family ship phone builds to the store's beta channel, and each had its own release script, copied from whichever app had one first. On a census, the copies were not diverging the way modules diverge, with two authors making two reasonable choices. They were each missing something, and each was missing something different.
- Two archived through a pipe into a filter that always succeeds, which threw away the build tool's own exit status. A failed archive could continue to the upload step.
- One had no clean-tree guard, no check that the commit had not moved during the build, and no check that the exported package existed.
- One never bumped its build number, so its next upload would have been refused by the store.
- Two bumped the build number and left the change uncommitted, which their own next run then refused.
- Only two refused to run on a locked screen. Only three validated their arguments.
A copied program decays by losing guards. Each copy was written by someone solving that app's release that day, and the guard that bit someone else last month was not on their mind. Nothing fails when a guard is missing, until the exact day it would have caught something.
One Program, Facts in a Config, Differences in Named Hooks
The kit took the release program in as a single vendored script — one program, not a template rendered per app. Every guard any copy had now runs for every app, in one order: strict arguments, the signing key or a locked-screen refusal, a dedicated simulator, committed inputs, the clean-kit gate from track 3, the app's own guard and icon check, the test suite, the build-number bump, the archive on the build tool's own status with the log kept, a check that the commit and the inputs did not move during the build, the archive gate, the export judged by its real status, the package present, the bump committed and pushed, and a result line other tools can read.
What genuinely differs between apps went into two places. The facts — names, paths, which directories count for the clean-tree check, where the build number and the marketing version come from — live in a per-app config file. The behavior that genuinely differs lives in five named hooks: an app-specific guard, the test step, the archive step, a step after the archive, and what to do when export fails. An app that needs nothing special leaves the hooks at their defaults. Nothing else is overridable, which is the point: the order of the guards is not an app's decision.
Prove It Against Broken Copies
Before any app adopted it, a self-test ran the real program end to end on a fixture repository: stubbed build tools, real git, a bare remote to push to, and the real gate. It then ran deliberately broken variants and watched ten load-bearing guards fail. One of them — the swallowed archive status — needed a dedicated scenario with a partial archive before the test could catch it at all, which is exactly why that guard had been lost in two copies. A contract test in the kit holds the vendored pieces together: every app that has the program also has its config and its gate.
Each app then adopted it with one commit, and a dry-run mode resolved and checked every fact for every app without building anything.
The Same Shape, Elsewhere
The following week a stabilization review swept the family for the same pattern and lifted several more pieces out of their copies: an atomic file writer whose lagging copies lost 240 of 320 concurrent writes in a reproduction, a service-reload helper that waits until the old job is truly gone, an icon compiler two native apps had each carried, and a drift helper every Swift suite now calls. None of these is about what an app does. Each is a program or routine whose copies had quietly lost a guard.