diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 04e0bfb9..f05dcaa9 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -134,20 +134,16 @@ jobs: -DAXYNE_APP_BUNDLE=Axyne.app \ -DAXYNE_APP_EXECUTABLE=Axyne \ -P packaging/validation/validate-install.cmake - mkdir -p "$package_directory/dragndrop" - pkgbuild \ - --root "$staging_directory" \ - --identifier com.axyne.ide \ - --version 0.1.0 \ - --install-location /Applications \ - "$package_directory/Axyne-0.1.0-macOS.pkg" - package_payload="$(pkgutil --payload-files "$package_directory/Axyne-0.1.0-macOS.pkg")" - if ! grep -q 'Axyne.app/' <<< "$package_payload"; then - echo 'The macOS pkg has no Axyne.app payload.' >&2 + cpack --config build/CPackConfig.cmake -G productbuild -C Release \ + -B "$package_directory" + packages=("$package_directory"/*.pkg) + if [ "${#packages[@]}" -ne 1 ] || [ ! -f "${packages[0]}" ]; then + echo 'CPack must produce exactly one productbuild installer.' >&2 exit 1 fi + python3 packaging/validation/validate-productbuild.py \ + "${packages[0]}" "$staging_directory" cpack --config build/CPackConfig.cmake -G DragNDrop -C Release \ - -D CPACK_PACKAGING_INSTALL_PREFIX=/ \ -B "$package_directory/dragndrop" if cpack --config build/CPackConfig.cmake -G productbuild -C Debug -B "$package_directory/debug" >"$RUNNER_TEMP/axyne-cpack-debug.log" 2>&1; then @@ -160,7 +156,7 @@ jobs: exit 1 fi - cp "$package_directory/Axyne-0.1.0-macOS.pkg" "$artifact_directory/" + cp "${packages[0]}" "$artifact_directory/" find "$package_directory/dragndrop" \ -maxdepth 1 -type f -name '*.dmg' \ -exec cp {} "$artifact_directory/" \; diff --git a/CMakeLists.txt b/CMakeLists.txt index b6f9ffa1..ad99cdf4 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -537,7 +537,9 @@ if(WIN32 OR APPLE) set(CPACK_NSIS_MENU_LINKS "axyne.exe" "Axyne") elseif(APPLE) set(CPACK_GENERATOR "productbuild;DragNDrop") - set(CPACK_PACKAGING_INSTALL_PREFIX "/Applications") + # CPack drops its automatically discovered sole Unspecified component. + # productbuild requires it explicitly to create a choice and payload. + set(CPACK_COMPONENTS_ALL Unspecified) set(CPACK_PRODUCTBUILD_IDENTIFIER "com.axyne.ide") set(CPACK_PRODUCTBUILD_INSTALL_PATH "/Applications") set(CPACK_DMG_VOLUME_NAME "${AXYNE_DISPLAY_NAME} ${PROJECT_VERSION}") diff --git a/docs/specs/macos-installer-payload.md b/docs/specs/macos-installer-payload.md new file mode 100644 index 00000000..7a91e74c --- /dev/null +++ b/docs/specs/macos-installer-payload.md @@ -0,0 +1,40 @@ +# macOS installer payload repair + +Category: macOS packaging. Branch: `fix/macos-installer-payload`. + +| Decision | Value | Source | Status | +|---|---|---|---| +| Install behavior | Install the full existing application and uninstall command under `/Applications`; retain native Installer | IMPLEMENTATION | CONFIRMED existing behavior | +| Scope | Repair packaging and CI validation only; no new features | IMPLEMENTATION | CONFIRMED implementation boundary | +| Component selection | Explicitly include the existing `Unspecified` component | IMPLEMENTATION | CONFIRMED by experiment | +| Generator staging | DragNDrop `/`, productbuild `/Applications` | IMPLEMENTATION | CONFIRMED | +| Installer choice | Display existing required component as visible `Axyne` | DELEGATED | ASSUMED | +| Archive validation | Expand the final CPack productbuild archive with `pkgutil`; resolve Distribution choices and component references, verify effective destination and every staged file, directory, symlink, file digest, and executable permission | IMPLEMENTATION | CONFIRMED | + +## Implementation state + +| Unit | Responsibility | Dependencies | Status | +|---|---|---|---| +| Packaging | Include payload component and separate generator prefixes | Existing Release install rules | COMPLETE | +| Validation | Validate final archive and use actual CPack productbuild in existing CI | Packaging, staged install tree | COMPLETE | +| Verification | Reproduce original failure, validate repaired archive, separate review | Both units | COMPLETE | + +## Experimental evidence + +Original CPack productbuild produces a 2.1 KB package with an empty +`choices-outline`, although its staging tree contains the app. Setting +`CPACK_MONOLITHIC_INSTALL=ON` still produces the empty archive. Setting +`CPACK_COMPONENTS_ALL=Unspecified` creates a required, hidden, selected choice +and a component payload containing `Applications/Axyne.app`. PackageInfo uses +install-location `/`, so the effective application destination is `/Applications`. + +Repaired deliverables are under `artifacts/macos-installer-fixed-final-20261011`. +Existing user artifacts are untouched. + +Final checks: repaired productbuild archive passes the complete staged-tree +comparison; original empty archive fails on empty choices-outline; DragNDrop +succeeds without a prefix override; Debug packaging rejects with Release-only; +final Distribution shows visible, selected, disabled (required) `Axyne` choice; +`git diff --check` passes. Independent final review found no actionable issues. +Installer acceptance was checked with `installer -showChoicesXML`; an actual +privileged installation was not performed. diff --git a/packaging/CPackProjectConfig.cmake.in b/packaging/CPackProjectConfig.cmake.in index 4e194a29..a674568b 100644 --- a/packaging/CPackProjectConfig.cmake.in +++ b/packaging/CPackProjectConfig.cmake.in @@ -6,6 +6,16 @@ if(DEFINED CPACK_BUILD_CONFIG AND NOT CPACK_BUILD_CONFIG STREQUAL "Release") "Axyne packaging is Release-only; requested CPack configuration is '${CPACK_BUILD_CONFIG}'.") endif() +# A disk image places the app beside CPack's Applications shortcut. An +# installer stages the same complete tree below the system Applications path. +if(CPACK_GENERATOR STREQUAL "DragNDrop") + set(CPACK_PACKAGING_INSTALL_PREFIX "/") +elseif(CPACK_GENERATOR STREQUAL "productbuild") + set(CPACK_PACKAGING_INSTALL_PREFIX "/Applications") + set(CPACK_COMPONENT_UNSPECIFIED_DISPLAY_NAME "Axyne") + set(CPACK_COMPONENT_UNSPECIFIED_HIDDEN FALSE) +endif() + set(_axyne_packaging_configured_build_type "@AXYNE_PACKAGING_CONFIGURED_BUILD_TYPE@") set(_axyne_packaging_multi_config "@AXYNE_PACKAGING_MULTI_CONFIG@") diff --git a/packaging/validation/validate-productbuild.py b/packaging/validation/validate-productbuild.py new file mode 100644 index 00000000..a339e283 --- /dev/null +++ b/packaging/validation/validate-productbuild.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +"""Validate the final native installer, including every staged payload entry.""" + +import argparse +import hashlib +from pathlib import Path +import subprocess +import tempfile +from urllib.parse import unquote +import xml.etree.ElementTree as ET + + +def require(condition, message): + if not condition: + raise ValueError(message) + + +def digest(path): + return hashlib.sha256(path.read_bytes()).digest() + + +def validate(package, staged): + require(package.is_file(), f"Package does not exist: {package}") + require((staged / "Axyne.app/Contents/MacOS/Axyne").is_file(), + "Staged install has no Axyne executable") + with tempfile.TemporaryDirectory(prefix="axyne-productbuild-") as work: + expanded = Path(work) / "expanded" + subprocess.run(["pkgutil", "--expand-full", str(package), str(expanded)], + check=True) + distribution = ET.parse(expanded / "Distribution").getroot() + lines = distribution.findall("./choices-outline//line") + require(lines, "Distribution has an empty choices-outline") + choices = {choice.get("id"): choice for choice in distribution.findall("choice")} + references = {ref.get("id"): (ref.text or "").strip() + for ref in distribution.findall("pkg-ref") + if (ref.text or "").strip().startswith("#")} + payloads = [] + for line in lines: + choice = choices.get(line.get("choice")) + require(choice is not None, "Distribution choice is missing") + require(choice.get("start_selected", "true") == "true", + "Application payload choice is not selected") + for ref in choice.findall("pkg-ref"): + filename = unquote(references.get(ref.get("id"), "").removeprefix("#")) + require(filename and Path(filename).name == filename, + "Distribution has no local component package reference") + component = expanded / filename + info = ET.parse(component / "PackageInfo").getroot() + require(info.get("identifier") == ref.get("id"), + "Distribution and component identifiers differ") + location = Path(info.get("install-location", "/")) + require(location.is_absolute(), "Component install location is not absolute") + payloads.append((component / "Payload", location)) + require(payloads, "Distribution has no referenced payload") + for source in staged.rglob("*"): + relative = source.relative_to(staged) + destination = Path("/Applications") / relative + candidates = [root / destination.relative_to(location) + for root, location in payloads + if destination.is_relative_to(location)] + matches = [path for path in candidates if path.exists() or path.is_symlink()] + require(len(matches) == 1, f"Missing or duplicate installer payload: {destination}") + target = matches[0] + if source.is_symlink(): + require(target.is_symlink() and target.readlink() == source.readlink(), + f"Changed payload symlink: {destination}") + elif source.is_dir(): + require(target.is_dir(), f"Missing payload directory: {destination}") + else: + require(target.is_file() and not target.is_symlink() + and digest(target) == digest(source), + f"Changed or empty payload file: {destination}") + require((target.stat().st_mode & 0o111) == (source.stat().st_mode & 0o111), + f"Changed executable permissions: {destination}") + print(f"Validated productbuild Distribution and complete /Applications payload: {package}") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("package", type=Path) + parser.add_argument("staged_install", type=Path) + args = parser.parse_args() + try: + validate(args.package.resolve(), args.staged_install.resolve()) + except (ValueError, OSError, ET.ParseError, subprocess.CalledProcessError) as error: + parser.exit(1, f"Invalid productbuild package: {error}\n")