Skip to content

Preserve the framework symlinks in the published XCFramework - #55

Merged
LaStrada merged 5 commits into
mainfrom
fix/preserve-framework-symlinks
Sep 7, 2026
Merged

LaStrada merged 5 commits into
mainfrom
fix/preserve-framework-symlinks

Conversation

@LaStrada

@LaStrada LaStrada commented Sep 2, 2026 •

Copy link
Copy Markdown
Member

Description ✏️

The published XCFramework's macOS slice was malformed. KMMBridge archives it with Gradle's Zip
task, and Gradle archive tasks resolve symlinks into copies. A macOS framework is a versioned
bundle — Versions/A/ holds the payload and Versions/Current plus the top-level KmpLog,
Headers, Modules and Resources are symlinks into it — so all five were replaced by real
copies. The archive carried the binary three times, and consumers unpacked a Versions/Current
directory where a symlink belongs, giving Couldn't resolve framework symlink … Invalid argument (22) on a macOS build. The same malformed layout blocked App Store archiving in
firebase/firebase-ios-sdk#12668, so it is worth more than the warning it currently shows.

Kotlin/Native's own output is correct — all five symlinks are there — so only the archiving step is
at fault. It is now repacked with ditto, which preserves them:

before after
archive 17,609,711 B 12,355,078 B
unpacked macOS slice 29 MB 9.8 MB
symlinks 0 5
copies of the binary 3 1

Only the macOS slice changes; iOS frameworks are flat and have no Versions/ hierarchy.

ditto needs --norsrc --noextattr: without them it stores extended attributes as AppleDouble
sidecars, which land inside the bundle as ._Headers and friends — exactly the stray files
codesign refuses in a framework. Gradle's Zip never produced those, so the repack would have
traded one signing problem for another. Building a consumer against the archive rather than
against the framework directory is what surfaced it.

The repack has to hook KMMBridge's task by name, because the task is registered in afterEvaluate
and a hard tasks.named reference fails in every ordinary build. That would fail silently if the
task were ever renamed, so a task-graph assertion fails the publish instead, and a CI step checks
the archive's layout on every PR — neither existed before, which is how this shipped in the first
place. Releases up to 0.3.1 stay malformed; the fix applies from the next publish.

Screenshots / Recordings 📷

How to Test 🐛

./gradlew zipXCFramework -PENABLE_PUBLISHING=true -PGITHUB_PUBLISH_TOKEN=unused -PGITHUB_REPO=Airthings/KmpLog
ditto -x -k build/kmmbridge/zip/frameworkarchive.zip /tmp/out
ls -l /tmp/out/KmpLog.xcframework/macos-arm64_x86_64/KmpLog.framework

All five entries should be symlinks. The new CI step does exactly this and was verified to fail
both when the repack is disabled and when the links are present but dangling.

References 🔗

KMMBridge archives the XCFramework with Gradle's Zip task, which resolves
symlinks into copies. A macOS framework is a versioned bundle, so that
flattening replaced Versions/Current and every top-level entry with real copies
of Versions/A: the binary shipped three times, and consumers unpacked a bundle
whose Versions/Current was a directory where a symlink belongs. Build tools that
resolve that link report it as a failed readlink, and codesign can reject the
layout outright.

Repacking the archive with ditto keeps the links. Measured on the current
sources: the archive drops from 17,609,711 to 12,355,078 bytes, the unpacked
macOS slice from 29 MB to 9.8 MB, and the five symlinks survive extraction.
Only the macOS slice is affected; iOS frameworks are flat and unchanged.
The repack hooks KMMBridge's archive task by name, because that task only exists
when publishing is enabled and a hard task reference would break every ordinary
build. The cost is that a rename upstream would match nothing: no error, no
warning, and a flattened archive published again.

Assert instead that a publish never runs without the archive task in the graph.
Nothing exercised the packaging path. `gradlew build` skips the XCFramework, and
the archive is only produced by the manual publish workflow, so a malformed one
would first be noticed as a broken release — which is how the flattened symlinks
went out in the first place.

The step extracts the archive the way Swift Package Manager does and asserts the
five framework symlinks survive and only one copy of the binary is present.
Verified to fail when the repack is disabled.
test -L is true for a dangling link, and the binary count stays at one, so an
archive with five broken links would have passed. Resolve each link with -e as
well, and clean up the extraction directory on the way out.

Also say in the repack's failure message which assumption broke, since it only
looks for the release build type that KMMBridge publishes.
@LaStrada
LaStrada requested review from a team as code owners September 2, 2026 12:44
ditto stores extended attributes as AppleDouble files, so the repacked archive
carried a ._Headers, ._KmpLog and so on next to every entry — inside the bundle,
where codesign refuses stray files. Gradle's Zip never produced them, so this was
introduced by the repack itself and found by building a consumer against the
archive rather than against the framework directory.

--norsrc --noextattr drops them: 121 archive entries become 61, all five symlinks
survive, and the CI check now fails if any reappear.
@LaStrada
LaStrada merged commit e87502b into main Sep 7, 2026
2 checks passed
@LaStrada
LaStrada deleted the fix/preserve-framework-symlinks branch September 7, 2026 07:40
@LaStrada LaStrada mentioned this pull request Sep 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants