Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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()",
Expand All @@ -160,13 +165,24 @@
{
"name": "Upload unsigned bundle",
"id": "upload",
"if": "runner.os == 'Windows'",
"uses": "actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02",
"with": {
"name": "MatteLoop-unsigned-${{ matrix.target }}",
"path": "dist",
"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"
}
}
]
},
Expand All @@ -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",
Expand Down
13 changes: 7 additions & 6 deletions docs/building.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<target>.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

Expand Down
117 changes: 114 additions & 3 deletions tests/release/test_frozen_smoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import tempfile
import tomllib
import urllib.request
import zipfile
from dataclasses import FrozenInstanceError
from pathlib import Path

Expand Down Expand Up @@ -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
Expand All @@ -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(
Expand Down Expand Up @@ -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'<?xml version="1.0" encoding="UTF-8"?>'
b"<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" "
b'\"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">'
b"<plist version=\"1.0\"><dict><key>CFBundleExecutable</key>"
b"<string>MatteLoop</string></dict></plist>",
)
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.

Expand Down
Loading