From fa9177f5804d548e43ebc1b3b89033e06acce0ba Mon Sep 17 00:00:00 2001 From: smb Date: Sat, 5 Sep 2026 22:37:48 +0200 Subject: [PATCH] fix: build the macOS release archive where its permissions still exist 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) --- .github/workflows/release.yml | 22 +++++- docs/building.md | 13 ++-- tests/release/test_frozen_smoke.py | 117 ++++++++++++++++++++++++++++- 3 files changed, 142 insertions(+), 10 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b55f370..c5e677d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -147,6 +147,11 @@ "key": "nuitka-${{ runner.os }}-${{ matrix.target }}-${{ github.sha }}" } }, + { + "name": "Create macOS application archive", + "if": "runner.os == 'macOS'", + "run": "ditto -c -k --norsrc --keepParent dist/MatteLoop.app \"dist/MatteLoop-${{ matrix.target }}.zip\"" + }, { "name": "Upload retained media-stack staging on failure", "if": "failure()", @@ -160,6 +165,7 @@ { "name": "Upload unsigned bundle", "id": "upload", + "if": "runner.os == 'Windows'", "uses": "actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02", "with": { "name": "MatteLoop-unsigned-${{ matrix.target }}", @@ -167,6 +173,16 @@ "if-no-files-found": "error", "include-hidden-files": true } + }, + { + "name": "Upload macOS application archive", + "if": "runner.os == 'macOS'", + "uses": "actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02", + "with": { + "name": "MatteLoop-unsigned-${{ matrix.target }}", + "path": "dist/MatteLoop-${{ matrix.target }}.zip\ndist/MatteLoop-*-sources-*", + "if-no-files-found": "error" + } } ] }, @@ -188,7 +204,11 @@ }, { "name": "Separate the application from its LGPL source archives", - "run": "mkdir -p assets\nfor bundle in bundles/MatteLoop-unsigned-*; do\n target=\"${bundle##*/MatteLoop-unsigned-}\"\n # The source archives beside the bundle are the LGPL corresponding\n # sources; they ship as their own assets so the application download is\n # not 90 MB heavier for every user. Whatever remains is the application.\n find \"$bundle\" -maxdepth 1 -name \"MatteLoop-*-sources-*\" -exec mv {} assets/ \\;\n (cd \"$bundle\" && zip -qry \"../../assets/MatteLoop-${GITHUB_REF_NAME}-${target}.zip\" .)\ndone\nls -lh assets" + "run": "mkdir -p assets\nfor bundle in bundles/MatteLoop-unsigned-*; do\n target=\"${bundle##*/MatteLoop-unsigned-}\"\n # The source archives beside the bundle are the LGPL corresponding\n # sources; they ship as their own assets so the application download is\n # not 90 MB heavier for every user. Whatever remains is the application.\n find \"$bundle\" -maxdepth 1 -name \"MatteLoop-*-sources-*\" -exec mv {} assets/ \\;\n if [ \"$target\" = \"macos-15-arm64\" ]; then\n mv \"$bundle/MatteLoop-${target}.zip\" \"assets/MatteLoop-${GITHUB_REF_NAME}-${target}.zip\"\n else\n (cd \"$bundle\" && zip -qry \"../../assets/MatteLoop-${GITHUB_REF_NAME}-${target}.zip\" .)\n fi\ndone\nls -lh assets" + }, + { + "name": "Validate macOS archive launcher permissions", + "run": "set -euo pipefail\narchive=\"assets/MatteLoop-${GITHUB_REF_NAME}-macos-15-arm64.zip\"\ntest -f \"$archive\"\nextracted=$(mktemp -d)\nunzip -q \"$archive\" -d \"$extracted\"\npython3 -c 'import pathlib, plistlib, sys; app = pathlib.Path(sys.argv[1]); executable = plistlib.loads((app / \"Contents\" / \"Info.plist\").read_bytes())[\"CFBundleExecutable\"]; launcher = app / \"Contents\" / \"MacOS\" / executable; assert launcher.is_file() and launcher.stat().st_mode & 0o111, f\"{launcher} is not executable\"' \"$extracted/MatteLoop.app\"" }, { "name": "Create the draft release", diff --git a/docs/building.md b/docs/building.md index 39ef2cd..576e27f 100644 --- a/docs/building.md +++ b/docs/building.md @@ -259,12 +259,13 @@ hash of the media manifest, all `scripts/media_stack/**/*.py` files, `scripts/build_media_stack.py`, and `scripts/verify_media_stack.py`; it has no broad restore key. -The workflow uploads all of `dist/` as one temporary unsigned Actions artifact, -so the app and both source/checksum pairs follow the existing upload path. It -does not create a release, publish, sign, notarize, or permanently host the -corresponding sources. A later authorized publication must keep all five -deliverables together on a durable endpoint; an expiring Actions artifact is -not that endpoint. +Windows uploads all of `dist/` as one temporary unsigned Actions artifact; +macOS uploads the finished `MatteLoop-.zip` together with the source +archives and their checksums, and `publish` moves the archive unchanged and +verifies the launcher is executable. It does not create a release, publish, +sign, notarize, or permanently host the corresponding sources. A later +authorized publication must keep all five deliverables together on a durable +endpoint; an expiring Actions artifact is not that endpoint. ## Models and first launch diff --git a/tests/release/test_frozen_smoke.py b/tests/release/test_frozen_smoke.py index 1ee79bd..427a25d 100644 --- a/tests/release/test_frozen_smoke.py +++ b/tests/release/test_frozen_smoke.py @@ -12,6 +12,7 @@ import tempfile import tomllib import urllib.request +import zipfile from dataclasses import FrozenInstanceError from pathlib import Path @@ -766,12 +767,32 @@ def test_release_workflow_builds_on_dispatch_tags_and_media_stack_changes() -> N assert steps.index(cache_stats) == build_index + 1 upload = next(step for step in steps if step.get("id") == "upload") + assert upload["if"] == "runner.os == 'Windows'" assert upload["with"]["name"] == "MatteLoop-unsigned-${{ matrix.target }}" assert upload["with"]["path"] == "dist" - # delocate stores PyAV's FFmpeg libraries in av/.dylibs, and the default - # upload silently drops hidden files: the runner's own smoke test passed - # while the artifact people downloaded could not import av at all. + # On macOS, delocate's av/.dylibs and other hidden directories travel + # inside the ditto archive; this hidden-file setting belongs to Windows. assert upload["with"]["include-hidden-files"] is True + + ditto = next( + step for step in steps if step["name"] == "Create macOS application archive" + ) + macos_upload = next( + step for step in steps if step["name"] == "Upload macOS application archive" + ) + validation = next( + step + for step in workflow["jobs"]["publish"]["steps"] + if step["name"] == "Validate macOS archive launcher permissions" + ) + assert ditto["if"] == "runner.os == 'macOS'" + assert ditto["run"] == ( + "ditto -c -k --norsrc --keepParent dist/MatteLoop.app " + '\"dist/MatteLoop-${{ matrix.target }}.zip\"' + ) + assert macos_upload["if"] == "runner.os == 'macOS'" + assert steps.index(ditto) < steps.index(macos_upload) + assert "dist/MatteLoop-*-sources-*" in macos_upload["with"]["path"] serialized = json.dumps(workflow).lower() assert "secrets." not in serialized assert "codesign" not in serialized @@ -788,6 +809,7 @@ def test_release_workflow_builds_on_dispatch_tags_and_media_stack_changes() -> N create = publish["steps"][-1] assert create["env"] == {"GH_TOKEN": "${{ github.token }}"} assert "--draft" in create["run"] + assert publish["steps"].index(validation) < publish["steps"].index(create) @pytest.mark.parametrize( @@ -904,6 +926,95 @@ def test_release_packaging_keeps_lgpl_sources_out_of_the_application_zip( assert not [name for name in archived if "sources" in name] +def test_release_packaging_preserves_macos_archive_and_source_sidecars( + tmp_path: Path, +) -> None: + workflow_path = REPOSITORY_ROOT / ".github" / "workflows" / "release.yml" + workflow = json.loads(workflow_path.read_text(encoding="utf-8")) + step = next( + s + for s in workflow["jobs"]["publish"]["steps"] + if s["name"].startswith("Separate the application") + ) + bundle = tmp_path / "bundles" / "MatteLoop-unsigned-macos-15-arm64" + bundle.mkdir(parents=True) + archive = bundle / "MatteLoop-macos-15-arm64.zip" + with zipfile.ZipFile(archive, "w") as created: + created.writestr("MatteLoop.app/Contents/MacOS/MatteLoop", b"app") + sidecars = { + "MatteLoop-media-sources-macos-arm64-abc.tar.gz": b"media source", + "MatteLoop-media-sources-macos-arm64-abc.tar.gz.sha256": b"media hash", + "MatteLoop-qt-sources-6.10.3-def.tar.gz": b"qt source", + "MatteLoop-qt-sources-6.10.3-def.tar.gz.sha256": b"qt hash", + } + for name, contents in sidecars.items(): + (bundle / name).write_bytes(contents) + original_archive = archive.read_bytes() + + subprocess.run( + ["bash", "-e", "-c", step["run"]], + cwd=tmp_path, + check=True, + env={**os.environ, "GITHUB_REF_NAME": "v9.9.9"}, + capture_output=True, + ) + + assets_dir = tmp_path / "assets" + assert (assets_dir / "MatteLoop-v9.9.9-macos-15-arm64.zip").read_bytes() == ( + original_archive + ) + assert { + path.name + for path in assets_dir.iterdir() + if path.name != "MatteLoop-v9.9.9-macos-15-arm64.zip" + } == set(sidecars) + for name, contents in sidecars.items(): + assert (assets_dir / name).read_bytes() == contents + + +@pytest.mark.parametrize( + ("launcher_mode", "expected_returncode"), + [(0o755, 0), (0o644, 1)], +) +def test_release_validation_requires_an_executable_macos_launcher( + tmp_path: Path, + launcher_mode: int, + expected_returncode: int, +) -> None: + workflow_path = REPOSITORY_ROOT / ".github" / "workflows" / "release.yml" + workflow = json.loads(workflow_path.read_text(encoding="utf-8")) + step = next( + s + for s in workflow["jobs"]["publish"]["steps"] + if s["name"] == "Validate macOS archive launcher permissions" + ) + assets_dir = tmp_path / "assets" + assets_dir.mkdir() + archive = assets_dir / "MatteLoop-v9.9.9-macos-15-arm64.zip" + launcher = zipfile.ZipInfo("MatteLoop.app/Contents/MacOS/MatteLoop") + launcher.external_attr = launcher_mode << 16 + with zipfile.ZipFile(archive, "w") as created: + created.writestr( + "MatteLoop.app/Contents/Info.plist", + b'' + b"' + b"CFBundleExecutable" + b"MatteLoop", + ) + created.writestr(launcher, b"launcher") + + completed = subprocess.run( + ["bash", "-e", "-c", step["run"]], + cwd=tmp_path, + check=False, + env={**os.environ, "GITHUB_REF_NAME": "v9.9.9"}, + capture_output=True, + ) + + assert completed.returncode == expected_returncode + + def test_every_workflow_shell_script_parses() -> None: """A `run:` block that cannot be parsed fails its step on every platform.