Skip to content

Build the macOS release archive on the macOS runner, where its permissions still exist - #70

Merged
smb merged 1 commit into
mainfrom
fix/issue-57-macos-archive-permissions
Sep 5, 2026
Merged

smb merged 1 commit into
mainfrom
fix/issue-57-macos-archive-permissions

Conversation

@smb

@smb smb commented Sep 5, 2026

Copy link
Copy Markdown
Collaborator

Every published macOS build is unlaunchable. The bundle was uploaded unpackaged
with actions/upload-artifact, which does not preserve file modes, and the ZIP
was then assembled from those 0644 files on a Linux runner. The frozen smoke
test runs earlier, on the macOS runner, against a bundle that is still intact —
so four releases went out green while none of them could start.

v0.1.0   0 of 343 entries carry an execute bit
v0.1.1   0 of 343
v0.1.2   0 of 343
v0.2.1   0 of 344

What changed

The archive is created on the macOS runner with ditto and uploaded as a
finished file; publish only moves it into assets/. Nothing reconstructs a
macOS bundle on another platform any more. The Windows path is untouched — it
has no permission bits to lose.

--norsrc is load-bearing. Files created on a macos-15 runner carry a
com.apple.provenance xattr and ditto preserves xattrs, so --sequesterRsrc
would write an AppleDouble tree and give the archive a second top-level entry
beside MatteLoop.app. With --norsrc the listing matches what v0.2.1 shipped,
modes 100755 and framework symlinks intact.

One file legitimately changes shape: libonnxruntime.1.dylib shipped as a
second 33 MB copy because upload-artifact follows symlinks, and is now stored
as the symlink scripts/build.py creates — the same one the runner's smoke test
exercised. Same paths, smaller archive.

The guard

Before gh release create, publish extracts the ZIP, reads
CFBundleExecutable from Info.plist and fails the job if that file is not
executable. It runs on the artifact a user would download, not on the build
directory — the distinction that let this ship four times.

Tests

All four fail against the workflow on main and pass here:

  • test_release_validation_requires_an_executable_macos_launcher — the guard
    itself, on a 0o755 and a 0o644 launcher.
  • test_release_packaging_preserves_macos_archive_and_source_sidecars — the
    archive reaches assets/ byte-identical, with all four source and checksum
    sidecars beside it.
  • test_release_workflow_builds_on_dispatch_tags_and_media_stack_changes — the
    step order and platform conditions the guard depends on.

tests/release is green (28 passed).

Not in scope

The published releases stay as they are, as decided in the issue. A user who
already has one repairs it with chmod +x MatteLoop.app/Contents/MacOS/matteloop,
which is verified sufficient — the full runtime smoke test passes afterwards.

Closes #57

🤖 Generated with Claude Code

Trigger: the published v0.2.1 macOS asset - 0 of 344 entries carry an execute
bit, and extracting it and running Contents/MacOS/matteloop gives permission
denied; test test_release_validation_requires_an_executable_macos_launcher

No macOS artifact this project has published can start. The bundle was uploaded
unpackaged with actions/upload-artifact, which does not preserve file modes, and
the ZIP was then assembled from those 0644 files on a Linux runner. The frozen
smoke test runs earlier, on the macOS runner, against a bundle that is still
intact - which is why four releases went out green while none of them could
launch.

The archive is now created on the macOS runner with ditto, uploaded as a
finished file, and only moved into place by publish. Nothing reconstructs a
macOS bundle on another platform any more; the Windows path is unchanged, since
it has no permission bits to lose.

--norsrc matters. Files created on a macos-15 runner carry a
com.apple.provenance extended attribute, and ditto preserves xattrs by default,
so --sequesterRsrc would write an AppleDouble tree and give the archive a second
top-level entry beside MatteLoop.app. With --norsrc the listing matches what
v0.2.1 shipped, modes and symlinks intact. One file legitimately changes shape:
libonnxruntime.1.dylib travelled as a second 33 MB copy because upload-artifact
follows symlinks, and is now stored as the symlink build.py creates - the same
one the runner's smoke test exercised.

A check on the published archive guards it: publish extracts the ZIP, reads
CFBundleExecutable from Info.plist and fails the job if that file is not
executable. It runs before the release is created and on the artifact a user
would download, not on the build directory - the distinction that let this ship
four times. Tests cover the guard on an executable and a non-executable
launcher, that the macOS archive reaches assets/ byte-identical with its four
source and checksum sidecars, and the step order the guard depends on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@smb
smb merged commit 40e6dcf into main Sep 5, 2026
10 of 11 checks passed
@smb
smb deleted the fix/issue-57-macos-archive-permissions branch September 5, 2026 21:10
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.

Every published macOS build is unlaunchable: the release archive loses the executable bit

1 participant