Table of Contents
The problem
CocoaPods trunk goes read-only on December 2, 2026, after a trial run of read-only mode on
November 1–7. Most of what gets said about it is louder than the change itself. Builds keep
working: the Specs repository and the CDN stay online, and pod install keeps resolving every
version that was already published. What stops is narrower and easier to miss. A pod that is
published on trunk will never get another version there, security fixes included, unless its
maintainers ship it some other way, usually as a Swift package.
Firebase moves earlier. Its migration page says version 12 is the last major on CocoaPods and
that it stops publishing there in October 2026, and the stop covers the listed pods "and their
subdependencies".
So a team with a Podfile.lock has two questions per dependency: does this pod stop getting
updates, and when? And is there already a Swift package I can move to? The answers differ for a
native iOS app, a Flutter app or plugin, and a React Native or Expo app, because in the last
two most pods come from local paths (.symlinks/plugins, node_modules) and update through pub
or npm, not trunk.
podexit answers those two questions from the lockfile the project already has. It installs
nothing into the project and changes no files; it writes only its cache and the report you ask
for.
Design decisions and the alternatives
Podfile.lock, not Podfile. The Podfile says what you asked for; the lockfile says what
CocoaPods resolved: exact versions, subspecs, the dependency graph and, for every pod, where it
came from. That last part decides the answer. A pod from a local path or a git source is not
affected by trunk going read-only; a pod from trunk is. parseLockfile folds subspecs such as
Firebase/Analytics into their root pod and keeps the source of each one.
Node.js and TypeScript instead of a CocoaPods plugin. A Ruby plugin would have to be added to
the project and would run inside pod install. The people who need this answer include Flutter
and React Native developers, who already have Node.js, and npx runs the tool without adding
anything to the project. The cost is that a native-only iOS developer may not have Node.js; the
Homebrew formula in muhittincamdali/tap covers that case by installing the release tarball.
Two answers per pod, kept separate. Update status is one of frozen-on-trunk, capped,
not-affected or unknown. Swift package status is one of spm-available, no-spm, unknown
or not-checked. Mixing them into a single score would hide the useful case: a pod that is
frozen but can be moved today.
Publisher caps as sourced data, not code. The Firebase cap lives in
data/publisher-policy.json: 20 listed pods, last major 12, publishing stopped in 2026-10, and
an includesSubdependencies flag. Each entry cites its source, a zod schema validates the file,
and a weekly workflow (data.yml) checks that the cited sources still answer. The data has its
own dataVersion, shown in the Markdown and JSON reports.
Package.swift through raw.githubusercontent.com. The upstream check is a HEAD request for
Package.swift, first on the default branch and then, when the podspec names a tag, at the
locked tag. Going through the raw host keeps it out of the REST API's quota for unauthenticated
requests, and a HEAD request downloads no body. GITHUB_TOKEN or GH_TOKEN is sent only to
GitHub hosts, for private upstream repositories. Checking the locked tag matters: if the package
exists only in newer versions, the report says so instead of a plain "yes" that would suggest
the move costs nothing (analyze.test.ts: "flags a package that exists only after the locked
version").
One fetcher, three uses. Everything from the network goes through a Fetcher.
CachedFetcher stores each answer as a JSON file keyed by a hash of method and URL, keeps 404s
(a missing Package.swift is a real answer) and does not keep 5xx or 429. It writes to a
temporary file and renames it, so two runs never read half a file. Behind an OfflineFetcher,
the same files serve --offline, the tests and the terminal demos. The five fixtures are real
native, Flutter app, Flutter plugin, Expo and React Native projects resolved by CocoaPods
1.16.2, with the trunk and GitHub answers recorded on 14 October 2026.
src/core/ is pure (parsing, version comparison, classification, no I/O), and a missing answer
becomes unknown with a reason rather than an error.
A bug, and what it taught
Before 0.1.0 shipped, the first implementation capped only the 20 pods Firebase lists. In the native fixture that left
FirebaseCoreInternal, FirebaseSessions, GoogleUtilities and nanopb showing "frozen after
Dec 2", although Firebase stops publishing them in October. Commit bf0e1ba (PR #7) fixed it:
classifyLockfile now classifies every pod on its own, then walks the dependency graph
breadth-first from each listed capped pod and caps the trunk pods it reaches. Breadth-first is
deliberate, so the via field names the nearest listed pod. Pods from local paths, git or
private specs are left alone, and a capped subdependency is labelled "capped with Firebase"
rather than "capped at 12.x", since GoogleUtilities has its own version line. On the native
fixture the result is 28 pods: 15 capped, 11 frozen, 2 not affected.
The more instructive lesson came after the 0.1.0 release. The input is untrusted: a
Podfile.lock can arrive in a pull request, and YAML's double-quoted escapes let it carry
control characters. The terminal reporter printed pod names, versions and paths as they came,
so an entry such as "Evil\e[31mPod (1.0.0)" sent a raw escape sequence to the terminal. The fix
landed in four steps, each with its own test:
e5a86c3(PR #19): the terminal and Markdown reports replace C0, DEL and C1 controls with
U+FFFD, so tampering stays visible instead of acting on the screen.
c2db6a9(PR #22): the JSON report too. The first fix had skipped JSON on the assumption that
JSON.stringify escapes control characters, but it only escapes U+0000 to U+001F; DEL and C1
controls such as U+009B (8-bit CSI) passed through.
316e164(PR #22): messages on stderr. A lockfile entry quoted in "Unexpected PODS entry" could carry
an OSC 52 clipboard write or a screen clear, and so could a folder name in the "Checking" line.
b06bb49(PR #22): the Markdown report, meant for pull request comments, escaped only|. A backtick
in a pod name could close its code span and add images, links or @mentions. Values now go into
code spans whose fence is longer than any backtick run inside them, and repository URLs are
percent-encoded.
The first fix came after the v0.1.0 tag, so the 0.1.0 tarball does not contain it; 0.1.1 ships
all four. The takeaway is about assumptions: "the serializer already escapes this" was true for
one range and false for another, and only a test with a C1 character showed it.
Release 0.1.2 changed no behaviour; only the package name in the help text and user agent moved. npm rejected the unscoped name as too similar to an existing
package, so the package is published as @mcamdali/podexit; the command is still podexit
(24d4f1b).
Usage
From the project folder:
1npx @mcamdali/podexitWithout paths, podexit reads every lockfile it finds at Podfile.lock, ios/Podfile.lock,
macos/Podfile.lock, example/ios/Podfile.lock and example/macos/Podfile.lock. `--format
markdown produces a report for a pull request, and --format json` gives scripts each pod's
updates.status, swiftPackage.status, the latest version on trunk and a sentence explaining
each answer. Exit code 0 means a report was written; podexit reports, and does not fail a
build because of what it finds.
The CLI is a thin layer over a programmatic API. This is the README example:
<!-- prettier-ignore -->
1import {2 analyzeLockfile,3 buildReport,4 CachedFetcher,5 HttpFetcher,6 loadPublisherPolicy,7 render,8} from '@mcamdali/podexit';9 10const policy = loadPublisherPolicy();11const fetcher = new CachedFetcher(new HttpFetcher({ userAgent: 'my-tool' }), {12 directory: '.cache',13});14const lockfile = await analyzeLockfile(text, 'ios/Podfile.lock', { fetcher, policy });15console.log(render(buildReport([lockfile], { policy, offline: false }), 'markdown'));A runnable version is examples/api/summary.mjs, which reads the five fixtures offline and is
run by the test suite. examples/github-actions/podexit.yml adds the report to the job summary
of every pull request that changes a Podfile.lock.
Limits
- The JSON layout is
schemaVersion0 and may change before 1.0. - podexit does not say which npm or pub.dev package brings a pod (planned for 0.3), and it does
not map pods to Swift package product names or versions (planned for 0.5). A Package.swift
in a repository does not prove it offers the product you need.
- A pod whose podspec points to a host other than GitHub, such as a binary download, is
unknown.
- The publisher policy is maintained by hand. The weekly check catches dead sources, not new
pods a publisher adds.
- It reports; it does not migrate. There is no
--fail-on, SARIF output or GitHub Action yet. - The tests use five fixtures recorded on one day. CI runs on Linux (Node.js 22 and 24); Windows
is not tested.
The repository was opened on 14 October 2026; 0.1.0 shipped the same day, and 0.1.1 and 0.1.2
followed on 15 October.
Source code: github.com/muhittincamdali/podexit
Tags
iOS Development News
Weekly Swift tips, SwiftUI tricks and iOS best practices. No spam, only valuable content.
Your subscription starts when you open the link in the confirmation email and press “Confirm my subscription”. The newsletter keeps open/click statistics; you can unsubscribe anytime with one click. Privacy

