Fix: Fixes #857, and turns the macOS package into a self-contained, Apple-silicon-only install - #859
Conversation
There was a problem hiding this comment.
🟡 Changes recommended
The new pruned-FreeSurfer cache reuse and packaging copy logic can mis-handle partially corrupted caches or pre-existing destinations, and the macOS MPS fallback docs should be clarified to match the new default behavior.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
This PR improves the macOS .pkg installer reliability and macOS usability by making post-install steps fail correctly (while avoiding known false failures), relocating the bundled pruned FreeSurfer to avoid collisions with real FreeSurfer installs, and updating CI/docs to match the new packaging behavior.
Changes:
- Make macOS postinstall stricter (
set -e) while making quarantine attribute cleanup non-fatal; make FreeSurfer symlink creation idempotent. - Bundle pruned FreeSurfer under
$FASTSURFER_HOME/fs-pruned, add--namesupport and CI caching for faster packaging. - Add a macOS-specific MPS fallback default and update installer/docs navigation and install guidance.
File summaries
| File | Description |
|---|---|
| tools/macos_build/scripts/postinstall.sh.template | Enable set -e, point bundled FreeSurfer to fs-pruned, and make xattr cleanup non-fatal. |
| tools/macos_build/macos_setup_fastsurfer.sh.template | Default FREESURFER_HOME to the bundled fs-pruned and update docs link. |
| tools/macos_build/edit_distribution.py | Clarify that MACOS.md is used as the installer’s plain-text conclusion screen. |
| tools/macos_build/build_release_package.sh | Install pruned FreeSurfer into fs-pruned, support cached builds, and retain MACOS.md in resources. |
| tools/build/link_fs.sh | Make symlink creation idempotent via ln -sf. |
| tools/build/install_fs_pruned.sh | Add --name option and introduce cache reuse keyed by the source URL marker. |
| run_fastsurfer.sh | Auto-set PYTORCH_ENABLE_MPS_FALLBACK=1 on macOS when unset (before any PyTorch import). |
| recon_surf/functions.sh | Add fallback to bundled fs-pruned when FREESURFER_HOME is unset and the stamp file exists. |
| pyproject.toml | Pin sphinx-argparse to <0.6 due to issues in newer versions. |
| doc/overview/INSTALL.md | Document Gatekeeper “Open Anyway” flow and expand macOS console/environment guidance. |
| doc/overview/index.rst | Remove MACOS.md from visible nav while keeping it in a hidden toctree. |
| .github/workflows/deploy.yml | Add cache keying and caching for pruned FreeSurfer to speed up macOS packaging CI. |
Review details
- Files reviewed: 12/12 changed files
- Comments generated: 3
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
There was a problem hiding this comment.
🟡 Changes recommended
The fallback omits FreeSurfer environment initialization, and interrupted cached downloads can be accepted as complete.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
- Files reviewed: 12/12 changed files
- Comments generated: 2
- Review effort level: Balanced
There was a problem hiding this comment.
🟡 Changes recommended
Cache copying, validation, and py2app environment handling can currently produce missing or persistently incomplete package contents.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (1)
tools/build/install_fs_pruned.sh:491
- The cache marker is written unconditionally even though this script does not use
set -eand the preceding copy/pruning commands do not accumulate or check failures. For example, a missing archive member makescpfail, butbuild-stamp.txtmay already exist and this marker is still created; every later invocation then treats that incomplete install as valid and exits at line 93. Only publish the marker after verified successful pruning (ideally build in a temporary directory and atomically move it into the cache).
# record what this pruned install was built from, so a re-run (e.g. from a restored CI cache) can
# tell whether it is still valid instead of blindly reusing possibly-stale content
echo "$fslink" > "$source_marker"
- Files reviewed: 13/13 changed files
- Comments generated: 2
- Review effort level: Balanced
There was a problem hiding this comment.
🟡 Changes recommended
Cold-cache builds currently fail, and stale or incomplete staging/cache contents can be packaged or reused.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (1)
tools/build/install_fs_pruned.sh:491
- This marker can certify an incomplete install. The script does not use
set -e, and failures in the precedingcp/touchloops are followed by later successful commands, so the marker andbuild-stamp.txtcan both exist even when required files were not copied; every later run then skips rebuilding that broken cache. Ensure all build operations abort on failure before writing the marker.
# record what this pruned install was built from, so a re-run (e.g. from a restored CI cache) can
# tell whether it is still valid instead of blindly reusing possibly-stale content
echo "$fslink" > "$source_marker"
- Files reviewed: 15/15 changed files
- Comments generated: 3
- Review effort level: Balanced
There was a problem hiding this comment.
🟡 Changes recommended
Intel-incompatible commands, shell PATH handling, and unchecked cache-copy failures can produce unusable installations.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (4)
Previously missed (3) — in code that hasn't changed since the last review.
tools/macos_build/conclusion.html.template:51
- These examples are also embedded in the x86_64 installer, where MPS is unavailable and an explicit
--device mpsraisesValueError(FastSurferCNN/utils/common.py:133-137). Useautoso the same conclusion works for both package architectures while still selecting MPS on Apple silicon.
<pre>run_fastsurfer.sh --seg_only --device mps \
--sd <output/dir> --sid <subject_id> --t1 <subject/t1.nii.gz></pre>
<p>Full pipeline, including surfaces:</p>
<pre>run_fastsurfer.sh --device mps --fs_license ~/fs_license.txt \
--sd <output/dir> --sid <subject_id> --t1 <subject/t1.nii.gz></pre>
doc/overview/INSTALL.md:227
- This command is presented in the package instructions shared by Intel and Apple-silicon users, but explicit
mpsfails on Intel Macs. Useauto(the script default), which selects MPS when available and CPU otherwise.
run_fastsurfer.sh --device mps --sd <path/to/output/dir> --sid <subject_id> --t1 <path/to/subjects/t1/image> --fs_license ~/fs_license.txt
tools/macos_build/conclusion.html.template:40
- This PATH guarantee breaks after the first applet launch on macOS's default zsh. The setup script writes only
~/.bash_profile; on the first zsh launch it also exports PATH, but on later launches the existing profile line makes it skip the export even though zsh never loaded that file, leavingrun_fastsurfer.shunavailable. Persist/load the entries for the active shell and always export them in the current console.
<p>Double-click <b><fastsurfer></b> in your Applications folder. It opens a Terminal window that
has everything set up — the Python environment, the pruned FreeSurfer bundled with this
package, and <code>run_fastsurfer.sh</code> on your <code>PATH</code>. You can recognize it by the
tools/build/install_fs_pruned.sh:507
- The source marker is written unconditionally even though this script does not use
set -eand the preceding copy loop does not checkcpfailures. If an upstream archive omits a listed file or a copy fails,build-stamp.txtmay already exist and this marker causes the incomplete tree to be accepted and cached as valid on every later run. Only create the marker after all required construction steps have been checked successfully.
# record what this pruned install was built from, so a re-run (e.g. from a restored CI cache) can
# tell whether it is still valid instead of blindly reusing possibly-stale content
echo "$fslink" > "$source_marker"
- Files reviewed: 15/15 changed files
- Comments generated: 1
- Review effort level: Balanced
postinstall had no set -e, so a real failure (pip, checkpoint download) went unreported, while xattr -dr made the script exit non-zero whenever any file simply never carried com.apple.quarantine -- which is most of them -- so Installer.app reported failed even on a fully successful install (Deep-MI#857).
…tory as fs-pruned
…ndant MACOS.md page
…re to store it, why autodetection into fs-pruned won't work)
…s lives in run_fastsurfer.sh, not Python)
…wnload URL + prune-script hash
…conclusion screen) and pin sphinx-argparse<0.6 (0.6.1 broke command xref resolution)
…ial cache (require build-stamp.txt too)
… a stale destination
…lly by run_fastsurfer.sh on macOS
… merge conflict either order
There was a problem hiding this comment.
🟡 Changes recommended
The build uses GNU-only sort -V, which fails on the targeted macOS runners.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (1)
tools/macos_build/build_release_package.sh:274
- This second
sort -Vinvocation has the same macOS portability failure: BSDsortrejects-V, so the comparison aborts the build instead of enforcing the deployment target. Use the same portable numeric-component ordering as the probe above.
elif [[ "$(printf '%s\n%s\n' "$MACOS_MIN_SUPPORTED" "$macos_min_found" | sort -V | tail -1)" != "$MACOS_MIN_SUPPORTED" ]]
- Files reviewed: 22/22 changed files
- Comments generated: 1
- Review effort level: Balanced
…ent on macos-15 runners
There was a problem hiding this comment.
🔵 Needs a closer look
The new FreeSurfer caching and custom-name logic can reuse incorrect archives or delete an unrelated installation.
Review details
Suppressed comments (3)
Previously missed (1) — in code that hasn't changed since the last review.
tools/build/install_fs_pruned.sh:97
- The cache identity ignores
--upx, although that flag changes the generated install. Reusing the same destination first without--upxand then with it exits here with an unpacked tree (and the reverse reuses a packed tree), so the requested output is not rebuilt.
This issue also appears in the following locations of the same file:
- line 119
- line 133
cache_stamp="$fslink $script_digest"
tools/build/install_fs_pruned.sh:119
- With the new
--nameoption, this unconditionally deletes a different sibling namedfreesurfer. For example, installing as--name fs-prunedunder a directory that already contains a real FreeSurfer installation removes that installation, even though only$where/fs-prunedwas requested. Extract into a private temporary directory (or fail rather than deleting an unrelated sibling) before moving the result to$fsd.
rm -rf "$fsd" "$fss" "$where/freesurfer"
tools/build/install_fs_pruned.sh:136
- This cache file is reused solely because it exists; it is not tied to
fslink. If the FreeSurfer URL/version changes while the caller keeps the same cache path, the old valid archive is pruned and then stamped as if it came from the new URL, producing a successful but incorrectly versioned package. Store and verify a URL/checksum sidecar, or invalidate the file whenever its source URL differs.
if [[ -n "$download_cache" ]] ; then
freesurfer_dl="$download_cache"
mkdir -p "$(dirname "$freesurfer_dl")"
delete_freesurfer_dl="false"
- Files reviewed: 22/22 changed files
- Comments generated: 0 new
- Review effort level: Balanced
Extraction went into and cleaned /freesurfer first, which with --name deletes an unrelated FreeSurfer install rather than this script's own target; it now unpacks into a private directory. Also fold --upx into the cache identity, and tie the --fs-download-cache archive to the URL it came from, so a version bump cannot prune the old tarball and stamp it as the new version.
There was a problem hiding this comment.
🟡 Changes recommended
The advertised Intel build cannot resolve the pinned PyTorch dependency because macOS x86_64 wheels are unavailable.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
doc/overview/INSTALL.md:184
- This advertises Intel support, but the bundled dependency constraints cannot be installed on Intel macOS: both
requirements.txt(torch==2.7.1) and the pyproject fallback (torch==2.7.*,torchvision>=0.22) require releases for which no macOS x86_64 wheels are published. Until the build uses an Intel-compatible dependency set or custom wheels, this requirement should not promise an Intel package.
- Files reviewed: 22/22 changed files
- Comments generated: 1
- Review effort level: Balanced
PyTorch publishes no macOS x86_64 wheels after 2.2, so the bundled environment cannot be resolved for Intel at all; the x86_64 packages released since v2.5.0 have never worked, they only failed later, at pip time on the user's machine. Drop the intel matrix entry and point Intel users at Docker, which runs the amd64 image natively there and is already the documented option for them.
The asset name embedded the version, so no permanent download URL was possible and the docs had to tell people to navigate the releases page. Drop the version from the file name, keep it in the installer window title via PACKAGE_TITLE, and link releases/latest/download/... from INSTALL.md.
workflow_dispatch could already build on any branch, but both asset steps are gated on a release, so the package was built and discarded. Upload it for non-release runs, so the CI build can be tested and the result downloaded without publishing anything.
There was a problem hiding this comment.
🟡 Changes recommended
The build still advertises an unsupported Intel target and temporary FreeSurfer URL sidecars are not cleaned up.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
tools/build/install_fs_pruned.sh:255
- When the default temporary-download branch is used, the tarball is deleted but the newly created
.urlsidecar is not. Each uncached local or CI build therefore leaves a timestampedfreesurfer_*.tar.gz.urlartifact in its working directory. Remove the sidecar together with the temporary archive; only persistent caches need to retain it.
- Files reviewed: 22/22 changed files
- Comments generated: 1
- Review effort level: Balanced
There was a problem hiding this comment.
🟡 Changes recommended
Generated provenance is always marked dirty, and development builds emit an invalid bundle version string.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
tools/macos_build/build_release_package.sh:326
-ois parsed withargparse.FileType("w"), so this file is created beforeversion.pyrunsgit status -sb. Becausetools/macos_build/BUILD.info.gitis not ignored, every package built from an otherwise clean checkout records this generated file as an untracked change and reports a dirty source tree. Create the temporary cache outside the checkout.
- Files reviewed: 22/22 changed files
- Comments generated: 1
- Review effort level: Balanced
CFBundleShortVersionString allows only one to three dot-separated integers, so 2.6.0-dev0 was invalid. Non-release versions become 0.0.0 rather than being truncated to a release that does not exist yet; the identifier keeps the full version.
State one number, the macOS 14 the bundled binaries are compiled for, as a lower bound rather than a support claim. The build-time check enforces that figure, so it fails loudly if a dependency update raises it.
Installer reliability (the #857 root cause)
postinstall.shhad noset -e(masking real failures) and its trailing
xattr -dr com.apple.quarantinecleanup returned non-zerowhenever any file simply never had the attribute, which was virtually always: added
set -eandmade that cleanup non-fatal
link_fs.shsymlinks idempotent (ln -sf) so re-running the installer after a prior attemptdoesn't abort under the new
set -eSelf-contained package
package, so installing needs no network, no Homebrew and no Python on the target machine, and the
first run does not download weights. Dependencies go into the distribution's own site-packages with
no virtual environment in between, since a venv's
pyvenv.cfgand activate scripts do not survivethe move to the install location
tool.python.version(3.12) rather than therequires-pythonfloor, which is only asupport floor and was pulling an unintended interpreter
source tree and both
run_fastsurfer.shand the console put it onPYTHONPATH. Installing it aswell left a second, shadowed copy of every module, which is how the console and the pipeline
previously imported different copies of the same code. The
pyproject.tomlfallback path nowresolves dependencies first and installs those, since installing
$FASTSURFER_HOME[qc]directlyreintroduced exactly that duplication
resolves paths against the FastSurferCNN package it imports, not
$FASTSURFER_HOME, so running thecheckout's copy wrote the weights into the checkout and copied an untouched
--checkpoints-dirintothe package. In CI that directory is the actions/cache path, so it stayed empty on every run, and an
empty
checkpoints/is unrecoverable at runtime because the install tree is root-ownedinstaller; checkpoints are staged explicitly from
--checkpoints-dirBUILD.info, so--versionworks at runtime in apackage that has no git checkout to interrogate. It is retargeted to the install prefix, and its
intermediate cache is written outside the checkout, so the shipped file reports neither
build-machine paths nor a dirty source tree
version-unique
CFBundleIdentifierPlatform support
macos-15(off the now-deprecatedmacos-14).armis the onlyaccepted argument; an
intelinvocation is refused immediately with the reason rather than failingan hour later in dependency resolution
$ARCH_TYPEonly names the package and selects nothing: the build failsif the bundled interpreter's architecture does not match the package name, and if any bundled binary
requires a newer macOS than the documented floor. That floor is the single version the docs state, so
a dependency update raising it fails the build rather than silently invalidating the documentation
explicitly not a tested-support claim, plus Rosetta 2, which the full pipeline needs because
the bundled FreeSurfer is upstream's Intel-only build. Segmentation (
--seg_only) does not use it,and a native arm64 FreeSurfer 8 would remove the requirement entirely
CFBundleShortVersionStringandCFBundleVersion, which accept only one tothree dot-separated integers: a non-release version like
2.6.0-dev0becomes0.0.0rather thanclaiming a release that does not exist yet
FreeSurfer layout
/Applications/freesurferto$FASTSURFER_HOME/fs-pruned, so it can no longer collide with or partially overwrite a realFreeSurfer install (
install_fs_pruned.shgained a--nameoption; Docker's own use of the scriptis unaffected, but we can consider moving it there too, would be cleaner)
$FASTSURFER_HOME/fs-prunedwhen$FREESURFER_HOMEis unset, in theshared
recon_surf/functions.sh(coversrun_fastsurfer.sh,recon-surf.sh, and all otherconsumers); the fallback sources
SetUpFreeSurfer.shrather than only exportingFREESURFER_HOME,so it yields a complete environment
The FastSurfer console
/bin/bashand hands it the setup script as its init file.do scriptruns its text in whatever shell Terminal is configured to start, and the setup scripthas always been bash, so the console failed on the first line for anyone whose Terminal shell was
tcsh or fish. Since
--init-filereplaces~/.bashrc, the script sources that first, keeping theuser's own aliases and settings
PATHin every session rather than only the first, and no longer modifies theuser's shell profile. Persisting only the
PATHentry was a trap, sincerun_fastsurfer.shwouldthen be found while
python3was still Apple's system Python andFREESURFER_HOMEwas unsetset -uorset -e: it no longerexpands
FASTSURFER_CONSOLE,FS_LICENSE,PYTHONPATHorPS1without defaults (PS1is unset inevery non-interactive shell), and it suspends both options across FreeSurfer's own
SetUpFreeSurfer.sh, which is not strict-mode safe, restoring what the caller had. BuildingPYTHONPATHwith${PYTHONPATH:+:...}also drops a trailing colon that put the working directory onsys.pathPYTORCH_ENABLE_MPS_FALLBACK=1on macOS, set inrun_fastsurfer.sh(it must be in placebefore torch is imported, so it cannot live in Python), which also let the redundant export be
dropped from the console template
macOS and BSD portability
PATH, since Apple ships bash 3.2as
/bin/bashwhile a Homebrew bash 5 may be first onPATHtimeoutis unavailable, as on macOS, and stop warning on every timedcommand once timing is known to be unavailable
version.pyhashed thecheckpoints by shelling out to
md5sum(nowhashlib, which also drops a 10 second timeout thathundreds of MB of weights could exceed), and the deployment-target check used
sort -V. Both existon macOS 26 but not on macOS 15, so a local test proves nothing about CI
Build and CI
install_fs_pruned.shskips the work when a valid prunedinstall is already there, and
build_release_package.shgained--fs-download-cache(raw tarball)and
--fs-pruned-cache-dir(pruned install), both cached in CI. Cache identity is the URL plus adigest of the prune script plus
--upx, so editing what gets pruned invalidates it; a partial cacheis not reused (
build-stamp.txtmust exist), a corrupt tarball is deleted rather than stamped valid,a cached tarball is tied by a sidecar to the URL it came from, and extraction now happens in a
private directory so it can never delete a sibling
freesurferinstall--py2app-venv): its dependency scanner walks the wholeenvironment it runs in, so unrelated dev packages (matplotlib) make it fail. Plus build-script fixes
for absolute paths across the
pushd, the resolved--fs-pruned-cache-dir, and installing py2appwhenever it is not importable rather than only when the venv is missing
$HOMEis expanded. They were givenas a literal
~, whichactions/cacheexpands inpathbut which is passed to a step'senvverbatim, so the build wrote to a directory actually named
~inside the workspace while the cacheaction saved and restored one under
$HOME, and neither cache was ever hitreleases/latest/download/FastSurfer-macos-darwin_arm64.pkg. The version moved to the installerwindow title, which is not name-constrained
workflow_dispatchruns upload the installer as a build artifact, so the CI build can be testedwithout publishing a release
Docs
unsigned
.pkg, and the FreeSurfer license situation for package users (where to register, where tostore the file, why autodetection into
fs-prunedwon't work) inINSTALL.mdMACOS.mdentirely; its content is now covered by
INSTALL.md, and it was the last consumer of the file--device mpsfrom thefull-pipeline example: it raises
ValueErroron any Mac without an MPS-capable GPU, while thedefault
autoalready picks MPS where availablesphinx-argparse<0.6, whose 0.6.1 broke command xref resolutionREADME.mdroutes macOS users by architecture, Intel to Docker and Apple silicon to the package.tools/macos_build/README.mddropsinteland the instruction to install py2app by hand, whichthe cached venv makes unnecessary
Verification
Built via
workflow_dispatchonmacos-15, then installed from the resulting artifact and checked on macOS 26. Nothing is runtime-tested on 14 or 15. On the installed package: 11 checkpoints (293 MB), bundled pythonarm64, highest deployment target macOS 14.0,BUILD.infofree of staging paths,run_fastsurfer.sh --versionreporting2.6.0-dev0+5967cb06with no git checkout present, and the console sourcing cleanly underset -euo pipefailwithtorch 2.7.1importing.