Support custom tap folder for apps without casks; add three apps (#43784)

This pull request introduces support for ingesting Homebrew casks from
third-party taps (not available in the official
`Homebrew/homebrew-cask`) into the Fleet Maintained Apps (FMA) system.
It does this by allowing cask metadata to be committed directly into the
repository and referenced via a new `cask_path` field. The PR also
updates CI workflows to better support Fleet Desktop validation and
documents the new contributor flow.

**Support for custom Homebrew casks:**

* Added a new `cask_path` field to app manifests, allowing the FMA
ingester to read cask metadata from a local JSON file instead of
fetching from the Homebrew API. This enables ingestion of apps from
third-party taps or custom casks not present in the official Homebrew
repository.
[[1]](diffhunk://#diff-be469dd148f0c50ad56489c48bdb514522e1a46d21336e8f747b5880d71a6d1bR49-R66)
[[2]](diffhunk://#diff-abd7db4bef16a062c1bd81f54a7c846f1e91b913a9fe9f87976c8075f39b8cd2R270-R276)
* Refactored the Homebrew ingester (`brewIngester`) to use a new
`fetchCask` helper, which reads from the local file if `cask_path` is
set, or falls back to the API otherwise. Includes robust error handling.
[[1]](diffhunk://#diff-abd7db4bef16a062c1bd81f54a7c846f1e91b913a9fe9f87976c8075f39b8cd2L99-R101)
[[2]](diffhunk://#diff-abd7db4bef16a062c1bd81f54a7c846f1e91b913a9fe9f87976c8075f39b8cd2R200-R251)
* Added comprehensive documentation and examples for the custom tap
workflow, including a new `custom-tap/` directory with cask DSL sources,
generated JSON, and a regeneration script.
[[1]](diffhunk://#diff-2dfa2fc79b9becad555db38289a16afe4ce651665a31868d386fed8b4e160740R1-R85)
[[2]](diffhunk://#diff-be469dd148f0c50ad56489c48bdb514522e1a46d21336e8f747b5880d71a6d1bR49-R66)
* Added new custom casks for `fleet-desktop`, `druva-insync`, and
`zoom-rooms` under `inputs/homebrew/custom-tap/Casks/`.
[[1]](diffhunk://#diff-2555a54830de2bfb0ffca8bc487aac67de84dee5d431fe5f42e90e1754f63bb6R1-R36)
[[2]](diffhunk://#diff-db1fa8a43a27c5adf49a5ade04e61405ce1e9420f266e3160156cabf69ed4ea8R1-R40)
[[3]](diffhunk://#diff-effd461583140683d41dc68d9a93692d039be5ad5e52b6b108ece79f17155107R1-R44)

**Testing and validation:**

* Added a new test (`TestIngestCaskPath`) to ensure the ingester
correctly reads from `cask_path` and does not make unnecessary HTTP
requests, with error handling for missing files.

**CI workflow improvements:**

* Updated GitHub Actions workflows to handle Fleet Desktop's installer
requirements in CI by creating a managed preferences stub when
validating Fleet Desktop, ensuring the installer succeeds even without
MDM enrollment.
[[1]](diffhunk://#diff-28b30c8601cb7662d59efbfbbcf800cae91455fd3d875627659dced8c1257a24R100)
[[2]](diffhunk://#diff-28b30c8601cb7662d59efbfbbcf800cae91455fd3d875627659dced8c1257a24R116-R123)
[[3]](diffhunk://#diff-28b30c8601cb7662d59efbfbbcf800cae91455fd3d875627659dced8c1257a24R148-R172)
[[4]](diffhunk://#diff-c263ffc3062c3b5e4e4eb65976080c6cbddac478a5fed3392fe8b23c49bb2da8R69-R92)

These changes make it possible to maintain and test apps from custom
Homebrew taps within the Fleet repo, improving flexibility and
reliability for Fleet-maintained apps.

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

* **New Features**
* Added support for three new macOS apps: Fleet Desktop, Druva inSync,
and Zoom Rooms
  * Added UI icons for Fleet Desktop and Zoom Rooms
* **Enhancements**
* Fleet Desktop includes an MDM enrollment caveat and improved installer
validation for macOS installers
* Support for overriding Homebrew cask input via a local cask JSON file
* **Tests**
  * Added unit coverage for local cask JSON ingestion behavior
* **Chores**
* Added a deterministic script to regenerate Homebrew custom-tap
manifests
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Allen Houchins
2026-04-29 20:30:51 -05:00
committed by GitHub
parent a67f14d388
commit 6ff485fcdd
26 changed files with 949 additions and 32 deletions
@@ -97,6 +97,7 @@ jobs:
if [ -z "$DARWIN_SLUGS" ]; then
echo "has_darwin_apps=false" >> $GITHUB_OUTPUT
echo "has_google_chrome=false" >> $GITHUB_OUTPUT
echo "has_fleet_desktop=false" >> $GITHUB_OUTPUT
echo "No darwin apps changed, skipping Darwin workflow"
else
echo "has_darwin_apps=true" >> $GITHUB_OUTPUT
@@ -112,6 +113,14 @@ jobs:
else
echo "has_google_chrome=false" >> $GITHUB_OUTPUT
fi
# Check if fleet-desktop/darwin is in the changed apps
if echo "$DARWIN_SLUGS" | grep -q "^fleet-desktop/darwin$"; then
echo "has_fleet_desktop=true" >> $GITHUB_OUTPUT
echo "Fleet Desktop detected in changed apps"
else
echo "has_fleet_desktop=false" >> $GITHUB_OUTPUT
fi
fi
shell: bash
@@ -136,6 +145,31 @@ jobs:
sudo rm -rf "$app"
done
# Fleet Desktop's installer refuses to run unless the
# com.fleetdm.fleetd.config managed preferences profile is present
# (it's normally delivered via MDM). CI runners aren't MDM-enrolled,
# so we drop a stub plist in place before the validate step so the
# install script succeeds. This only runs when fleet-desktop/darwin
# is actually being validated in this PR.
- name: Create Fleet Desktop MDM config stub (CI-only)
if: steps.check-darwin-apps.outputs.has_darwin_apps == 'true' && steps.check-darwin-apps.outputs.has_fleet_desktop == 'true'
run: |
sudo mkdir -p "/Library/Managed Preferences"
sudo tee "/Library/Managed Preferences/com.fleetdm.fleetd.config.plist" > /dev/null <<'PLIST'
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>EnrollSecret</key>
<string>ci-test-placeholder</string>
<key>FleetURL</key>
<string>https://ci.test.example.com</string>
</dict>
</plist>
PLIST
sudo chmod 644 "/Library/Managed Preferences/com.fleetdm.fleetd.config.plist"
ls -l "/Library/Managed Preferences/com.fleetdm.fleetd.config.plist"
- name: Filter apps.json and verify changed apps
if: steps.check-darwin-apps.outputs.has_darwin_apps == 'true'
run: |
+24
View File
@@ -66,6 +66,30 @@ jobs:
sudo rm -rf "$app"
done
# Fleet Desktop's installer refuses to run unless the
# com.fleetdm.fleetd.config managed preferences profile is present
# (it's normally delivered via MDM). CI runners aren't MDM-enrolled,
# so we drop a stub plist in place before the validate step so the
# install script succeeds. This workflow validates every FMA every
# run, so fleet-desktop is always exercised.
- name: Create Fleet Desktop MDM config stub (CI-only)
run: |
sudo mkdir -p "/Library/Managed Preferences"
sudo tee "/Library/Managed Preferences/com.fleetdm.fleetd.config.plist" > /dev/null <<'PLIST'
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>EnrollSecret</key>
<string>ci-test-placeholder</string>
<key>FleetURL</key>
<string>https://ci.test.example.com</string>
</dict>
</plist>
PLIST
sudo chmod 644 "/Library/Managed Preferences/com.fleetdm.fleetd.config.plist"
ls -l "/Library/Managed Preferences/com.fleetdm.fleetd.config.plist"
- name: Verify Fleet Maintained Apps mac
run: |
ls /Applications
+18
View File
@@ -46,6 +46,24 @@
| `post_uninstall_scripts` | string | Command lines run **after** the generated uninstall script (e.g., for [Box](inputs/homebrew/box-drive.json)). |
| `install_script_path` | string | Filepath to a custom install script (`.sh`). Overrides the generated install script. Script must be placed in `inputs/homebrew/scripts/`. |
| `uninstall_script_path` | string | Filepath to a custom uninstall script (`.sh`). Overrides the generated uninstall script. Cannot be used together with `pre_uninstall_scripts` or `post_uninstall_scripts`. Script must be placed in `inputs/homebrew/scripts/`. |
| `cask_path` | string | Path (relative to the repo root) to a local file containing the cask JSON in the same schema as `https://formulae.brew.sh/api/cask/<token>.json`. Used to commit cask metadata for third-party taps directly into this repo under [`inputs/homebrew/custom-tap/`](inputs/homebrew/custom-tap/). See [Ingesting apps from a custom tap](#ingesting-apps-from-a-custom-tap) below. |
### Ingesting apps from a custom tap
Apps that live in a third-party Homebrew tap (not `Homebrew/homebrew-cask`) are not proxied by `https://formulae.brew.sh/api/`. To ingest them, commit both the `.rb` source and the generated `.json` into [`inputs/homebrew/custom-tap/`](inputs/homebrew/custom-tap/), laid out like a Homebrew tap:
```
custom-tap/
├── Casks/<token>.rb # Cask DSL source
├── api/<token>.json # Generated with regenerate.sh
└── regenerate.sh # Rebuild api/*.json from Casks/*.rb
```
1. Write the cask DSL in `inputs/homebrew/custom-tap/Casks/<token>.rb`.
2. Run `./regenerate.sh` from inside `custom-tap/` to produce `api/<token>.json`. Requires macOS with Homebrew and `jq`.
3. In the app's input manifest (`inputs/homebrew/<token>.json`), set `cask_path` to `ee/maintained-apps/inputs/homebrew/custom-tap/api/<token>.json`. See `inputs/homebrew/fleet-desktop.json` for an example.
See [`inputs/homebrew/custom-tap/README.md`](inputs/homebrew/custom-tap/README.md) for the full contributor flow. Apps without `cask_path` continue to be fetched from `formulae.brew.sh`.
## Adding a new app (Windows)
@@ -96,39 +96,9 @@ type brewIngester struct {
}
func (i *brewIngester) ingestOne(ctx context.Context, input inputApp) (*maintained_apps.FMAManifestApp, error) {
apiURL := fmt.Sprintf("%scask/%s.json", i.baseURL, input.Token)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil)
cask, err := i.fetchCask(ctx, input)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "create http request")
}
res, err := i.client.Do(req)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "execute http request")
}
defer res.Body.Close()
body, err := io.ReadAll(res.Body)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "read http response body")
}
switch res.StatusCode {
case http.StatusOK:
// success, go on
case http.StatusNotFound:
return nil, ctxerr.New(ctx, "app not found in brew API")
default:
if len(body) > 512 {
body = body[:512]
}
return nil, ctxerr.Errorf(ctx, "brew API returned status %d: %s", res.StatusCode, string(body))
}
var cask brewCask
if err := json.Unmarshal(body, &cask); err != nil {
return nil, ctxerr.Wrapf(ctx, err, "unmarshal brew cask for %s", input.Token)
return nil, err
}
out := &maintained_apps.FMAManifestApp{}
@@ -227,6 +197,66 @@ func (i *brewIngester) ingestOne(ctx context.Context, input inputApp) (*maintain
return out, nil
}
// fetchCask resolves the brew cask JSON for the given input app from
// either a local file (cask_path) or the default brew API.
func (i *brewIngester) fetchCask(ctx context.Context, input inputApp) (brewCask, error) {
var cask brewCask
if input.CaskPath != "" {
body, err := os.ReadFile(input.CaskPath)
if err != nil {
return cask, ctxerr.WrapWithData(ctx, err, "reading local cask JSON file", map[string]any{"cask_path": input.CaskPath})
}
if err := json.Unmarshal(body, &cask); err != nil {
return cask, ctxerr.Wrapf(ctx, err, "unmarshal local cask JSON for %s", input.Token)
}
// Cross-check the cask file matches the configured input. This catches
// subtle misconfiguration like pointing cask_path at the wrong JSON file.
if cask.Token != input.Token {
return cask, ctxerr.Errorf(ctx, "local cask JSON token %q does not match input token %q (cask_path: %s)", cask.Token, input.Token, input.CaskPath)
}
if len(cask.Name) == 0 {
return cask, ctxerr.Errorf(ctx, "local cask JSON for %s has empty name (cask_path: %s)", input.Token, input.CaskPath)
}
return cask, nil
}
apiURL := fmt.Sprintf("%scask/%s.json", i.baseURL, input.Token)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil)
if err != nil {
return cask, ctxerr.Wrap(ctx, err, "create http request")
}
res, err := i.client.Do(req)
if err != nil {
return cask, ctxerr.Wrap(ctx, err, "execute http request")
}
defer res.Body.Close()
body, err := io.ReadAll(res.Body)
if err != nil {
return cask, ctxerr.Wrap(ctx, err, "read http response body")
}
switch res.StatusCode {
case http.StatusOK:
// success, go on
case http.StatusNotFound:
return cask, ctxerr.New(ctx, "app not found in brew API")
default:
if len(body) > 512 {
body = body[:512]
}
return cask, ctxerr.Errorf(ctx, "brew API returned status %d: %s", res.StatusCode, string(body))
}
if err := json.Unmarshal(body, &cask); err != nil {
return cask, ctxerr.Wrapf(ctx, err, "unmarshal brew cask for %s", input.Token)
}
return cask, nil
}
type inputApp struct {
// Name is the user-friendly name of the app.
Name string `json:"name"`
@@ -245,6 +275,13 @@ type inputApp struct {
InstallScriptPath string `json:"install_script_path"`
UninstallScriptPath string `json:"uninstall_script_path"`
PatchPolicyPath string `json:"patch_policy_path"`
// CaskPath optionally points at a local file (relative to the repo
// root) containing the cask JSON in the same schema as
// https://formulae.brew.sh/api/cask/<token>.json. Used to commit cask
// metadata for third-party taps directly into this repo (see
// inputs/homebrew/custom-tap/). When empty, the ingester fetches from
// formulae.brew.sh.
CaskPath string `json:"cask_path"`
}
type brewCask struct {
@@ -157,3 +157,106 @@ func TestIngestValidations(t *testing.T) {
})
}
}
// TestIngestCaskPath verifies that when an input app sets cask_path, the
// ingester reads cask JSON from that local file and makes no HTTP call.
// This is the path used for casks committed into inputs/homebrew/custom-tap/.
func TestIngestCaskPath(t *testing.T) {
tempDir := t.TempDir()
caskJSON, err := json.Marshal(brewCask{
Token: "local-cask",
Name: []string{"Local Cask"},
URL: "https://example.com/local/installer.pkg",
Version: "9.9.9",
SHA256: "deadbeef",
})
require.NoError(t, err)
caskPath := path.Join(tempDir, "local-cask.json")
require.NoError(t, os.WriteFile(caskPath, caskJSON, 0o644))
// Server that should never be called when cask_path is set; any hit is a
// bug because it means the ingester fell back to HTTP.
var httpHits int
srv := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
httpHits++
}))
t.Cleanup(srv.Close)
ctx := context.Background()
i := &brewIngester{
logger: slog.New(slog.DiscardHandler),
client: fleethttp.NewClient(fleethttp.WithTimeout(10 * time.Second)),
baseURL: srv.URL + "/",
}
out, err := i.ingestOne(ctx, inputApp{
Token: "local-cask",
UniqueIdentifier: "com.example.localcask",
InstallerFormat: "pkg",
Name: "Local Cask",
CaskPath: caskPath,
})
require.NoError(t, err)
require.Equal(t, "https://example.com/local/installer.pkg", out.InstallerURL)
require.Equal(t, "9.9.9", out.Version)
require.Equal(t, "deadbeef", out.SHA256)
require.Equal(t, 0, httpHits, "cask_path path must not make an HTTP call")
// Missing file yields an actionable error.
_, err = i.ingestOne(ctx, inputApp{
Token: "missing",
UniqueIdentifier: "com.example.missing",
InstallerFormat: "pkg",
Name: "Missing",
CaskPath: path.Join(tempDir, "does-not-exist.json"),
})
require.ErrorContains(t, err, "reading local cask JSON file")
require.Equal(t, 0, httpHits)
// Token mismatch between input and cask file is rejected so a misconfigured
// cask_path can't silently ingest the wrong app.
mismatchPath := path.Join(tempDir, "mismatch.json")
mismatchJSON, err := json.Marshal(brewCask{
Token: "some-other-cask",
Name: []string{"Some Other Cask"},
URL: "https://example.com/other/installer.pkg",
Version: "1.0.0",
SHA256: "cafebabe",
})
require.NoError(t, err)
require.NoError(t, os.WriteFile(mismatchPath, mismatchJSON, 0o644))
_, err = i.ingestOne(ctx, inputApp{
Token: "local-cask",
UniqueIdentifier: "com.example.localcask",
InstallerFormat: "pkg",
Name: "Local Cask",
CaskPath: mismatchPath,
})
require.ErrorContains(t, err, "does not match input token")
require.Equal(t, 0, httpHits)
// Cask file with an empty name is rejected.
emptyNamePath := path.Join(tempDir, "empty-name.json")
emptyNameJSON, err := json.Marshal(brewCask{
Token: "local-cask",
Name: []string{},
URL: "https://example.com/local/installer.pkg",
Version: "9.9.9",
SHA256: "deadbeef",
})
require.NoError(t, err)
require.NoError(t, os.WriteFile(emptyNamePath, emptyNameJSON, 0o644))
_, err = i.ingestOne(ctx, inputApp{
Token: "local-cask",
UniqueIdentifier: "com.example.localcask",
InstallerFormat: "pkg",
Name: "Local Cask",
CaskPath: emptyNamePath,
})
require.ErrorContains(t, err, "empty name")
require.Equal(t, 0, httpHits)
}
@@ -0,0 +1,40 @@
cask "druva-insync" do
version "7.6.1,110931"
sha256 "a67784b4d6789e9a671e2d77789c408116b10c89c6c8893c3f08ed6212684bf2"
url "https://downloads.druva.com/downloads/inSync/MAC/#{version.csv.first}/inSync-#{version.csv.first}-r#{version.csv.second}.dmg"
name "Druva inSync"
desc "Endpoint data backup and recovery client"
homepage "https://www.druva.com/"
livecheck do
skip "Druva does not expose a parseable version feed; bump manually"
end
depends_on macos: ">= :big_sur"
pkg "Install inSync.pkg"
uninstall launchctl: [
"com.druva.inSyncAgent",
"com.druva.inSyncDecom",
"com.druva.inSyncUpgrade",
"com.druva.inSyncUpgradeDaemon",
],
quit: "com.druva.inSyncClient",
pkgutil: "com.druva.inSync.pkg",
delete: [
"/Library/LaunchAgents/inSyncAgent.plist",
"/Library/LaunchAgents/inSyncUpgrade.plist",
"/Library/LaunchDaemons/inSyncDecommission.plist",
"/Library/LaunchDaemons/inSyncUpgradeDaemon.plist",
]
zap trash: [
"~/Library/Application Support/Druva",
"~/Library/Caches/com.druva.inSyncClient",
"~/Library/Logs/Druva",
"~/Library/Preferences/com.druva.inSyncClient.plist",
"~/Library/Saved Application State/com.druva.inSyncClient.savedState",
]
end
@@ -0,0 +1,36 @@
cask "fleet-desktop" do
version "1.1.0"
sha256 "4f3537c37a094f333046072262b1f37729f73074bf935f09f05799bc341fef58"
url "https://github.com/allenhouchins/fleet-desktop/releases/download/v#{version}/fleet_desktop-v#{version}.pkg"
name "Fleet Desktop"
desc "End-user client for Fleet device management"
homepage "https://github.com/allenhouchins/fleet-desktop"
livecheck do
url :url
strategy :github_latest
end
depends_on macos: ">= :ventura"
pkg "fleet_desktop-v#{version}.pkg"
uninstall quit: "com.fleetdm.fleet-desktop",
pkgutil: "com.fleetdm.fleet-desktop"
zap trash: [
"~/Library/Caches/com.fleetdm.fleet-desktop",
"~/Library/HTTPStorages/com.fleetdm.fleet-desktop",
"~/Library/HTTPStorages/com.fleetdm.fleet-desktop.binarycookies",
"~/Library/Preferences/com.fleetdm.fleet-desktop.plist",
"~/Library/Saved Application State/com.fleetdm.fleet-desktop.savedState",
"~/Library/WebKit/com.fleetdm.fleet-desktop",
]
caveats <<~EOS
Fleet Desktop requires the Mac to be enrolled in MDM with the
com.fleetdm.fleetd.config managed preferences profile. The installer
will fail with "Installation Failed" otherwise.
EOS
end
@@ -0,0 +1,44 @@
cask "zoom-rooms" do
version "7.0.0.12322"
sha256 "c35229e6066732aec7f26b762d0f2d43ab7b8270e512fbabe5bbb77a9c714bbc"
url "https://cdn.zoom.us/prod/#{version}/ZoomRooms.pkg"
name "Zoom Rooms"
desc "Conference room software for Zoom meetings"
homepage "https://www.zoom.com/en/products/zoom-rooms/"
livecheck do
skip "Zoom does not expose a parseable Zoom Rooms version feed; bump manually"
end
depends_on macos: ">= :catalina"
pkg "ZoomRooms.pkg"
# The product is branded "Zoom Rooms" but the installer drops the app at
# /Applications/ZoomPresence.app with the legacy bundle id us.zoom.ZoomPresence.
uninstall launchctl: [
"us.zoom.rooms.daemon",
"us.zoom.rooms.tool",
],
quit: "us.zoom.ZoomPresence",
pkgutil: "us.zoom.pkg.zp",
delete: [
"/Applications/ZoomPresence.app",
"/Library/LaunchDaemons/us.zoom.rooms.daemon.plist",
"/Library/LaunchDaemons/us.zoom.rooms.tool.plist",
"/Library/PrivilegedHelperTools/us.zoom.ZoomRoomsDaemon",
"/Library/Logs/us.zoom.ZoomRoomUpdateRecord",
"/Library/Logs/zpinstall.log",
]
zap trash: [
"~/Library/Application Support/ZoomPresence",
"~/Library/Caches/us.zoom.ZoomPresence",
"~/Library/HTTPStorages/us.zoom.ZoomPresence",
"~/Library/HTTPStorages/us.zoom.ZoomPresence.binarycookies",
"~/Library/Preferences/us.zoom.ZoomPresence.plist",
"~/Library/Saved Application State/us.zoom.ZoomPresence.savedState",
"~/Library/WebKit/us.zoom.ZoomPresence",
]
end
@@ -0,0 +1,85 @@
# Fleet custom-tap casks
This directory is a **self-contained source of truth** for Fleet-maintained apps that
don't exist in `Homebrew/homebrew-cask` and therefore can't be ingested from
`https://formulae.brew.sh/api/`.
It is laid out like a Homebrew tap:
```
custom-tap/
├── Casks/ # Cask DSL sources (.rb). Edit these.
│ ├── fleet-desktop.rb
│ └── druva-insync.rb
├── api/ # Generated cask metadata (.json). Do not hand-edit.
│ ├── fleet-desktop.json
│ └── druva-insync.json
├── regenerate.sh # Regenerates api/*.json from Casks/*.rb.
└── README.md # You are here.
```
It is **not** a real Homebrew tap — the Fleet repo isn't named
`homebrew-<something>` and `Casks/` isn't at the repo root, so `brew tap` /
`brew install` against it won't work. It exists solely to feed Fleet's FMA
ingester, and keeping both the source (`.rb`) and the built artifact (`.json`)
in the same repo means the PR that changes a cask also tests the change in
CI.
## How it hooks into the FMA ingester
Each app here has an input manifest one directory up
(`../<token>.json`) with a `cask_path` field pointing at the generated JSON.
Example — [`../fleet-desktop.json`](../fleet-desktop.json):
```json
{
"name": "Fleet Desktop",
"token": "fleet-desktop",
"cask_path": "ee/maintained-apps/inputs/homebrew/custom-tap/api/fleet-desktop.json",
...
}
```
When `go run cmd/maintained-apps/main.go` runs, the ingester sees `cask_path`,
reads the local file, and skips the brew API entirely. Apps without
`cask_path` continue to use `https://formulae.brew.sh/api/` as before.
## Adding a new cask
1. Write the cask DSL in `Casks/<token>.rb`. Use an existing file or
<https://docs.brew.sh/Cask-Cookbook> as a reference.
2. Run `./regenerate.sh` in this directory to produce `api/<token>.json`.
3. Create an input manifest at
`ee/maintained-apps/inputs/homebrew/<token>.json` that points
`cask_path` at the new JSON. Follow the template in
`../fleet-desktop.json`.
4. Generate the FMA output manifest:
`go run cmd/maintained-apps/main.go --slug="<token>/darwin"` from the repo
root.
5. Follow the rest of the FMA contributor flow in
[`../../../README.md`](../../../README.md) (apps.json description, icon,
PR).
## Updating an existing cask
1. Edit the stanza you care about in `Casks/<token>.rb`.
2. Run `./regenerate.sh` to refresh `api/<token>.json`.
3. Regenerate the FMA output manifest:
`go run cmd/maintained-apps/main.go --slug="<token>/darwin"`.
4. Commit all three changes together: the `.rb`, the `.json`, and the
`outputs/<token>/darwin.json`.
## Why `regenerate.sh` strips fields
`brew info --cask --json=v2` includes several fields that depend on the
developer's machine or the throwaway tap the script uses internally —
`installed`, `installed_time`, `outdated`, `full_token`, `tap`,
`tap_git_head`, `generated_date`. None of these are read by the FMA
ingester, so the script strips them to keep committed JSON stable across
machines.
## Requirements
- macOS (the `.rb` DSL is parsed by Homebrew).
- Homebrew installed (`brew` on PATH).
- `jq` (`brew install jq`).
@@ -0,0 +1,88 @@
{
"token": "druva-insync",
"old_tokens": [],
"name": [
"Druva inSync"
],
"desc": "Endpoint data backup and recovery client",
"homepage": "https://www.druva.com/",
"url": "https://downloads.druva.com/downloads/inSync/MAC/7.6.1/inSync-7.6.1-r110931.dmg",
"url_specs": {},
"version": "7.6.1,110931",
"autobump": true,
"no_autobump_message": null,
"skip_livecheck": true,
"bundle_version": null,
"bundle_short_version": null,
"sha256": "a67784b4d6789e9a671e2d77789c408116b10c89c6c8893c3f08ed6212684bf2",
"artifacts": [
{
"uninstall": [
{
"launchctl": [
"com.druva.inSyncAgent",
"com.druva.inSyncDecom",
"com.druva.inSyncUpgrade",
"com.druva.inSyncUpgradeDaemon"
],
"quit": "com.druva.inSyncClient",
"pkgutil": "com.druva.inSync.pkg",
"delete": [
"/Library/LaunchAgents/inSyncAgent.plist",
"/Library/LaunchAgents/inSyncUpgrade.plist",
"/Library/LaunchDaemons/inSyncDecommission.plist",
"/Library/LaunchDaemons/inSyncUpgradeDaemon.plist"
]
}
]
},
{
"pkg": [
"Install inSync.pkg"
]
},
{
"zap": [
{
"trash": [
"~/Library/Application Support/Druva",
"~/Library/Caches/com.druva.inSyncClient",
"~/Library/Logs/Druva",
"~/Library/Preferences/com.druva.inSyncClient.plist",
"~/Library/Saved Application State/com.druva.inSyncClient.savedState"
]
}
]
}
],
"caveats": null,
"caveats_rosetta": null,
"depends_on": {
"macos": {
">=": [
"11"
]
}
},
"conflicts_with": null,
"container": null,
"rename": [],
"auto_updates": null,
"deprecated": false,
"deprecation_date": null,
"deprecation_reason": null,
"deprecation_replacement_formula": null,
"deprecation_replacement_cask": null,
"deprecate_args": null,
"disabled": false,
"disable_date": null,
"disable_reason": null,
"disable_replacement_formula": null,
"disable_replacement_cask": null,
"disable_args": null,
"languages": [],
"ruby_source_path": "Casks/druva-insync.rb",
"ruby_source_checksum": {
"sha256": "9443d1939f90512b0c4dc645ef19176e6c44c5bd0119043c6bc38657f9416791"
}
}
@@ -0,0 +1,77 @@
{
"token": "fleet-desktop",
"old_tokens": [],
"name": [
"Fleet Desktop"
],
"desc": "End-user client for Fleet device management",
"homepage": "https://github.com/allenhouchins/fleet-desktop",
"url": "https://github.com/allenhouchins/fleet-desktop/releases/download/v1.1.0/fleet_desktop-v1.1.0.pkg",
"url_specs": {},
"version": "1.1.0",
"autobump": true,
"no_autobump_message": null,
"skip_livecheck": false,
"bundle_version": null,
"bundle_short_version": null,
"sha256": "4f3537c37a094f333046072262b1f37729f73074bf935f09f05799bc341fef58",
"artifacts": [
{
"uninstall": [
{
"quit": "com.fleetdm.fleet-desktop",
"pkgutil": "com.fleetdm.fleet-desktop"
}
]
},
{
"pkg": [
"fleet_desktop-v1.1.0.pkg"
]
},
{
"zap": [
{
"trash": [
"~/Library/Caches/com.fleetdm.fleet-desktop",
"~/Library/HTTPStorages/com.fleetdm.fleet-desktop",
"~/Library/HTTPStorages/com.fleetdm.fleet-desktop.binarycookies",
"~/Library/Preferences/com.fleetdm.fleet-desktop.plist",
"~/Library/Saved Application State/com.fleetdm.fleet-desktop.savedState",
"~/Library/WebKit/com.fleetdm.fleet-desktop"
]
}
]
}
],
"caveats": "Fleet Desktop requires the Mac to be enrolled in MDM with the\ncom.fleetdm.fleetd.config managed preferences profile. The installer\nwill fail with \"Installation Failed\" otherwise.\n",
"caveats_rosetta": null,
"depends_on": {
"macos": {
">=": [
"13"
]
}
},
"conflicts_with": null,
"container": null,
"rename": [],
"auto_updates": null,
"deprecated": false,
"deprecation_date": null,
"deprecation_reason": null,
"deprecation_replacement_formula": null,
"deprecation_replacement_cask": null,
"deprecate_args": null,
"disabled": false,
"disable_date": null,
"disable_reason": null,
"disable_replacement_formula": null,
"disable_replacement_cask": null,
"disable_args": null,
"languages": [],
"ruby_source_path": "Casks/fleet-desktop.rb",
"ruby_source_checksum": {
"sha256": "a348b5c812ce3ee1c1ee361c273ce39725feffb557d48bbda2f42648c1b2e6c5"
}
}
@@ -0,0 +1,90 @@
{
"token": "zoom-rooms",
"old_tokens": [],
"name": [
"Zoom Rooms"
],
"desc": "Conference room software for Zoom meetings",
"homepage": "https://www.zoom.com/en/products/zoom-rooms/",
"url": "https://cdn.zoom.us/prod/7.0.0.12322/ZoomRooms.pkg",
"url_specs": {},
"version": "7.0.0.12322",
"autobump": true,
"no_autobump_message": null,
"skip_livecheck": true,
"bundle_version": null,
"bundle_short_version": null,
"sha256": "c35229e6066732aec7f26b762d0f2d43ab7b8270e512fbabe5bbb77a9c714bbc",
"artifacts": [
{
"uninstall": [
{
"launchctl": [
"us.zoom.rooms.daemon",
"us.zoom.rooms.tool"
],
"quit": "us.zoom.ZoomPresence",
"pkgutil": "us.zoom.pkg.zp",
"delete": [
"/Applications/ZoomPresence.app",
"/Library/LaunchDaemons/us.zoom.rooms.daemon.plist",
"/Library/LaunchDaemons/us.zoom.rooms.tool.plist",
"/Library/PrivilegedHelperTools/us.zoom.ZoomRoomsDaemon",
"/Library/Logs/us.zoom.ZoomRoomUpdateRecord",
"/Library/Logs/zpinstall.log"
]
}
]
},
{
"pkg": [
"ZoomRooms.pkg"
]
},
{
"zap": [
{
"trash": [
"~/Library/Application Support/ZoomPresence",
"~/Library/Caches/us.zoom.ZoomPresence",
"~/Library/HTTPStorages/us.zoom.ZoomPresence",
"~/Library/HTTPStorages/us.zoom.ZoomPresence.binarycookies",
"~/Library/Preferences/us.zoom.ZoomPresence.plist",
"~/Library/Saved Application State/us.zoom.ZoomPresence.savedState",
"~/Library/WebKit/us.zoom.ZoomPresence"
]
}
]
}
],
"caveats": null,
"caveats_rosetta": null,
"depends_on": {
"macos": {
">=": [
"10.15"
]
}
},
"conflicts_with": null,
"container": null,
"rename": [],
"auto_updates": null,
"deprecated": false,
"deprecation_date": null,
"deprecation_reason": null,
"deprecation_replacement_formula": null,
"deprecation_replacement_cask": null,
"deprecate_args": null,
"disabled": false,
"disable_date": null,
"disable_reason": null,
"disable_replacement_formula": null,
"disable_replacement_cask": null,
"disable_args": null,
"languages": [],
"ruby_source_path": "Casks/zoom-rooms.rb",
"ruby_source_checksum": {
"sha256": "5a428ab919a9c0da0d54f4868ef4ce49693f83a778de7cdd508f063ef770514f"
}
}
@@ -0,0 +1,89 @@
#!/usr/bin/env bash
#
# regenerate.sh
#
# Rebuild every api/<token>.json from its Casks/<token>.rb source using
# Homebrew. Run this whenever you edit or add a cask under Casks/, then
# commit both the updated .rb and the regenerated .json alongside the
# Fleet-maintained-app output manifest (produced separately by
# `go run cmd/maintained-apps/main.go --slug=<slug>`).
#
# Why a throwaway local tap? `brew info --cask --json=v2` can only parse
# casks reachable through a tap; it won't parse a loose .rb file path.
# So the script drops the Casks/*.rb into a private tap, runs brew info,
# extracts the single cask object, and tears the tap down on exit.
#
# Fields that vary between developer machines (install state, tap
# identity, build timestamps) are stripped so the committed JSON is
# deterministic.
#
# Requirements: macOS with Homebrew and jq installed.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
command -v brew >/dev/null 2>&1 || {
echo "error: brew is required; install from https://brew.sh" >&2
exit 1
}
command -v jq >/dev/null 2>&1 || {
echo "error: jq is required; install with 'brew install jq'" >&2
exit 1
}
if [ ! -d Casks ]; then
echo "error: expected a Casks/ directory next to this script" >&2
exit 1
fi
shopt -s nullglob
rb_files=(Casks/*.rb)
if [ ${#rb_files[@]} -eq 0 ]; then
echo "error: no .rb files found under Casks/" >&2
exit 1
fi
TAP_USER="fleetdm"
TAP_NAME="fma-custom-tap"
TAP_DIR="$(brew --repository)/Library/Taps/${TAP_USER}/homebrew-${TAP_NAME}"
cleanup() {
rm -rf "$TAP_DIR"
}
trap cleanup EXIT
rm -rf "$TAP_DIR"
mkdir -p "$TAP_DIR/Casks"
cp Casks/*.rb "$TAP_DIR/Casks/"
(
cd "$TAP_DIR"
git init -q
git add -A
git -c user.email=fma@local -c user.name=fma commit -q -m "local regenerate"
) >/dev/null
mkdir -p api
# Fields stripped from brew's output because they vary by developer
# machine and are not read by Fleet's FMA ingester:
# installed, installed_time, outdated — install state on this host
# tap, tap_git_head, full_token — tap identity (throwaway tap)
# generated_date — build timestamp
STRIP='del(.installed, .installed_time, .outdated, .tap, .tap_git_head, .full_token, .generated_date)'
for rb in "${rb_files[@]}"; do
token="$(basename "$rb" .rb)"
out="api/${token}.json"
echo "Regenerating ${out} from ${rb}..."
brew info --cask --json=v2 "${TAP_USER}/${TAP_NAME}/${token}" \
| jq ".casks[0] | ${STRIP}" \
> "$out"
done
echo
echo "Done. If api/*.json changed, also regenerate the FMA output manifests:"
echo " go run cmd/maintained-apps/main.go --slug=<app>/darwin"
echo "and commit everything together."
@@ -0,0 +1,9 @@
{
"name": "Druva inSync",
"unique_identifier": "com.druva.inSyncClient",
"token": "druva-insync",
"installer_format": "dmg",
"slug": "druva-insync/darwin",
"default_categories": ["Productivity"],
"cask_path": "ee/maintained-apps/inputs/homebrew/custom-tap/api/druva-insync.json"
}
@@ -0,0 +1,9 @@
{
"name": "Fleet Desktop",
"unique_identifier": "com.fleetdm.fleet-desktop",
"token": "fleet-desktop",
"installer_format": "pkg",
"slug": "fleet-desktop/darwin",
"default_categories": ["Productivity"],
"cask_path": "ee/maintained-apps/inputs/homebrew/custom-tap/api/fleet-desktop.json"
}
@@ -79,6 +79,12 @@
"frozen": {
"type": "boolean",
"description": "If true, the app will not be processed during ingestion and no new output will be created."
},
"cask_path": {
"type": "string",
"description": "Path (relative to the repo root) to a local file containing the cask JSON in the same schema as https://formulae.brew.sh/api/cask/<token>.json. Used to commit cask metadata for third-party taps directly into this repo under inputs/homebrew/custom-tap/.",
"minLength": 1,
"pattern": "\\.json$"
}
},
"not": {
@@ -0,0 +1,9 @@
{
"name": "Zoom Rooms",
"unique_identifier": "us.zoom.ZoomPresence",
"token": "zoom-rooms",
"installer_format": "pkg",
"slug": "zoom-rooms/darwin",
"default_categories": ["Communication"],
"cask_path": "ee/maintained-apps/inputs/homebrew/custom-tap/api/zoom-rooms.json"
}
+21
View File
@@ -631,6 +631,13 @@
"unique_identifier": "com.getdropbox.dropbox",
"description": "Dropbox is a client for the Dropbox cloud storage service."
},
{
"name": "Druva inSync",
"slug": "druva-insync/darwin",
"platform": "darwin",
"unique_identifier": "com.druva.inSyncClient",
"description": "Druva inSync is a cloud-based product that protects endpoint data."
},
{
"name": "Druva inSync",
"slug": "druva-insync/windows",
@@ -729,6 +736,13 @@
"unique_identifier": "Mozilla Firefox 140.7.1 ESR (x64 en-US)",
"description": "Mozilla Firefox ESR is the Extended Support Release version of the popular web browser Firefox."
},
{
"name": "Fleet Desktop",
"slug": "fleet-desktop/darwin",
"platform": "darwin",
"unique_identifier": "com.fleetdm.fleet-desktop",
"description": "Fleet Desktop is a native macOS application that provides end users with a self-service portal for Fleet."
},
{
"name": "Fork",
"slug": "fork/darwin",
@@ -1975,6 +1989,13 @@
"unique_identifier": "io.zeplin.osx",
"description": "Zeplin is an app to share, organize, and collaborate on designs."
},
{
"name": "Zoom Rooms",
"slug": "zoom-rooms/darwin",
"platform": "darwin",
"unique_identifier": "us.zoom.ZoomPresence",
"description": "Zoom Rooms is conference room software for Zoom meetings."
},
{
"name": "Zoom",
"slug": "zoom/darwin",
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -11,6 +11,7 @@ import Charles from "./Charles";
import ConnectFonts from "./ConnectFonts";
import CrashPlan from "./CrashPlan";
import DruvaInSync from "./DruvaInSync";
import FleetDesktop from "./FleetDesktop";
import Gemini from "./Gemini";
import GoogleCredentialProviderForWindows from "./GoogleCredentialProviderForWindows";
import Iina from "./Iina";
@@ -251,6 +252,7 @@ import Zen from "./Zen";
import Zeplin from "./Zeplin";
import ZeroOneZeroEditor from "./010Editor";
import Zoom from "./Zoom";
import ZoomRooms from "./ZoomRooms";
import Zotero from "./Zotero";
// SOFTWARE_NAME_TO_ICON_MAP list "special" applications that have a defined
@@ -317,6 +319,7 @@ export const SOFTWARE_NAME_TO_ICON_MAP = {
"company portal": IntuneCompanyPortal,
"connect fonts": ConnectFonts,
crashplan: CrashPlan,
"fleet desktop": FleetDesktop,
gemini: Gemini,
"google credential provider for windows": GoogleCredentialProviderForWindows,
iina: Iina,
@@ -515,6 +518,7 @@ export const SOFTWARE_NAME_TO_ICON_MAP = {
zed: Zed,
zen: Zen,
zeplin: Zeplin,
"zoom rooms": ZoomRooms,
zotero: Zotero,
} as const;
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB