Skip to content

Only show Nudge once an update is prepared - #51546

Merged
allenhouchins merged 21 commits into
mainfrom
dogfood-nudge-ddm-prepared-gate
Oct 20, 2026
Merged

allenhouchins merged 21 commits into
mainfrom
dogfood-nudge-ddm-prepared-gate

Conversation

@allenhouchins

@allenhouchins allenhouchins commented Aug 19, 2026 •

Copy link
Copy Markdown
Member

Dogfood-only GitOps change. On supervised Macs running macOS 26 or later, Nudge now appears only after macOS has already downloaded and staged an update, so an end user who clicks Update gets a quick install instead of sitting through a download first. Macs that can't use that gate keep getting Nudge's preferences unconditionally, as they do today.

What changed

The old nudge-configuration.mobileconfig carried two payloads: Nudge's preferences and a com.apple.servicemanagement rule that keeps Nudge's launch agent a managed login item. Only the preferences should be gated on a staged update, so the profile is split in two.

  • lib/macos/configuration-profiles/nudge-background-task.mobileconfig (new) — the com.apple.servicemanagement rule for com.github.macadmins.Nudge, on its own. Fleet delivers it unconditionally, same pattern as google-updater-background-task.mobileconfig and fleet-desktop-launch-agent.mobileconfig.
  • lib/macos/configuration-profiles/nudge-preferences.mobileconfig (renamed from nudge-configuration.mobileconfig) — the Nudge preferences payload only, with a new PayloadIdentifier (com.fleetdm.profile.nudge-preferences) and fresh UUIDs. This file is no longer delivered by Fleet; it is the hosted payload the declaration below points at. Its settings match main, including the macOS 15 latest-supported rule and 2026-12-13 deadline from Nudge: add macOS 27 target, OS-specific update URLs, and macOS 15 upgrade deadline #54130.
  • lib/macos/configuration-profiles/nudge-preferences-ungated.mobileconfig (new) — the same settings with its own identifier (com.fleetdm.profile.nudge-preferences-ungated), delivered directly by Fleet to the Macs that skip the gate.
  • lib/macos/declaration-profiles/Nudge settings.json (new) — a com.apple.configuration.legacy declaration whose ProfileURL points at nudge-preferences.mobileconfig, pinned to a commit.
  • lib/macos/declaration-profiles/Nudge activation.json (new) — a com.apple.activation.simple activation gating that declaration on @status(softwareupdate.install-state) == 'prepared'.
  • lib/all/labels/macos-15-and-older-hosts.yml and lib/all/labels/macs-not-enrolled-through-ade.yml (new) — dynamic labels for the Macs that skip the gate, added to default.yml.
  • fleets/workstations.yml — nudge-configuration.mobileconfig comes out of configuration_profiles. In go nudge-background-task.mobileconfig, the declaration (with activation: attached, excluding both labels), and the ungated profile (including both labels).

Which Macs skip the gate

  • Macs not enrolled through ADE. softwareupdate.install-state is supervised-enrollments-only on macOS, and macOS only supervises ADE enrollments, so the predicate could never be true. The label uses the macadmins mdm table (installed_from_dep = 'false'); a Mac reporting unknown stays on the gated path.
  • macOS 15 and older. Their rule is a major-upgrade deadline. Nothing in this config makes macOS stage a major upgrade, so a fully patched macOS 15 Mac would never reach prepared. Nudge's own attemptToFetchMajorUpgrade can't help either, because Nudge only gets its settings once the gate opens.

The ungated copy needs its own identifier for the same reason the hosted one does (below): a Mac that moves between the two paths, such as one upgrading from macOS 15 to 27, gets a RemoveProfile for one identifier and an install for the other, and they must not collide. The YAML comment in workstations.yml says to keep both files' settings identical.

Why the identifier changed

