Extend macOS bundle script with release checks#779
Conversation
Add release-oriented subcommands to bundle-with-homebrew.sh: check, bundle, verify, and all. The new check step validates the Homebrew-based macOS bundling environment before packaging, while verify can inspect a generated DMG artifact. Add Makefile entry points for the release workflow. These targets delegate to the bundle script instead of implementing release logic in Makefile: - make release-check - make release - make release-test make release now runs the check step before bundling.
|
Please use the following format to refer to the associated issue in the future:
You used "Fixes number" in the PR comment and I changed it. See Pull Request Guidelines:
|
iefiru
left a comment
There was a problem hiding this comment.
I added inline comments around the release workflow. Feel free to share any questions or suggestions.
| .PHONY: release-check | ||
| release-check: | ||
| $(MODMESH_ROOT)/contrib/bundle/bundle-with-homebrew.sh check | ||
|
|
||
| .PHONY: release | ||
| release: | ||
| $(MODMESH_ROOT)/contrib/bundle/bundle-with-homebrew.sh all \ | ||
| --output "$(RELEASE_OUTPUT)" $(RELEASE_ARGS) | ||
|
|
||
| .PHONY: release-test | ||
| release-test: | ||
| $(MODMESH_ROOT)/contrib/bundle/bundle-with-homebrew.sh verify \ | ||
| "$(RELEASE_ARTIFACT)" | ||
|
|
There was a problem hiding this comment.
release-checkchecks the bundle dependencies.releasechecks the dependencies and builds the DMG bundle.release-testtests the DMG bundle.
There was a problem hiding this comment.
Discuss: I propose to use explicit names for macos: bundle-precheck (from release-check), bundle (from release), bundle-test (release-test).
| verify) | ||
| [[ $# -eq 1 ]] || { usage >&2; exit 2; } | ||
| VERIFY_ARTIFACT="$1" | ||
| shift | ||
| ;; |
There was a problem hiding this comment.
MUST provide DMG file for verify.
| cd "$BUNDLE_REPO_ROOT" | ||
|
|
||
| MIN_LOADS=${MIN_LOADS:-50} | ||
| HOST_PREFIX_RE='^(/opt/homebrew|/usr/local)' |
There was a problem hiding this comment.
Centralize host package path patterns for easier management and reuse.
| Subcommands: | ||
| check Check macOS bundle release dependencies. Does not build or install. | ||
| bundle Build/package pilot.app and pilot.dmg with Homebrew dependencies. | ||
| verify Verify a generated release DMG artifact. | ||
| all Run check, then bundle. |
There was a problem hiding this comment.
checkchecks the bundle dependencies.bundlechecks the dependencies and builds the DMG bundle.verifytests the DMG bundle.
There was a problem hiding this comment.
If all does not include verify, make it explicit like:
Run check and then bundle. (No verify.)
| python_module_check() { | ||
| local module="$1" hint="${2:-}" out | ||
| out=$(mktemp -t modmesh-bundle-pycheck) | ||
| if python3 - "$module" <<'PY' >"$out" 2>&1 | ||
| import importlib | ||
| import sys | ||
| module = sys.argv[1] | ||
| try: | ||
| mod = importlib.import_module(module) | ||
| except Exception as e: | ||
| print(f"{type(e).__name__}: {e}") | ||
| raise SystemExit(1) | ||
| else: | ||
| print(getattr(mod, "__file__", "built-in")) | ||
| PY | ||
| then | ||
| ok "python module $module: $(cat "$out")" | ||
| rm -f "$out" | ||
| else | ||
| cat "$out" >&2 || true | ||
| rm -f "$out" | ||
| fail "python module $module: missing${hint:+ ($hint)}" | ||
| fi | ||
| } |
There was a problem hiding this comment.
Import the given Python package to verify it is available.
| check_pybind11_cmake() { | ||
| local config_dir prefix | ||
| if command -v pybind11-config >/dev/null 2>&1; then | ||
| config_dir=$(pybind11-config --cmakedir 2>/dev/null || true) | ||
| if [[ -n "$config_dir" && -f "$config_dir/pybind11Config.cmake" ]]; then | ||
| ok "pybind11 CMake config: $config_dir/pybind11Config.cmake" | ||
| return 0 | ||
| fi | ||
| fi | ||
| prefix=$(brew --prefix pybind11 2>/dev/null || true) | ||
| if [[ -n "$prefix" && -f "$prefix/share/cmake/pybind11/pybind11Config.cmake" ]]; then | ||
| ok "pybind11 CMake config: $prefix/share/cmake/pybind11/pybind11Config.cmake" | ||
| else | ||
| fail "pybind11 CMake config: not found (brew install pybind11)" | ||
| fi | ||
| } |
There was a problem hiding this comment.
Ensure pybind11's CMake config is available before the build step.
| check_deps() { | ||
| local brew_exe brew_prefix python_path fw_path failed=0 | ||
| require_macos | ||
|
|
||
| if brew_exe=$(find_brew); then | ||
| ok "brew: $brew_exe" | ||
| eval "$("$brew_exe" shellenv)" | ||
| brew_prefix=$(brew --prefix) | ||
| ok "Homebrew prefix: $brew_prefix" | ||
| else | ||
| fail "brew: not found (install Homebrew)" || true | ||
| setup_hint | ||
| exit 1 | ||
| fi | ||
|
|
||
| for cmd in cmake make python3 macdeployqt qtpaths otool \ | ||
| install_name_tool codesign hdiutil rsync shasum; do | ||
| require_command "$cmd" || failed=1 | ||
| done | ||
|
|
||
| python_path=$(command -v python3 || true) | ||
| if [[ -n "$python_path" && "$python_path" == "$brew_prefix"/* ]]; then | ||
| ok "python3 is under Homebrew prefix" | ||
| else | ||
| fail "python3 is not from Homebrew prefix ($python_path; expected under $brew_prefix)" || failed=1 | ||
| fi | ||
|
|
||
| if fw_path=$(python_framework_check 2>&1); then | ||
| ok "Python framework dylib: $fw_path" | ||
| else | ||
| fail "$fw_path" || failed=1 | ||
| fi | ||
|
|
||
| check_pybind11_cmake || failed=1 | ||
| python_module_check numpy "brew install numpy" || failed=1 | ||
| python_module_check PySide6 "brew install pyside" || failed=1 | ||
| python_module_check shiboken6 "brew install pyside" || failed=1 | ||
| python_module_check shiboken6_generator "brew install pyside" || failed=1 | ||
| python_module_check matplotlib "brew install python-matplotlib" || failed=1 | ||
|
|
||
| if [[ $failed -ne 0 ]]; then | ||
| setup_hint | ||
| exit 1 | ||
| fi | ||
| } |
There was a problem hiding this comment.
Implement the check subcommand by running the macOS release preflight checks.
| verify_app_structure() { | ||
| local app="$1" bin | ||
| [[ -d "$app" ]] || fail "app not found: $app" | ||
| [[ -f "$app/Contents/Info.plist" ]] || fail "Info.plist not found" | ||
| bin="$app/Contents/MacOS/$(basename "$app" .app)" | ||
| [[ -x "$bin" ]] || fail "main binary not executable: $bin" | ||
| [[ -d "$app/Contents/Frameworks" ]] || fail "Contents/Frameworks not found" | ||
| [[ -d "$app/Contents/Resources" ]] || fail "Contents/Resources not found" | ||
| file "$bin" | ||
| } |
There was a problem hiding this comment.
Check that the DMG contains a complete macOS app bundle.
| smoke_launch_verify() { | ||
| local bin="$1" trace="$2" pid elapsed=0 loaded | ||
| : > "$trace" | ||
| env -i HOME="${HOME:-/}" USER="${USER:-nobody}" \ | ||
| PATH=/usr/bin:/bin TERM="${TERM:-xterm}" \ | ||
| DYLD_PRINT_LIBRARIES=1 \ | ||
| "$bin" >/dev/null 2>"$trace" & | ||
| pid=$! | ||
| while [[ $elapsed -lt 15 ]]; do | ||
| sleep 1 | ||
| elapsed=$((elapsed + 1)) | ||
| loaded=$(grep -c '^dyld\[' "$trace" 2>/dev/null || true) | ||
| [[ ${loaded:-0} -ge $MIN_LOADS ]] && break | ||
| done | ||
| kill "$pid" 2>/dev/null || true | ||
| wait "$pid" 2>/dev/null || true | ||
|
|
||
| loaded=$(grep -c '^dyld\[' "$trace" 2>/dev/null || true) | ||
| if [[ $loaded -lt $MIN_LOADS ]]; then | ||
| echo "ERROR: pilot loaded only $loaded libraries; did it crash early?" >&2 | ||
| sed -n '1,80p' "$trace" >&2 | ||
| return 1 | ||
| fi | ||
| if grep -E '^dyld\[' "$trace" | grep -E '/opt/homebrew|/usr/local' >/dev/null; then | ||
| echo "ERROR: smoke launch loaded libraries from host package prefixes:" >&2 | ||
| grep -E '^dyld\[' "$trace" | grep -E '/opt/homebrew|/usr/local' | head -20 >&2 | ||
| return 1 | ||
| fi | ||
| echo " OK: $loaded libraries loaded, none from host package prefixes" | ||
| } |
There was a problem hiding this comment.
Launch the app from the mounted DMG and verify its dyld trace does not load host package libraries.
| verify_dmg() { | ||
| local dmg="$1" mnt tmp trace marker app bin copied_app | ||
| require_macos | ||
| [[ -f "$dmg" ]] || fail "release artifact not found: $dmg" | ||
|
|
||
| note "Release artifact" | ||
| ls -lh "$dmg" | ||
| shasum -a 256 "$dmg" | ||
| hdiutil imageinfo "$dmg" >/dev/null | ||
|
|
||
| VERIFY_MNT=$(mktemp -d -t modmesh-release-dmg) | ||
| VERIFY_TMP=$(mktemp -d -t modmesh-release-app) | ||
| VERIFY_TRACE=$(mktemp -t modmesh-release-dyld) | ||
| VERIFY_MARKER=$(mktemp -t modmesh-release-marker) | ||
| mnt="$VERIFY_MNT" | ||
| tmp="$VERIFY_TMP" | ||
| trace="$VERIFY_TRACE" | ||
| marker="$VERIFY_MARKER" | ||
| trap cleanup_verify EXIT | ||
|
|
||
| note "Mounting DMG" | ||
| hdiutil attach -nobrowse -readonly -mountpoint "$mnt" "$dmg" >/dev/null | ||
| app=$(find "$mnt" -maxdepth 2 -name '*.app' -type d -print -quit) | ||
| [[ -n "$app" ]] || fail "no .app found in $dmg" | ||
| bin="$app/Contents/MacOS/$(basename "$app" .app)" | ||
|
|
||
| note "Checking mounted app structure" | ||
| verify_app_structure "$app" | ||
|
|
||
| note "Checking code signature before launch" | ||
| codesign --verify --deep --strict "$app" | ||
| spctl --assess --type execute --verbose=4 "$app" 2>&1 || \ | ||
| echo " warning: spctl rejected this app (expected for ad-hoc signed prototype builds)" | ||
|
|
||
| note "Static scan for host package paths" | ||
| check_self_contained "$app" | ||
|
|
||
| note "Runtime import check from a writable app copy" | ||
| cp -R "$app" "$tmp/" | ||
| copied_app="$tmp/$(basename "$app")" | ||
| codesign --verify --deep --strict "$copied_app" | ||
| touch "$marker" | ||
| runtime_import_check "$copied_app/Contents/MacOS/$(basename "$copied_app" .app)" | ||
| if find "$copied_app" -name '*.pyc' -newer "$marker" -print -quit | grep -q .; then | ||
| echo "ERROR: runtime import wrote Python bytecode into the signed app bundle:" >&2 | ||
| find "$copied_app" -name '*.pyc' -newer "$marker" -print | sed -n '1,20p' >&2 | ||
| return 1 | ||
| fi | ||
| codesign --verify --deep --strict "$copied_app" | ||
|
|
||
| note "Smoke launch from mounted DMG" | ||
| smoke_launch_verify "$bin" "$trace" | ||
|
|
||
| note "Release artifact verification passed" | ||
| } |
There was a problem hiding this comment.
Implement the verify subcommand by validating the generated DMG as a release artifact.
| Subcommands: | ||
| check Check macOS bundle release dependencies. Does not build or install. | ||
| bundle Build/package pilot.app and pilot.dmg with Homebrew dependencies. | ||
| verify Verify a generated release DMG artifact. | ||
| all Run check, then bundle. |
There was a problem hiding this comment.
If all does not include verify, make it explicit like:
Run check and then bundle. (No verify.)
| .PHONY: release-check | ||
| release-check: | ||
| $(MODMESH_ROOT)/contrib/bundle/bundle-with-homebrew.sh check | ||
|
|
||
| .PHONY: release | ||
| release: | ||
| $(MODMESH_ROOT)/contrib/bundle/bundle-with-homebrew.sh all \ | ||
| --output "$(RELEASE_OUTPUT)" $(RELEASE_ARGS) | ||
|
|
||
| .PHONY: release-test | ||
| release-test: | ||
| $(MODMESH_ROOT)/contrib/bundle/bundle-with-homebrew.sh verify \ | ||
| "$(RELEASE_ARTIFACT)" | ||
|
|
There was a problem hiding this comment.
Discuss: I propose to use explicit names for macos: bundle-precheck (from release-check), bundle (from release), bundle-test (release-test).
Rename the release-oriented make targets added for the macOS bundling workflow to a "bundle" prefix, matching the bundle-with-homebrew.sh subcommands they delegate to: - make release-check -> make bundle-precheck - make release -> make bundle - make release-test -> make bundle-test
Set PYTHONDONTWRITEBYTECODE=1 before the interpreter starts so the code-signed release bundle isn't mutated by .pyc writes at import time.
There was a problem hiding this comment.
Update following modifications:
- If all does not include verify, make it explicit.
- Discuss: I propose to use explicit names for macos: bundle-precheck (from release-check), bundle (from release), bundle-test (release-test).
New Item:
- Add the missing
PYTHONDONTWRITEBYTECODE=1setting to keep the signed bundle clean.
| // Keep the embedded interpreter from writing .pyc files. The release app | ||
| // bundle is code-signed and ships no precompiled bytecode, so any write | ||
| // would mutate the signed bundle and break its signature. | ||
| setenv("PYTHONDONTWRITEBYTECODE", "1", 1); |
There was a problem hiding this comment.
Disables writing Python bytecode (.pyc) at runtime so the embedded interpreter never modifies the code-signed bundle on import, which would otherwise break its signature.
There was a problem hiding this comment.
Unlike setting the Qt environment variable, disabling generation of .pyc is too offensive for a Python extension module. It should not be turned on by default.
Do it only when the process is within an app bundle.
There was a problem hiding this comment.
sys.dont_write_bytecode is not preferred because it is hard to do it in Python source code before loading any modules. Please include the reason in the code comments too.
yungyuc
left a comment
There was a problem hiding this comment.
- Disabling .pyc generation should not be the default behavior. The environment variable can only be set when the process is within an app bundle.
| // Keep the embedded interpreter from writing .pyc files. The release app | ||
| // bundle is code-signed and ships no precompiled bytecode, so any write | ||
| // would mutate the signed bundle and break its signature. | ||
| setenv("PYTHONDONTWRITEBYTECODE", "1", 1); |
There was a problem hiding this comment.
Unlike setting the Qt environment variable, disabling generation of .pyc is too offensive for a Python extension module. It should not be turned on by default.
Do it only when the process is within an app bundle.
Switch from disabling runtime bytecode writes to shipping pre-compiled .pyc, so the embedded interpreter never mutates the signed bundle on import. - Stop forcing PYTHONDONTWRITEBYTECODE in the embedded interpreter - Run compileall to pre-compile Python bytecode for the bundle
iefiru
left a comment
There was a problem hiding this comment.
@yungyuc Thanks for the earlier feedback. Per our discussion, I switched the approach from forcing PYTHONDONTWRITEBYTECODE in the embedded interpreter to pre-compiling .pyc with compileall --invalidation-mode unchecked-hash before code signing. This matches what python-build-standalone / PyInstaller / py2app do, and avoids the dev-side cost of the env override.
| // Keep the embedded interpreter from writing .pyc files. The release app | ||
| // bundle is code-signed and ships no precompiled bytecode, so any write | ||
| // would mutate the signed bundle and break its signature. | ||
| setenv("PYTHONDONTWRITEBYTECODE", "1", 1); | ||
|
|
There was a problem hiding this comment.
Revert PYTHONDONTWRITEBYTECODE
| T_STEP=$SECONDS | ||
| echo "==> Pre-compiling bundled Python modules" | ||
| PY_LIB="$DEST_FW/Versions/$PY_VER/lib/python$PY_VER" | ||
| rm -rf "$PY_LIB/test" | ||
| "$DEST_FW/Versions/$PY_VER/bin/python$PY_VER" -m compileall -f \ | ||
| --invalidation-mode unchecked-hash -q \ | ||
| "$PY_LIB" || true | ||
| PYC_COUNT=$(find "$PY_LIB" -name '*.pyc' | wc -l | tr -d ' ') | ||
| [[ $PYC_COUNT -gt 0 ]] || \ | ||
| { echo "ERROR: compileall produced no .pyc files under $PY_LIB" >&2; exit 1; } | ||
| echo " compiled $PYC_COUNT .pyc files" | ||
| echo " [Step 5.5 (Pre-compile bytecode): $((SECONDS - T_STEP))s]" |
There was a problem hiding this comment.
Using compileall to pre-compile python byte code for bundle.
|
@chestercheng @ExplorerRay please take a look. |
|
I tested the script in a VM and it builds the bundle in 15 minutes. The final dmg is 300 MB. The result is reasonable. The built DMG can run in the guest VM but not on the host. The error message: Let's fix it in a followup PR. |
Add release-oriented subcommands to bundle-with-homebrew.sh: check, bundle, verify, and all. The new check step validates the Homebrew-based macOS bundling environment before packaging, while verify can inspect a generated DMG artifact.
Add Makefile entry points for the release workflow. These targets delegate to the bundle script instead of implementing release logic in Makefile:
make release now runs the check step before bundling.
Related to issue #765