All Articles
CategoryTools
Reading Time
8 min read
Published
2026-10-15
Word Count
1,544words

Grab a coffee — this one is a deep dive!

podexit: which pods stop getting updates when CocoaPods trunk goes read-only

Summary

A Node.js CLI that reads Podfile.lock and shows which pods freeze on December 2, 2026, which Firebase caps earlier, and which already ship a Swift package.

  • CocoaPods trunk goes read-only on December 2, 2026, after a trial run of read-only mode on November 1–7.
  • 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.
  • Exit code 0 means a report was written; podexit reports, and does not fail a build because of what it finds.
  • 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.
podexit: which pods stop getting updates when CocoaPods trunk goes read-only

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:

sh
1npx @mcamdali/podexit

Without 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 -->

js
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 schemaVersion 0 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

#cocoapods#swift-package-manager#flutter#react-native#cli#typescript
Muhittin Çamdalı

Muhittin Çamdalı

Senior Mobile Engineer

Senior Mobile Engineer with 12+ years of experience. I build mobile products with iOS, Flutter and React Native, working full-stack and AI-native.

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

Share