Repository navigation
Preserve the framework symlinks in the published XCFramework - #55
Merged
Merged
Conversation
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.
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.
karolb-proexe
approved these changes
Sep 7, 2026
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description ✏️
The published XCFramework's macOS slice was malformed. KMMBridge archives it with Gradle's
Ziptask, and Gradle archive tasks resolve symlinks into copies. A macOS framework is a versioned
bundle —
Versions/A/holds the payload andVersions/Currentplus the top-levelKmpLog,Headers,ModulesandResourcesare symlinks into it — so all five were replaced by realcopies. The archive carried the binary three times, and consumers unpacked a
Versions/Currentdirectory 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 infirebase/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:Only the macOS slice changes; iOS frameworks are flat and have no
Versions/hierarchy.dittoneeds--norsrc --noextattr: without them it stores extended attributes as AppleDoublesidecars, which land inside the bundle as
._Headersand friends — exactly the stray filescodesign refuses in a framework. Gradle's
Zipnever produced those, so the repack would havetraded 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
afterEvaluateand a hard
tasks.namedreference fails in every ordinary build. That would fail silently if thetask 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 🐛
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 🔗