Files
CarloandAllen Houchins 0594f653dd Propagate errors in macOS FMA install scripts (#50198)
<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** Resolves #50056

## Summary

macOS FMA install scripts never checked the exit code of the install
command (`installer -pkg` / `cp -R`) — the script's last statement is
always `relaunch_application`, which exits 0 — so a failed install
exited 0 and Fleet reported it installed.

**Generated scripts.** The generator now propagates failure: both
`installer -pkg` variants end with `|| exit $?`, and the `cp -R` path
exits non-zero on a failed copy, removes the partial copy (so a failed
fresh install isn't inventoried as the new version), and restores the
app it moved aside. Regenerated `outputs/` for non-frozen generated apps
are produced by the `ingest-maintained-apps` job, so they aren't
committed here.

**Custom scripts.** 9 of the 18 custom input scripts had the same bug
and are fixed with the same pattern: Google Chrome, Zoom, Microsoft
Edge, GitHub Desktop, Webex, Cycling '74 Max, Pd, Grammarly Desktop, and
P4V. The DMG-based ones also now fail before removing/moving the
existing app when the mount or staging copy fails, so a bad download
can't leave a host with nothing. Their `outputs/*/darwin.json` are
updated in the same commit (script content + recomputed 8-char sha256
ref, versions untouched), following the precedent of #49033. Docker
Desktop (`set -euo pipefail`), 1Password/Slack/LogiTune (installer is
the last statement), and the rest already propagated errors.

**Frozen apps.** The ingest job never rewrites frozen outputs, so the 10
frozen apps with generated scripts (adobe-acrobat-pro, comet, evernote,
firealpaca, keeper-password-manager, nvidia-geforce-now, pritunl,
vnc-viewer, wins, worksheet-crafter) had the fix applied directly to
their published `darwin.json` scripts — the exact text the current
generator would emit, with pinned versions/URLs/hashes untouched. The
11th frozen app (logi-options+) uses a custom script that was already
correct and in sync.

# Checklist for submitter

- [x] Changes file added for user-visible changes in `changes/`
(`changes/50056-fma-install-scripts-ignore-errors`).
- [x] Untrusted data interpolated into shell scripts is validated
against shell metacharacters. (No new untrusted interpolation: the guard
reuses the same curated cask-derived name the adjacent lines already
interpolate.)

## Testing

- [x] Added/updated automated tests (three generator tests: pkg,
pkg-with-choices, cp-R restore — the last now pins the exact emitted
block).
- [x] All 19 updated output manifests validated: embedded scripts pass
`bash -n`, refs match `sha256(script)[:8]`, refs map stays key-sorted
like Go's encoder.
- [x] QA'd all new/changed functionality manually.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Bug Fixes**
* macOS Fleet-maintained app installations now fail fast when
installers, DMG extraction/mounting, or app copy steps error.
* If an upgrade fails, the system removes any partial app and restores
the previously installed version when available.
* Improved robustness during app staging/copying, including safer
handling of paths with spaces or special characters.
* **Tests**
* Added unit coverage to verify installer failure propagation and
rollback behavior.
* **Documentation**
* Clarified that the install-script error handling applies to both
generated and custom scripts, including already-published frozen apps.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Allen Houchins <allenhouchins@mac.com>
2026-07-30 14:22:02 -05:00

59 lines
2.0 KiB
Bash

#!/bin/bash
quit_application() {
local bundle_id="$1"
local timeout_duration=10
local app_running; app_running=$(osascript -e "application id \"$bundle_id\" is running" 2>/dev/null)
if [[ "$app_running" != "true" ]]; then return; fi
local console_user; console_user=$(stat -f "%Su" /dev/console)
if [[ $EUID -eq 0 && "$console_user" == "root" ]]; then echo "Skipping quit for '$bundle_id'."; return; fi
echo "Quitting '$bundle_id'..."
SECONDS=0
while (( SECONDS < timeout_duration )); do
osascript -e "tell application id \"$bundle_id\" to quit" >/dev/null 2>&1 || true
if ! pgrep -f "$bundle_id" >/dev/null 2>&1; then echo "'$bundle_id' quit successfully."; return; fi
sleep 1
done
echo "'$bundle_id' did not quit."
}
[[ -n "$INSTALLER_PATH" && -f "$INSTALLER_PATH" ]] || { echo "missing installer"; exit 1; }
APPDIR="/Applications"
quit_application "com.perforce.p4v"
quit_application "com.perforce.p4merge"
quit_application "com.perforce.p4admin"
MOUNT_POINT="$(hdiutil attach -nobrowse -readonly "$INSTALLER_PATH" | awk '/\/Volumes\//{print $3; exit}')"
[[ -n "$MOUNT_POINT" ]] || { echo "failed to mount dmg"; exit 1; }
for app in p4v.app p4merge.app p4admin.app; do
if [[ -d "$MOUNT_POINT/$app" ]]; then
rm -rf "$APPDIR/$app" >/dev/null 2>&1 || true
if ! ditto "$MOUNT_POINT/$app" "$APPDIR/$app"; then
# remove the partial copy so a failed install isn't inventoried as installed
rm -rf "$APPDIR/$app" >/dev/null 2>&1 || true
hdiutil detach "$MOUNT_POINT" >/dev/null 2>&1 || true
echo "failed to install $app"
exit 1
fi
fi
done
# Install p4vc command line binary to /usr/local/bin
if [[ -f "$MOUNT_POINT/p4vc" ]]; then
mkdir -p /usr/local/bin
if ! cp "$MOUNT_POINT/p4vc" /usr/local/bin/p4vc; then
hdiutil detach "$MOUNT_POINT" >/dev/null 2>&1 || true
echo "failed to install p4vc"
exit 1
fi
chmod +x /usr/local/bin/p4vc
chown root:wheel /usr/local/bin/p4vc
fi
hdiutil detach "$MOUNT_POINT" >/dev/null 2>&1 || true
echo "p4v installed"