The same GitOps apply that adds the declaration also removes the old com.fleetdm.nudge.managed profile, which queues a RemoveProfile for that identifier. MDM commands and the DDM channel are delivered independently, so if the device applied the legacy declaration before draining the command queue, a hosted profile with the same identifier would have been deleted by the removal — with the declaration still reporting Verified and nothing ever re-applying it. Giving the hosted profile its own identifier means the removal can only ever touch the old copy.

Why the background-task rule is separate

If the servicemanagement payload were inside the gated profile, it would be installed and removed on every update cycle: a recurring "Background Items Added" notification for every user, and a window where they could disable Nudge's login item. Keeping it in an always-on profile means it does not matter when, or whether, the Nudge preferences activate.

Why it's shaped this way

DDM activations gate declarations, and Nudge's settings are a mobileconfig, so the profile has to be wrapped in com.apple.configuration.legacy. That declaration's ProfileURL must be an https:// URL, and Fleet has no endpoint that serves a mobileconfig, so this points at the file's own raw.githubusercontent.com URL.

On hosting: Apple's v26 schema said the URL "must be hosted by the MDM server." The Release-v27.0 schema removed that and now says the request "uses MDM semantics, which includes the device-identity certificate." An Apple engineer on the developer forums describes ProfileURL as expected to be on the MDM server, with the device presenting its identity certificate. GitHub doesn't request a client certificate, so the fetch should succeed, but Apple doesn't document this setup. On macOS 27, ProfileAssetReference with an asset using None authentication is Apple's supported way to host the profile elsewhere, and it verifies a hash. That's the path to move to once these Macs are all on 27.

The ProfileURL is pinned to a commit SHA, not main. A device only re-downloads ProfileURL when the declaration's ServerToken changes, and editing the mobileconfig doesn't touch the declaration. Pointing at main would mean edits to Nudge's settings silently never reach devices. Pinning to the commit that last changed nudge-preferences.mobileconfig makes the URL immutable and gives every edit a new URL for a real reason: after changing the mobileconfig, commit it, then update the SHA in Nudge settings.json to that commit. Forgetting the second step leaves devices on the old copy. This is documented in a YAML comment beside the entry in workstations.yml, since the next person to edit Nudge settings will open the mobileconfig, not the declaration. Commits on a squash-merged PR branch keep resolving on raw.githubusercontent.com after the branch is deleted (checked against #54130's head commit).

What I verified

  • All three mobileconfigs pass plutil -lint and Fleet's own NewMDMAppleConfigProfile and ValidateUserProvided (which runs ScreenPayloads) checks.
  • Both JSON files pass Fleet's own validators — MDMAppleRawDeclaration.ValidateUserProvided and MDMAppleRawActivation.ValidateUserProvided against the declaration's identifier.
  • default.yml and workstations.yml parse through spec.GitOpsFromFile with no profile or label errors.
  • The settings in nudge-preferences.mobileconfig and nudge-preferences-ungated.mobileconfig match main's nudge-configuration.mobileconfig apart from identifiers, UUIDs, and the removed servicemanagement payload.
  • The pinned URL returns 200 and serves a file identical to nudge-preferences.mobileconfig on this branch, and a trial merge with main (git merge-tree) is clean and leaves that file unchanged.
  • 'prepared' is the exact lowercase value in Apple's softwareupdate.install-state schema.
  • Fleet advertises a custom activation instead of the synthesized one, so the gate is real; a false predicate shows the host as Verified with a "predicate evaluated to false" detail rather than a failure.

Please don't merge before a device test

Three things I couldn't check without hardware, roughly in order of risk:

  1. Fetching from GitHub. The device makes an MDM-style request to a host that isn't the MDM server, and GitHub serves the profile as text/plain with x-content-type-options: nosniff. Both will probably just work, but if not, the device should report the configuration invalid and Fleet shows it as Failed.
  2. Does install-state actually reach prepared here? macos_updates is empty in workstations.yml, so nothing enforces updates via DDM; this relies on AutomaticActions.Download: AlwaysOn from Software Update settings.json. Apple's install-reason schema lists auto-update as a valid trigger, so it should — but if not, we'd need an enforcement declaration to drive the download. A false predicate looks like Verified in Fleet, so the test needs to confirm a Mac with a staged update actually receives the preferences profile.
  3. Label membership. Check that ADE-enrolled Macs on macOS 26+ are in neither label and that manually enrolled Macs and macOS 15 Macs are, so each Mac gets exactly one copy of the preferences.

Checklist

  • QA'd all new/changed functionality manually — not done, see the section above.

No changes file: dogfood GitOps config, not a user-visible product change.

AI

AI: Claude Code (claude-opus-5-5)

Summary by CodeRabbit

  • New Features
    • Added a dedicated macOS profile for Nudge’s background task.
    • Nudge settings are activated when a software update reaches the prepared state.
  • Updates
    • Updated Nudge’s macOS requirements with support links for macOS 26 and macOS 27.
    • Nudge preferences are fetched from a commit-pinned profile URL.

Nudge currently starts nagging as soon as a device is behind, which means
the end user clicks Update and then waits through a download. Gate it on
the OS having already staged the update, so the install is quick when they
finally act.

DDM activations only gate declarations, so the existing Nudge mobileconfig
is wrapped in a com.apple.configuration.legacy declaration and paired with
a com.apple.activation.simple activation whose predicate is
@status(softwareupdate.install-state) == 'prepared'. The mobileconfig comes
out of configuration_profiles and stays in the repo as the hosted payload.

Apple only re-fetches ProfileURL when the declaration's ServerToken changes,
so the URL carries a ?v=N cache-buster: bump it whenever the mobileconfig
changes or devices keep the old profile.
@allenhouchins allenhouchins changed the title Dogfood: only show Nudge once an update is prepared Only show Nudge once an update is prepared Aug 19, 2026
fleet-release
fleet-release previously approved these changes Sep 3, 2026
…he hosted preferences profile gets its own identifier
fleet-release
fleet-release previously approved these changes Sep 5, 2026
Removed comments regarding profile delivery and updates.
fleet-release
fleet-release previously approved these changes Sep 6, 2026
Condensed comments regarding Nudge preferences and their activation process.
fleet-release
fleet-release previously approved these changes Sep 6, 2026
fleet-release
fleet-release previously approved these changes Sep 25, 2026
allenhouchins and others added 2 commits October 9, 2026 08:59
The staged-update gate can't open on Macs that aren't supervised (no
softwareupdate.install-state) and doesn't fit macOS 15, whose deadline is
a major upgrade. Those Macs get an ungated copy of the preferences, with
its own identifier, via two new labels; the declaration excludes them.

Also ports the macOS 15 latest-supported rule and 2026-12-13 deadline
from #54130 into nudge-preferences.mobileconfig.

Co-Authored-By: Claude <noreply@anthropic.com>
The previous pin (57ffa20) predated the macOS 27 aboutUpdateURL fix and
the macOS 15 deadline.

Co-Authored-By: Claude <noreply@anthropic.com>
fleet-release
fleet-release previously approved these changes Oct 9, 2026
fleet-release
fleet-release previously approved these changes Oct 9, 2026
Co-Authored-By: Claude <noreply@anthropic.com>
fleet-release
fleet-release previously approved these changes Oct 9, 2026
Co-Authored-By: Claude <noreply@anthropic.com>
fleet-release
fleet-release previously approved these changes Oct 9, 2026
# Conflicts:
#	it-and-security/default.yml
@allenhouchins
allenhouchins marked this pull request as ready for review October 20, 2026 14:29
@allenhouchins
allenhouchins merged commit e3e6682 into main Oct 20, 2026
9 of 10 checks passed
@allenhouchins
allenhouchins deleted the dogfood-nudge-ddm-prepared-gate branch October 20, 2026 14:29

This branch was previously deployed

1 inactive deployment
Docker Hub — 148cf6f7 Deployed Oct 19, 2026 by allenhouchins via publish #109706
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants