From 98235185d216b3577dda44f4b7a91debb71b0a76 Mon Sep 17 00:00:00 2001 From: eltorrero Date: Mon, 14 Sep 2026 20:42:04 +0000 Subject: [PATCH 1/6] fix(chairlift): track rebranded projectbluefin/chairlift release Coordinate common's downstream integration to the rebranded ChairLift (projectbluefin/chairlift, io.projectbluefin.chairlift): - chairlift.Brewfile: tap ublue-os/homebrew-tap and cask ublue-os/homebrew-tap/chairlift, which pins the projectbluefin/chairlift release (v0.12.2); the old frostyard/tap cask is frozen at the pre-rebrand v0.10.1. - brew-preinstall: protect the new ublue-os/homebrew-tap/chairlift cask name in the --external-chairlift OS-diet handoff, alongside the historical unqualified, frostyard and ublue-os/tap spellings. - Rename the system-wide desktop entry, icons and bootc polkit policy from org.frostyard.ChairLift.* to io.projectbluefin.chairlift.*; the policy and desktop entry are re-vendored from upstream v0.12.2 (Exec= keeps the absolute wrapper-path edit), icons are unchanged. - check-chairlift-config: CHAIRLIFT_SCHEMA_REF v0.10.1 -> v0.12.2 and UPSTREAM_RAW -> projectbluefin/chairlift, bumped in the same change as the cask per the gate's own invariant. - test_chairlift_config.py: new IDs, new Brewfile expectations, v0.12.2 schema pin, and the v0.12.2 schema surface (8 new groups, ai_images / ai_model fields). - test_brew_preinstall.bats: handoff state test covers the new cask name. - pre-commit excludes, drift workflow and skill docs follow the rebrand. Refs: projectbluefin/chairlift#18, projectbluefin/common#1115 Signed-off-by: eltorrero --- .../workflows/validate-chairlift-config.yaml | 4 +- .pre-commit-config.yaml | 4 +- docs/skills/brew-lifecycle/SKILL.md | 18 ++--- .../brew-lifecycle/references/package-set.md | 7 +- .../references/service-mechanics.md | 32 +++++---- docs/skills/ci-pitfalls/SKILL.md | 2 +- docs/skills/workflow-map.md | 7 +- .../shared/usr/libexec/bootc-update-stage | 4 +- .../shared/usr/libexec/brew-preinstall | 8 ++- ...op => io.projectbluefin.chairlift.desktop} | 6 +- .../shared/usr/share/chairlift/config.yml | 2 +- ...=> io.projectbluefin.chairlift-flower.svg} | 0 ...ft.svg => io.projectbluefin.chairlift.svg} | 0 ... io.projectbluefin.chairlift-symbolic.svg} | 0 ... io.projectbluefin.chairlift.bootc.policy} | 8 +-- .../homebrew/preinstall.d/chairlift.Brewfile | 4 +- tests/check-chairlift-config | 21 +++--- tests/test_brew_preinstall.bats | 2 +- tests/test_chairlift_config.py | 72 ++++++++++++------- 19 files changed, 118 insertions(+), 83 deletions(-) rename system_files/shared/usr/share/applications/{org.frostyard.ChairLift.desktop => io.projectbluefin.chairlift.desktop} (76%) rename system_files/shared/usr/share/icons/hicolor/scalable/apps/{org.frostyard.ChairLift-flower.svg => io.projectbluefin.chairlift-flower.svg} (100%) rename system_files/shared/usr/share/icons/hicolor/scalable/apps/{org.frostyard.ChairLift.svg => io.projectbluefin.chairlift.svg} (100%) rename system_files/shared/usr/share/icons/hicolor/symbolic/apps/{org.frostyard.ChairLift-symbolic.svg => io.projectbluefin.chairlift-symbolic.svg} (100%) rename system_files/shared/usr/share/polkit-1/actions/{org.frostyard.ChairLift.bootc.policy => io.projectbluefin.chairlift.bootc.policy} (75%) diff --git a/.github/workflows/validate-chairlift-config.yaml b/.github/workflows/validate-chairlift-config.yaml index 6316a71c..61362e18 100644 --- a/.github/workflows/validate-chairlift-config.yaml +++ b/.github/workflows/validate-chairlift-config.yaml @@ -1,8 +1,8 @@ name: Validate ChairLift Config # Networked drift gate for /usr/share/chairlift/config.yml. It validates -# against the ChairLift release the frostyard/tap cask pins, not upstream -# main; the pin lives in one constant, CHAIRLIFT_SCHEMA_REF in +# against the ChairLift release the ublue-os/homebrew-tap cask pins, not +# upstream main; the pin lives in one constant, CHAIRLIFT_SCHEMA_REF in # tests/check-chairlift-config, and moves only when the cask does. # # This is deliberately NOT part of `just check`: that gate must stay diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 6d93374b..be5f8497 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -9,14 +9,14 @@ repos: - id: end-of-file-fixer # Verbatim upstream ChairLift artwork; keep it byte-identical to # the release the cask pins so its sha256 stays verifiable. - exclude: ^system_files/shared/usr/share/icons/hicolor/.*/apps/org\.frostyard\.ChairLift.*\.svg$ + exclude: ^system_files/shared/usr/share/icons/hicolor/.*/apps/io\.projectbluefin\.chairlift.*\.svg$ - id: trailing-whitespace - id: check-merge-conflict - id: detect-private-key - id: check-added-large-files # Same artwork: upstream ships these two app icons well over the # 500 KiB default and we do not re-encode vendored assets. - exclude: ^system_files/shared/usr/share/icons/hicolor/scalable/apps/org\.frostyard\.ChairLift.*\.svg$ + exclude: ^system_files/shared/usr/share/icons/hicolor/scalable/apps/io\.projectbluefin\.chairlift.*\.svg$ - repo: https://github.com/rhysd/actionlint rev: 03d0035246f3e81f36aed592ffb4bebf33a03106 # v1.7.7 hooks: diff --git a/docs/skills/brew-lifecycle/SKILL.md b/docs/skills/brew-lifecycle/SKILL.md index ebcfecd6..3010f57b 100644 --- a/docs/skills/brew-lifecycle/SKILL.md +++ b/docs/skills/brew-lifecycle/SKILL.md @@ -92,13 +92,13 @@ ChairLift is a managed cask installed for every user through `system_files/shared/usr/share/ublue-os/homebrew/preinstall.d/chairlift.Brewfile`: ```ruby -tap "frostyard/tap", trusted: true -cask "frostyard/tap/chairlift" +tap "ublue-os/homebrew-tap", trusted: true +cask "ublue-os/homebrew-tap/chairlift" ``` The tap line requires `trusted: true`; Homebrew 6 blocks untrusted taps. The -cask must remain pinned upstream in `frostyard/tap` rather than being replaced -with a local mutable download in common. +cask must remain pinned upstream in `ublue-os/homebrew-tap` rather than being +replaced with a local mutable download in common. Bluefin owns `/usr/share/chairlift/config.yml`, shipped from `system_files/shared/usr/share/chairlift/config.yml`. `/etc/chairlift/config.yml` @@ -121,7 +121,7 @@ reboot and an update uupd already staged gets re-locked), or Desktop integration ships from the image, not the cask. Homebrew has one shared prefix, so the cask's `~/.local/share` desktop entry and icons only ever reach the first user to run `brew bundle`. `common` ships the upstream -desktop file at `/usr/share/applications/org.frostyard.ChairLift.desktop` +desktop file at `/usr/share/applications/io.projectbluefin.chairlift.desktop` (`Exec=/home/linuxbrew/.linuxbrew/bin/chairlift-wrapper`) and the three upstream icons under `/usr/share/icons/hicolor/`, so every user gets a launcher. @@ -133,7 +133,9 @@ Images that supply their own ChairLift migration may invoke common's existing behavior is unchanged. The opt-in excludes the entire `chairlift.Brewfile` from tapping, hashing, bundling and managed state, and protects the historical unqualified, Frostyard-qualified and Bluefin-qualified -ChairLift names from OS-diet removal. Other packages keep their usual lifecycle. +ChairLift cask names (including both `ublue-os/tap/chairlift` and +`ublue-os/homebrew-tap/chairlift`) from OS-diet removal. Other packages keep +their usual lifecycle. Keep that Brewfile dedicated to ChairLift; do not place unrelated packages in it. The caller must capture any old managed-state authorization before invoking this @@ -151,8 +153,8 @@ call an older script that ignores the option. Homebrew 6.0 syntax — `trusted: true` is required: ```ruby -tap "frostyard/tap", trusted: true -cask "frostyard/tap/chairlift" +tap "ublue-os/homebrew-tap", trusted: true +cask "ublue-os/homebrew-tap/chairlift" ``` Without `trusted: true` the tap is blocked and the formula is silently unavailable. See [placement-rules.md](references/placement-rules.md#homebrew-60-tap-trust-required-as-of-2026-06-11). diff --git a/docs/skills/brew-lifecycle/references/package-set.md b/docs/skills/brew-lifecycle/references/package-set.md index be8717fa..44f50845 100644 --- a/docs/skills/brew-lifecycle/references/package-set.md +++ b/docs/skills/brew-lifecycle/references/package-set.md @@ -33,14 +33,15 @@ provision managed packages for every variant. The CLI set contains 11 packages: installs ChairLift for every user: ```ruby -tap "frostyard/tap", trusted: true -cask "frostyard/tap/chairlift" +tap "ublue-os/homebrew-tap", trusted: true +cask "ublue-os/homebrew-tap/chairlift" ``` This is OS-managed like the default formula set: add the cask and every user gets it on next login after update; remove it and users whose state file shows it as managed have it uninstalled. The `trusted: true` tap flag is required. -The cask must remain pinned upstream in `frostyard/tap`; do not vendor an +The cask must remain pinned upstream in `ublue-os/homebrew-tap`, where it +tracks the rebranded `projectbluefin/chairlift` releases; do not vendor an unpinned replacement cask into common. The cask's desktop entry and icons land in the installing user's diff --git a/docs/skills/brew-lifecycle/references/service-mechanics.md b/docs/skills/brew-lifecycle/references/service-mechanics.md index 97b4c990..0d1e6b07 100644 --- a/docs/skills/brew-lifecycle/references/service-mechanics.md +++ b/docs/skills/brew-lifecycle/references/service-mechanics.md @@ -97,12 +97,13 @@ ChairLift is a managed cask installed for every user from Keep both lines load-bearing: ```ruby -tap "frostyard/tap", trusted: true -cask "frostyard/tap/chairlift" +tap "ublue-os/homebrew-tap", trusted: true +cask "ublue-os/homebrew-tap/chairlift" ``` -Homebrew 6 requires `trusted: true` for the Frostyard tap, and the cask must -remain pinned upstream in `frostyard/tap`. +Homebrew 6 requires `trusted: true` for the tap, and the cask must remain +pinned upstream in `ublue-os/homebrew-tap`, which tracks the rebranded +`projectbluefin/chairlift` releases. Bluefin owns the maintainer defaults at `/usr/share/chairlift/config.yml` (`system_files/shared/usr/share/chairlift/config.yml` in this repo). Admins own @@ -151,21 +152,22 @@ user-scope artifacts are first-user-wins. | Path | Source | |---|---| -| `/usr/share/applications/org.frostyard.ChairLift.desktop` | upstream `data/org.frostyard.ChairLift.desktop`, `Exec=` rewritten to the absolute wrapper path | -| `/usr/share/icons/hicolor/scalable/apps/org.frostyard.ChairLift.svg` | upstream, verbatim | -| `/usr/share/icons/hicolor/scalable/apps/org.frostyard.ChairLift-flower.svg` | upstream, verbatim | -| `/usr/share/icons/hicolor/symbolic/apps/org.frostyard.ChairLift-symbolic.svg` | upstream, verbatim | +| `/usr/share/applications/io.projectbluefin.chairlift.desktop` | upstream `data/io.projectbluefin.chairlift.desktop`, `Exec=` rewritten to the absolute wrapper path | +| `/usr/share/icons/hicolor/scalable/apps/io.projectbluefin.chairlift.svg` | upstream, verbatim | +| `/usr/share/icons/hicolor/scalable/apps/io.projectbluefin.chairlift-flower.svg` | upstream, verbatim | +| `/usr/share/icons/hicolor/symbolic/apps/io.projectbluefin.chairlift-symbolic.svg` | upstream, verbatim | -All four are vendored from ChairLift v0.10.1 (GPL-3.0, `frostyard/chairlift`) -and must be refreshed from the tag the cask pins whenever it is bumped. The -three icons are byte-identical to upstream, so the claim is checkable: +All four are vendored from ChairLift v0.12.2 (GPL-3.0, +`projectbluefin/chairlift`) and must be refreshed from the tag the cask pins +whenever it is bumped. The three icons are byte-identical to upstream, so the +claim is checkable: ```bash -BASE=https://raw.githubusercontent.com/frostyard/chairlift/v0.10.1/data/icons/hicolor +BASE=https://raw.githubusercontent.com/projectbluefin/chairlift/v0.12.2/data/icons/hicolor cd system_files/shared/usr/share/icons/hicolor -for icon in scalable/apps/org.frostyard.ChairLift.svg \ - scalable/apps/org.frostyard.ChairLift-flower.svg \ - symbolic/apps/org.frostyard.ChairLift-symbolic.svg; do +for icon in scalable/apps/io.projectbluefin.chairlift.svg \ + scalable/apps/io.projectbluefin.chairlift-flower.svg \ + symbolic/apps/io.projectbluefin.chairlift-symbolic.svg; do diff <(curl -fsSL "$BASE/$icon") "$icon" && echo "ok $icon" done ``` diff --git a/docs/skills/ci-pitfalls/SKILL.md b/docs/skills/ci-pitfalls/SKILL.md index b3f7f3c1..77fd6db0 100644 --- a/docs/skills/ci-pitfalls/SKILL.md +++ b/docs/skills/ci-pitfalls/SKILL.md @@ -55,7 +55,7 @@ This is networked and syncs the declared taps, so it stays outside `just check`. Sync the complete declared tap set before checking any package names. Per-file tap setup makes bare-name resolution depend on traversal order: ChairLift adds -`frostyard/tap`, which also provides the five wallpaper casks in `ublue-os/tap`. +`ublue-os/homebrew-tap`, which also provides the wallpaper casks. Use fully qualified names in `artwork.Brewfile`; do not hide the collision by isolating taps or skipping unchanged Brewfiles. Zed's Linux cask lives in `ublue-os/tap`, not `ublue-os/experimental-tap`. diff --git a/docs/skills/workflow-map.md b/docs/skills/workflow-map.md index 4646e469..3ee80d48 100644 --- a/docs/skills/workflow-map.md +++ b/docs/skills/workflow-map.md @@ -30,7 +30,7 @@ Load this when you need to understand **what each GitHub workflow in `projectblu |---|---|---| | `validate.yml` | Main PR gate: submodule drift, `just check`, shellcheck, image-registry guard, dconf parity, pre-commit | Tightening repo-local validation or policy guards | | `validate-brewfiles.yaml` | Validates Brewfile correctness | Changing Brewfile structure or Brewfile validation rules | -| `validate-chairlift-config.yaml` | Checks `/usr/share/chairlift/config.yml` against the schema of the pinned ChairLift release (`CHAIRLIFT_SCHEMA_REF`, currently `v0.10.1`); path-filtered plus a weekly cron | Changing the ChairLift maintainer config, cask pin, or upstream schema assumptions | +| `validate-chairlift-config.yaml` | Checks `/usr/share/chairlift/config.yml` against the schema of the pinned ChairLift release (`CHAIRLIFT_SCHEMA_REF`, currently `v0.12.2`); path-filtered plus a weekly cron | Changing the ChairLift maintainer config, cask pin, or upstream schema assumptions | | `unit-tests.yml` | Runs `pytest` + `bats` on `system_files/**`, `tests/**`, and the `Justfile`. Triggers on PR, push to `main`, and `merge_group`. | Adding or changing unit tests, or changing the paths they cover | | `build.yml` | Builds and publishes the `common` OCI layer on merge. Runs parallel per-arch jobs (x86_64 on `ubuntu-24.04`, aarch64 on `ubuntu-24.04-arm`). Build uses rootless `buildah-build`; after build, `sudo skopeo copy` promotes the image into root storage so `push-image` (which uses `sudo podman push`) can find it. Then a `manifest` job assembles the multi-arch manifest, logs into GHCR, signs with keyless OIDC, generates SBOM, and attests SLSA L2. Downstream propagation is handled by Renovate (bluefin/bluefin-lts, ~3h) and dakota's daily cron — there is no direct dispatch from this workflow. | Changing how the shared layer is built or pushed | | `pr-e2e.yml` | Pre-merge composed-image gate for the PR's common layer (composes + runs common suite via `run-testsuite.yml`) | Changing how PR-time downstream composition is tested | @@ -57,8 +57,9 @@ Load this when you need to understand **what each GitHub workflow in `projectblu `validate-chairlift-config.yaml` validates against an external upstream schema that can drift without a common commit. It fetches ChairLift's page, group, and field names and fails closed because unknown keys disable the whole -application. It reads the tag the `frostyard/tap` cask pins rather than -upstream `main`, so it cannot green-light a key the shipped binary rejects. +application. It reads the tag the `ublue-os/homebrew-tap` cask pins rather +than upstream `main`, so it cannot green-light a key the shipped binary +rejects. It is a separate workflow on purpose: `just check` must stay hermetic, so no networked gate belongs in the repo-wide PR check. diff --git a/system_files/shared/usr/libexec/bootc-update-stage b/system_files/shared/usr/libexec/bootc-update-stage index a30a0ff1..cc3a31ea 100755 --- a/system_files/shared/usr/libexec/bootc-update-stage +++ b/system_files/shared/usr/libexec/bootc-update-stage @@ -1,8 +1,8 @@ #!/usr/bin/env bash # bootc-update-stage — privileged helper invoked via ChairLift's fixed -# org.frostyard.ChairLift.bootc.stage polkit action (pkexec exec.path is +# io.projectbluefin.chairlift.bootc.stage polkit action (pkexec exec.path is # pinned to this exact path; see -# system_files/shared/usr/share/polkit-1/actions/org.frostyard.ChairLift.bootc.policy). +# system_files/shared/usr/share/polkit-1/actions/io.projectbluefin.chairlift.bootc.policy). # # Stages the next bootc system image update. Plain `bootc upgrade` fetches # the new image and queues it as a staged deployment; ostree-finalize-staged diff --git a/system_files/shared/usr/libexec/brew-preinstall b/system_files/shared/usr/libexec/brew-preinstall index cdb51061..ed95e8ac 100755 --- a/system_files/shared/usr/libexec/brew-preinstall +++ b/system_files/shared/usr/libexec/brew-preinstall @@ -58,7 +58,7 @@ eval "$("${BREW_BIN}" shellenv)" # installability before processing the Brewfile's own tap lines, so on a # system that has never seen the tap it skips the cask as "requires macOS" # and still exits 0 — the cask is silently never installed (first boot of -# any image shipping a tap+cask pair, e.g. frostyard/tap + chairlift). +# any image shipping a tap+cask pair, e.g. ublue-os/homebrew-tap + chairlift). # Vendored taps are also trusted: brew refuses casks from untrusted taps, # and the Brewfile's own `trusted:` flag doesn't apply to an already-present # tap. This runs every boot, before the hash early-exit, because @@ -152,7 +152,11 @@ previous_packages=$(jq -r '.packages[]? // empty' "${STATE_FILE}" 2>/dev/null \ # work even if the old Brewfile has already disappeared from the new image. previous_casks=$(jq -r --argjson external "${external_chairlift}" ' .casks[]? // empty | - select($external == 0 or (. != "chairlift" and . != "frostyard/tap/chairlift" and . != "ublue-os/tap/chairlift")) + select($external == 0 + or (. != "chairlift" + and . != "frostyard/tap/chairlift" + and . != "ublue-os/tap/chairlift" + and . != "ublue-os/homebrew-tap/chairlift")) ' "${STATE_FILE}" 2>/dev/null \ | sort -u || true) diff --git a/system_files/shared/usr/share/applications/org.frostyard.ChairLift.desktop b/system_files/shared/usr/share/applications/io.projectbluefin.chairlift.desktop similarity index 76% rename from system_files/shared/usr/share/applications/org.frostyard.ChairLift.desktop rename to system_files/shared/usr/share/applications/io.projectbluefin.chairlift.desktop index 44a4583d..103a7fe0 100644 --- a/system_files/shared/usr/share/applications/org.frostyard.ChairLift.desktop +++ b/system_files/shared/usr/share/applications/io.projectbluefin.chairlift.desktop @@ -1,6 +1,6 @@ [Desktop Entry] -# Vendored from ChairLift v0.10.1 (data/org.frostyard.ChairLift.desktop), -# https://github.com/frostyard/chairlift — GPL-3.0. The only edit is the +# Vendored from ChairLift v0.12.2 (data/io.projectbluefin.chairlift.desktop), +# https://github.com/projectbluefin/chairlift — GPL-3.0. The only edit is the # absolute Exec= path below. # # Homebrew's chairlift cask installs this entry and its icons under the @@ -11,7 +11,7 @@ # docs/skills/brew-lifecycle/references/service-mechanics.md. Name=ChairLift Exec=/home/linuxbrew/.linuxbrew/bin/chairlift-wrapper -Icon=org.frostyard.ChairLift +Icon=io.projectbluefin.chairlift Terminal=false Type=Application Categories=System;GTK; diff --git a/system_files/shared/usr/share/chairlift/config.yml b/system_files/shared/usr/share/chairlift/config.yml index 3f3e756e..61d63a17 100644 --- a/system_files/shared/usr/share/chairlift/config.yml +++ b/system_files/shared/usr/share/chairlift/config.yml @@ -1,6 +1,6 @@ # ChairLift configuration for Bluefin (maintainer defaults). # Admin overrides go in /etc/chairlift/config.yml, which takes priority. -# Reference: https://github.com/frostyard/chairlift/blob/main/CONFIG.md +# Reference: https://github.com/projectbluefin/chairlift/blob/main/CONFIG.md system_page: system_info_group: diff --git a/system_files/shared/usr/share/icons/hicolor/scalable/apps/org.frostyard.ChairLift-flower.svg b/system_files/shared/usr/share/icons/hicolor/scalable/apps/io.projectbluefin.chairlift-flower.svg similarity index 100% rename from system_files/shared/usr/share/icons/hicolor/scalable/apps/org.frostyard.ChairLift-flower.svg rename to system_files/shared/usr/share/icons/hicolor/scalable/apps/io.projectbluefin.chairlift-flower.svg diff --git a/system_files/shared/usr/share/icons/hicolor/scalable/apps/org.frostyard.ChairLift.svg b/system_files/shared/usr/share/icons/hicolor/scalable/apps/io.projectbluefin.chairlift.svg similarity index 100% rename from system_files/shared/usr/share/icons/hicolor/scalable/apps/org.frostyard.ChairLift.svg rename to system_files/shared/usr/share/icons/hicolor/scalable/apps/io.projectbluefin.chairlift.svg diff --git a/system_files/shared/usr/share/icons/hicolor/symbolic/apps/org.frostyard.ChairLift-symbolic.svg b/system_files/shared/usr/share/icons/hicolor/symbolic/apps/io.projectbluefin.chairlift-symbolic.svg similarity index 100% rename from system_files/shared/usr/share/icons/hicolor/symbolic/apps/org.frostyard.ChairLift-symbolic.svg rename to system_files/shared/usr/share/icons/hicolor/symbolic/apps/io.projectbluefin.chairlift-symbolic.svg diff --git a/system_files/shared/usr/share/polkit-1/actions/org.frostyard.ChairLift.bootc.policy b/system_files/shared/usr/share/polkit-1/actions/io.projectbluefin.chairlift.bootc.policy similarity index 75% rename from system_files/shared/usr/share/polkit-1/actions/org.frostyard.ChairLift.bootc.policy rename to system_files/shared/usr/share/polkit-1/actions/io.projectbluefin.chairlift.bootc.policy index 9bf4f37f..c4cc4e85 100644 --- a/system_files/shared/usr/share/polkit-1/actions/org.frostyard.ChairLift.bootc.policy +++ b/system_files/shared/usr/share/polkit-1/actions/io.projectbluefin.chairlift.bootc.policy @@ -4,11 +4,11 @@ "http://www.freedesktop.org/standards/PolicyKit/1/policyconfig.dtd"> - Frostyard - https://github.com/frostyard/chairlift - org.frostyard.ChairLift + Project Bluefin + https://github.com/projectbluefin/chairlift + io.projectbluefin.chairlift - + Download and stage a system image update Authentication is required to stage a system update diff --git a/system_files/shared/usr/share/ublue-os/homebrew/preinstall.d/chairlift.Brewfile b/system_files/shared/usr/share/ublue-os/homebrew/preinstall.d/chairlift.Brewfile index 911ed69a..7578b375 100644 --- a/system_files/shared/usr/share/ublue-os/homebrew/preinstall.d/chairlift.Brewfile +++ b/system_files/shared/usr/share/ublue-os/homebrew/preinstall.d/chairlift.Brewfile @@ -1,2 +1,2 @@ -tap "frostyard/tap", trusted: true -cask "frostyard/tap/chairlift" +tap "ublue-os/homebrew-tap", trusted: true +cask "ublue-os/homebrew-tap/chairlift" diff --git a/tests/check-chairlift-config b/tests/check-chairlift-config index ae149253..70da06f2 100755 --- a/tests/check-chairlift-config +++ b/tests/check-chairlift-config @@ -18,8 +18,9 @@ Sources of truth, both read live from upstream at the PINNED tag: ActionConfig in internal/config/config.go. config.yml cannot supply these, because it only exercises a subset of the optional fields. -The tag matters. Bluefin ships whichever release frostyard/tap's chairlift -cask pins, so that binary's loader is the only one whose opinion counts. +The tag matters. Bluefin ships whichever release ublue-os/homebrew-tap's +chairlift cask pins, so that binary's loader is the only one whose opinion +counts. Validating against upstream's moving `main` would false-red on renames that never reach our users, and -- far worse -- false-green on a key the pinned binary rejects, which is exactly the disabledConfig() outcome this script @@ -42,13 +43,14 @@ import urllib.request import yaml -#: The ChairLift release Bluefin actually ships, pinned by frostyard/tap's -#: chairlift cask. Single source of truth for every upstream fetch below; -#: bump it together with the cask version, never independently. -CHAIRLIFT_SCHEMA_REF = "v0.10.1" +#: The ChairLift release Bluefin actually ships, pinned by +#: ublue-os/homebrew-tap's chairlift cask. Single source of truth for every +#: upstream fetch below; bump it together with the cask version, never +#: independently. +CHAIRLIFT_SCHEMA_REF = "v0.12.2" UPSTREAM_RAW = ( - f"https://raw.githubusercontent.com/frostyard/chairlift/{CHAIRLIFT_SCHEMA_REF}" + f"https://raw.githubusercontent.com/projectbluefin/chairlift/{CHAIRLIFT_SCHEMA_REF}" ) UPSTREAM_CONFIG_URL = f"{UPSTREAM_RAW}/config.yml" UPSTREAM_SCHEMA_URL = f"{UPSTREAM_RAW}/internal/config/config.go" @@ -162,7 +164,10 @@ def main() -> int: f"{', '.join(sorted(action_fields))})" ) - print(f"ChairLift schema @ {CHAIRLIFT_SCHEMA_REF} (pinned by the frostyard/tap cask):") + print( + f"ChairLift schema @ {CHAIRLIFT_SCHEMA_REF} " + "(pinned by the ublue-os/homebrew-tap cask):" + ) for page, groups in sorted(upstream.items()): print(f" {page}: {', '.join(sorted(groups))}") print(f" group fields: {', '.join(sorted(group_fields))}") diff --git a/tests/test_brew_preinstall.bats b/tests/test_brew_preinstall.bats index 895e3e7c..40173eb7 100644 --- a/tests/test_brew_preinstall.bats +++ b/tests/test_brew_preinstall.bats @@ -152,7 +152,7 @@ EOF printf 'tap "frostyard/tap", trusted: true\ncask "chairlift"\n' > "${WORKDIR}/preinstall.d/chairlift.Brewfile" printf 'brew "jq"\n' > "${WORKDIR}/preinstall.d/system-cli.Brewfile" mkdir -p "${WORKDIR}/.local/share/ublue-os" - printf '{"hash":"old","packages":["jq"],"casks":["chairlift","frostyard/tap/chairlift","ublue-os/tap/chairlift","other"]}\n' \ + printf '{"hash":"old","packages":["jq"],"casks":["chairlift","frostyard/tap/chairlift","ublue-os/tap/chairlift","ublue-os/homebrew-tap/chairlift","other"]}\n' \ > "${WORKDIR}/.local/share/ublue-os/brew-preinstall-state.json" BREW_LOG="${WORKDIR}/brew.log" run bash "${PATCHED_WRAPPER}" --external-chairlift diff --git a/tests/test_chairlift_config.py b/tests/test_chairlift_config.py index b26ab266..67a6c790 100644 --- a/tests/test_chairlift_config.py +++ b/tests/test_chairlift_config.py @@ -1,6 +1,6 @@ """Regression checks for the ChairLift config and preinstall Brewfile. -ChairLift (https://github.com/frostyard/chairlift) reads +ChairLift (https://github.com/projectbluefin/chairlift) reads /usr/share/chairlift/config.yml for maintainer defaults. These tests pin the Bluefin decisions: frostyard/chairlift#54 resolved via the system-integration split (frostyard/chairlift#102), so bootc staging is @@ -34,20 +34,20 @@ BOOTC_POLICY = ( ROOT / "system_files/shared/usr/share/polkit-1/actions" - / "org.frostyard.ChairLift.bootc.policy" + / "io.projectbluefin.chairlift.bootc.policy" ) BOOTC_STAGE_SCRIPT = ROOT / "system_files/shared/usr/libexec/bootc-update-stage" CHAIRLIFT_VALIDATOR = ROOT / "tests/check-chairlift-config" CHAIRLIFT_WORKFLOW = ROOT / ".github/workflows/validate-chairlift-config.yaml" JUSTFILE = ROOT / "Justfile" DESKTOP_FILE = ( - ROOT / "system_files/shared/usr/share/applications/org.frostyard.ChairLift.desktop" + ROOT / "system_files/shared/usr/share/applications/io.projectbluefin.chairlift.desktop" ) ICON_ROOT = ROOT / "system_files/shared/usr/share/icons/hicolor" ICONS = ( - ICON_ROOT / "scalable/apps/org.frostyard.ChairLift.svg", - ICON_ROOT / "scalable/apps/org.frostyard.ChairLift-flower.svg", - ICON_ROOT / "symbolic/apps/org.frostyard.ChairLift-symbolic.svg", + ICON_ROOT / "scalable/apps/io.projectbluefin.chairlift.svg", + ICON_ROOT / "scalable/apps/io.projectbluefin.chairlift-flower.svg", + ICON_ROOT / "symbolic/apps/io.projectbluefin.chairlift-symbolic.svg", ) #: Homebrew's shared prefix on Bluefin. The cask links chairlift-wrapper here. CHAIRLIFT_WRAPPER = "/home/linuxbrew/.linuxbrew/bin/chairlift-wrapper" @@ -76,9 +76,16 @@ # lives in .github/workflows/validate-chairlift-config.yaml, which fetches # upstream's config.yml and fails on drift. KNOWN_GROUPS = { - "system_page": {"system_info_group", "bootc_status_group", "health_group"}, + "system_page": { + "system_info_group", + "bootc_status_group", + "health_group", + "channel_group", + }, "updates_page": { + "update_all_group", "bootc_updates_group", + "sysupdate_updates_group", "flatpak_updates_group", "brew_updates_group", "brew_trust_group", @@ -96,8 +103,15 @@ "maintenance_brew_group", "maintenance_flatpak_group", "maintenance_optimization_group", + "reset_group", + }, + "features_page": { + "features_group", + "dx_group", + "gaming_group", + "ai_group", + "troubleshooting_group", }, - "features_page": {"features_group"}, "help_page": {"help_resources_group"}, } @@ -113,6 +127,8 @@ "issues", "chat", "bundles_paths", + "ai_images", + "ai_model", } KNOWN_ACTION_FIELDS = {"title", "script", "sudo"} @@ -156,7 +172,7 @@ def test_bootc_stage_polkit_policy_pins_fixed_helper_path(): """The polkit action must annotate the exact fixed path ChairLift's pkexec invocation expects, and require authentication.""" content = BOOTC_POLICY.read_text(encoding="utf-8") - assert "org.frostyard.ChairLift.bootc.stage" in content + assert "io.projectbluefin.chairlift.bootc.stage" in content assert ( '' "/usr/libexec/bootc-update-stage" in content @@ -313,11 +329,11 @@ def test_schema_validator_pins_the_shipped_chairlift_release(): validator = CHAIRLIFT_VALIDATOR.read_text(encoding="utf-8") refs = re.findall(r'^CHAIRLIFT_SCHEMA_REF = "([^"]+)"$', validator, re.MULTILINE) - assert refs == ["v0.10.1"], ( - f"expected exactly one CHAIRLIFT_SCHEMA_REF pinned to v0.10.1, got {refs}" + assert refs == ["v0.12.2"], ( + f"expected exactly one CHAIRLIFT_SCHEMA_REF pinned to v0.12.2, got {refs}" ) - urls = re.findall(r"https://raw\.githubusercontent\.com/frostyard/chairlift/\S*", validator) + urls = re.findall(r"https://raw\.githubusercontent\.com/projectbluefin/chairlift/\S*", validator) unpinned = [url for url in urls if "{CHAIRLIFT_SCHEMA_REF}" not in url] assert not unpinned, ( f"upstream URLs bypass the pin: {unpinned}; build every URL from " @@ -415,7 +431,7 @@ def test_just_check_stays_hermetic(): """`just check` is the repo-wide pre-commit gate documented across the skill docs and the PR template. Chaining a third-party network fetch into it makes every unrelated PR, the merge queue, and every offline - contributor depend on frostyard/chairlift being reachable. + contributor depend on projectbluefin/chairlift being reachable. Inspect the whole recipe closure -- header, body, and every recipe `check` depends on -- because `just check` runs all of it. A header-only @@ -495,9 +511,9 @@ def test_chairlift_drift_workflow_documents_the_pin(): def test_update_scheduling_is_not_expressed_as_a_config_group(): """Bluefin's update policy belongs to uupd, but that intent must not be - encoded as a made-up group. upstream's updates_page is exactly the four - groups in KNOWN_GROUPS; anything settings-shaped here is an invention - that would fail strict validation.""" + encoded as a made-up group. The only legitimate groups are the ones + upstream defines (mirrored in KNOWN_GROUPS); anything settings-shaped + here is an invention that would fail strict validation.""" updates = _load_config()["updates_page"] invented = {name for name in updates if "setting" in name or "schedul" in name} assert not invented, ( @@ -520,11 +536,15 @@ def test_help_links_point_at_bluefin(): ) -def test_brewfile_taps_frostyard_with_trust(): - """Homebrew 6 blocks untrusted taps silently; trusted: true is load-bearing.""" +def test_brewfile_taps_homebrew_tap_with_trust(): + """Homebrew 6 blocks untrusted taps silently; trusted: true is load-bearing. + + The cask must stay the rebranded upstream release: ublue-os/homebrew-tap + pins projectbluefin/chairlift, which is the source the schema gate in + tests/check-chairlift-config validates against.""" content = BREWFILE.read_text(encoding="utf-8") - assert 'tap "frostyard/tap", trusted: true' in content - assert 'cask "frostyard/tap/chairlift"' in content + assert 'tap "ublue-os/homebrew-tap", trusted: true' in content + assert 'cask "ublue-os/homebrew-tap/chairlift"' in content # --------------------------------------------------------------------------- @@ -563,7 +583,7 @@ def test_chairlift_desktop_entry_ships_system_wide(): entry = _desktop_entry() assert entry.get("Name") == "ChairLift" assert entry.get("Type") == "Application" - assert entry.get("Icon") == "org.frostyard.ChairLift" + assert entry.get("Icon") == "io.projectbluefin.chairlift" assert entry.get("NoDisplay") == "false", ( "the system-wide entry must be visible; it is the launcher for every " "user the cask's user-scoped artifact never reaches" @@ -585,9 +605,9 @@ def test_chairlift_desktop_entry_execs_the_homebrew_wrapper(): def test_chairlift_icons_ship_system_wide(): - """Icon=org.frostyard.ChairLift only resolves if the theme icon exists in - a system search path; the flower and symbolic variants are referenced by - the app itself.""" + """Icon=io.projectbluefin.chairlift only resolves if the theme icon exists + in a system search path; the flower and symbolic variants are referenced + by the app itself.""" for icon in ICONS: assert icon.is_file(), f"missing icon: {icon.relative_to(ROOT)}" assert icon.stat().st_size > 0, f"empty icon: {icon.relative_to(ROOT)}" @@ -600,6 +620,6 @@ def test_chairlift_desktop_entry_records_upstream_provenance(): """These are verbatim upstream GPL-3.0 artifacts. Keep the attribution and the version next to them so a cask bump has an obvious place to look.""" header = DESKTOP_FILE.read_text(encoding="utf-8") - assert "frostyard/chairlift" in header - assert "v0.10.1" in header + assert "projectbluefin/chairlift" in header + assert "v0.12.2" in header assert "GPL-3.0" in header From 0a083f84d32ba815dacaf4656f40ef60e0ceb19c Mon Sep 17 00:00:00 2001 From: castrojo Date: Wed, 23 Sep 2026 09:15:20 -0400 Subject: [PATCH 2/6] fix(brew-preinstall): migrate legacy frostyard/tap/chairlift before bundling --- .../shared/usr/libexec/brew-preinstall | 11 ++++++ tests/test_brew_preinstall.bats | 36 +++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/system_files/shared/usr/libexec/brew-preinstall b/system_files/shared/usr/libexec/brew-preinstall index ed95e8ac..bf10034b 100755 --- a/system_files/shared/usr/libexec/brew-preinstall +++ b/system_files/shared/usr/libexec/brew-preinstall @@ -96,6 +96,17 @@ fi echo "brew-preinstall: Brewfiles changed (${current_hash:0:12}...), applying..." +# Migrate legacy frostyard/tap/chairlift before bundling new ublue-os tap cask. +# Homebrew's cask_installed? matches on the un-namespaced token ("chairlift") +# and skips upgrade across different taps, leaving users stranded on v0.10.1. +if [[ "${external_chairlift}" -eq 0 ]] && command -v jq >/dev/null 2>&1; then + installed_chairlift_tap=$(brew info --cask --json=v2 chairlift 2>/dev/null | jq -r '.casks[0].tap // empty' 2>/dev/null || true) + if [[ "${installed_chairlift_tap}" == "frostyard/tap" ]]; then + echo "brew-preinstall: migrating legacy frostyard/tap/chairlift before bundle..." + brew uninstall --cask frostyard/tap/chairlift || true + fi +fi + # Install all packages declared in Brewfiles (brew bundle is idempotent). # Continue after an individual failure so independent Brewfiles still install, # but leave state untouched so the complete run is retried. diff --git a/tests/test_brew_preinstall.bats b/tests/test_brew_preinstall.bats index 40173eb7..796a1428 100644 --- a/tests/test_brew_preinstall.bats +++ b/tests/test_brew_preinstall.bats @@ -794,6 +794,42 @@ BREWMOCK [[ "${output}" == *"BUNDLE-OUTPUT-MARKER"* ]] } + +@test "brew-preinstall: uninstalls legacy frostyard/tap/chairlift before bundling new cask" { + cat > "${WORKDIR}/preinstall.d/chairlift.Brewfile" << 'BREWFILE' +tap "ublue-os/homebrew-tap", trusted: true +cask "ublue-os/homebrew-tap/chairlift" +BREWFILE + + cat > "${WORKDIR}/bin/brew" << BREWMOCK +#!/usr/bin/env bash +BREW_LOG="\${BREW_LOG:-/dev/null}" +printf 'brew %s\n' "\$*" >> "\${BREW_LOG}" +case "\$1" in + shellenv) printf 'export PATH="%s:\${PATH}"\n' "${WORKDIR}/bin" ;; + tap|trust) exit 0 ;; + info) + if [[ "\$*" == *"--cask --json=v2 chairlift"* ]]; then + echo '{"casks":[{"token":"chairlift","tap":"frostyard/tap"}]}' + fi + ;; + uninstall) + exit 0 + ;; + bundle) + exit 0 + ;; + list) + exit 0 + ;; +esac +BREWMOCK + chmod +x "${WORKDIR}/bin/brew" + + BREW_LOG="${WORKDIR}/brew.log" run bash "${PATCHED_SCRIPT}" + [ "${status}" -eq 0 ] + grep -q "brew uninstall --cask frostyard/tap/chairlift" "${WORKDIR}/brew.log" +} @test "brew-preinstall: never references /dev/stderr (ENXIO under systemd)" { ! sed 's/#.*//' "${BREW_PREINSTALL}" | grep -q "/dev/stderr" } From b8d40e9b8dcba39e6b599b63e36215cadde21607 Mon Sep 17 00:00:00 2001 From: castrojo Date: Wed, 23 Sep 2026 09:31:20 -0400 Subject: [PATCH 3/6] fix(brew-preinstall): fail closed when legacy chairlift uninstall fails --- .../shared/usr/libexec/brew-preinstall | 9 ++- tests/test_brew_preinstall.bats | 62 +++++++++++++++++++ 2 files changed, 68 insertions(+), 3 deletions(-) diff --git a/system_files/shared/usr/libexec/brew-preinstall b/system_files/shared/usr/libexec/brew-preinstall index bf10034b..b71ea1d3 100755 --- a/system_files/shared/usr/libexec/brew-preinstall +++ b/system_files/shared/usr/libexec/brew-preinstall @@ -95,22 +95,25 @@ if [[ "${current_hash}" == "${stored_hash}" ]]; then fi echo "brew-preinstall: Brewfiles changed (${current_hash:0:12}...), applying..." +bundle_failed=0 # Migrate legacy frostyard/tap/chairlift before bundling new ublue-os tap cask. # Homebrew's cask_installed? matches on the un-namespaced token ("chairlift") # and skips upgrade across different taps, leaving users stranded on v0.10.1. -if [[ "${external_chairlift}" -eq 0 ]] && command -v jq >/dev/null 2>&1; then +if [[ "${external_chairlift}" -eq 0 ]]; then installed_chairlift_tap=$(brew info --cask --json=v2 chairlift 2>/dev/null | jq -r '.casks[0].tap // empty' 2>/dev/null || true) if [[ "${installed_chairlift_tap}" == "frostyard/tap" ]]; then echo "brew-preinstall: migrating legacy frostyard/tap/chairlift before bundle..." - brew uninstall --cask frostyard/tap/chairlift || true + if ! brew uninstall --cask frostyard/tap/chairlift; then + echo "brew-preinstall: error: failed to uninstall legacy frostyard/tap/chairlift" + bundle_failed=1 + fi fi fi # Install all packages declared in Brewfiles (brew bundle is idempotent). # Continue after an individual failure so independent Brewfiles still install, # but leave state untouched so the complete run is retried. -bundle_failed=0 for brewfile in "${brewfiles[@]}"; do echo "brew-preinstall: bundling ${brewfile}" # Capture output for the skipped-cask check, then echo it. Do NOT pipe diff --git a/tests/test_brew_preinstall.bats b/tests/test_brew_preinstall.bats index 796a1428..7252f5cb 100644 --- a/tests/test_brew_preinstall.bats +++ b/tests/test_brew_preinstall.bats @@ -830,6 +830,68 @@ BREWMOCK [ "${status}" -eq 0 ] grep -q "brew uninstall --cask frostyard/tap/chairlift" "${WORKDIR}/brew.log" } + +@test "brew-preinstall: legacy chairlift migration failure keeps state unstamped and fails" { + cat > "${WORKDIR}/preinstall.d/chairlift.Brewfile" << 'BREWFILE' +tap "ublue-os/homebrew-tap", trusted: true +cask "ublue-os/homebrew-tap/chairlift" +BREWFILE + + cat > "${WORKDIR}/bin/brew" << BREWMOCK +#!/usr/bin/env bash +case "\$1" in + shellenv) printf 'export PATH="%s:\${PATH}"\n' "${WORKDIR}/bin" ;; + tap|trust) exit 0 ;; + info) + if [[ "\$*" == *"--cask --json=v2 chairlift"* ]]; then + echo '{"casks":[{"token":"chairlift","tap":"frostyard/tap"}]}' + fi + ;; + uninstall) + echo "uninstall simulated error" >&2 + exit 1 + ;; + bundle) + exit 0 + ;; + list) + exit 0 + ;; +esac +BREWMOCK + chmod +x "${WORKDIR}/bin/brew" + + run bash "${PATCHED_SCRIPT}" + [ "${status}" -eq 1 ] + [[ "${output}" == *"failed to uninstall legacy frostyard/tap/chairlift"* ]] + [ ! -f "${WORKDIR}/.local/share/ublue-os/brew-preinstall-state.json" ] +} + +@test "brew-preinstall: external-chairlift flag skips legacy chairlift migration" { + cat > "${WORKDIR}/preinstall.d/chairlift.Brewfile" << 'BREWFILE' +tap "ublue-os/homebrew-tap", trusted: true +cask "ublue-os/homebrew-tap/chairlift" +BREWFILE + + cat > "${WORKDIR}/bin/brew" << BREWMOCK +#!/usr/bin/env bash +BREW_LOG="\${BREW_LOG:-/dev/null}" +printf 'brew %s\n' "\$*" >> "\${BREW_LOG}" +case "\$1" in + shellenv) printf 'export PATH="%s:\${PATH}"\n' "${WORKDIR}/bin" ;; + tap|trust) exit 0 ;; + info) + echo '{"casks":[{"token":"chairlift","tap":"frostyard/tap"}]}' + ;; + uninstall|bundle|list) exit 0 ;; +esac +BREWMOCK + chmod +x "${WORKDIR}/bin/brew" + + BREW_LOG="${WORKDIR}/brew.log" run bash "${PATCHED_SCRIPT}" --external-chairlift + [ "${status}" -eq 0 ] + ! grep -q "brew uninstall --cask frostyard/tap/chairlift" "${WORKDIR}/brew.log" +} @test "brew-preinstall: never references /dev/stderr (ENXIO under systemd)" { ! sed 's/#.*//' "${BREW_PREINSTALL}" | grep -q "/dev/stderr" } From b36de14cc505857e59704cc03f9756b7f2c71fac Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Wed, 23 Sep 2026 10:04:17 -0400 Subject: [PATCH 4/6] fix(brew-preinstall): gate legacy chairlift migration on installation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `brew info --cask --json=v2 chairlift | jq '.casks[0].tap'` reports the source tap of any *resolvable* cask, not an installed one. With ChairLift never installed but `frostyard/tap` still tapped, bare-name resolution hit the frozen Frostyard cask, so the migration uninstalled a cask that was not installed, failed, and exited 1 with state unstamped — retrying every boot. Gate the migration on `brew list --cask chairlift` and on the installed receipt (`.casks[0] | select(.installed != null) | .tap`), and untap `frostyard/tap` best-effort after a successful uninstall so the bare token can never resolve back to the frozen cask. Also standardize on the canonical `ublue-os/tap` spelling. Homebrew strips the `homebrew-` repo prefix, so `ublue-os/homebrew-tap` named the same tap the rest of the repo calls `ublue-os/tap`; the dual spelling forced the handoff protection list to carry both qualified cask names. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/skills/brew-lifecycle/SKILL.md | 14 ++-- .../brew-lifecycle/references/package-set.md | 6 +- .../references/service-mechanics.md | 6 +- docs/skills/ci-pitfalls/SKILL.md | 2 +- docs/skills/workflow-map.md | 2 +- .../shared/usr/libexec/brew-preinstall | 23 ++++-- .../homebrew/preinstall.d/chairlift.Brewfile | 4 +- tests/check-chairlift-config | 6 +- tests/test_brew_preinstall.bats | 81 ++++++++++++++++--- tests/test_chairlift_config.py | 6 +- 10 files changed, 111 insertions(+), 39 deletions(-) diff --git a/docs/skills/brew-lifecycle/SKILL.md b/docs/skills/brew-lifecycle/SKILL.md index 3010f57b..ccfa6150 100644 --- a/docs/skills/brew-lifecycle/SKILL.md +++ b/docs/skills/brew-lifecycle/SKILL.md @@ -92,12 +92,12 @@ ChairLift is a managed cask installed for every user through `system_files/shared/usr/share/ublue-os/homebrew/preinstall.d/chairlift.Brewfile`: ```ruby -tap "ublue-os/homebrew-tap", trusted: true -cask "ublue-os/homebrew-tap/chairlift" +tap "ublue-os/tap", trusted: true +cask "ublue-os/tap/chairlift" ``` The tap line requires `trusted: true`; Homebrew 6 blocks untrusted taps. The -cask must remain pinned upstream in `ublue-os/homebrew-tap` rather than being +cask must remain pinned upstream in `ublue-os/tap` rather than being replaced with a local mutable download in common. Bluefin owns `/usr/share/chairlift/config.yml`, shipped from @@ -133,8 +133,8 @@ Images that supply their own ChairLift migration may invoke common's existing behavior is unchanged. The opt-in excludes the entire `chairlift.Brewfile` from tapping, hashing, bundling and managed state, and protects the historical unqualified, Frostyard-qualified and Bluefin-qualified -ChairLift cask names (including both `ublue-os/tap/chairlift` and -`ublue-os/homebrew-tap/chairlift`) from OS-diet removal. Other packages keep +ChairLift cask names (`chairlift`, `frostyard/tap/chairlift` and +`ublue-os/tap/chairlift`) from OS-diet removal. Other packages keep their usual lifecycle. Keep that Brewfile dedicated to ChairLift; do not place unrelated packages in it. @@ -153,8 +153,8 @@ call an older script that ignores the option. Homebrew 6.0 syntax — `trusted: true` is required: ```ruby -tap "ublue-os/homebrew-tap", trusted: true -cask "ublue-os/homebrew-tap/chairlift" +tap "ublue-os/tap", trusted: true +cask "ublue-os/tap/chairlift" ``` Without `trusted: true` the tap is blocked and the formula is silently unavailable. See [placement-rules.md](references/placement-rules.md#homebrew-60-tap-trust-required-as-of-2026-06-11). diff --git a/docs/skills/brew-lifecycle/references/package-set.md b/docs/skills/brew-lifecycle/references/package-set.md index 44f50845..9443ca58 100644 --- a/docs/skills/brew-lifecycle/references/package-set.md +++ b/docs/skills/brew-lifecycle/references/package-set.md @@ -33,14 +33,14 @@ provision managed packages for every variant. The CLI set contains 11 packages: installs ChairLift for every user: ```ruby -tap "ublue-os/homebrew-tap", trusted: true -cask "ublue-os/homebrew-tap/chairlift" +tap "ublue-os/tap", trusted: true +cask "ublue-os/tap/chairlift" ``` This is OS-managed like the default formula set: add the cask and every user gets it on next login after update; remove it and users whose state file shows it as managed have it uninstalled. The `trusted: true` tap flag is required. -The cask must remain pinned upstream in `ublue-os/homebrew-tap`, where it +The cask must remain pinned upstream in `ublue-os/tap`, where it tracks the rebranded `projectbluefin/chairlift` releases; do not vendor an unpinned replacement cask into common. diff --git a/docs/skills/brew-lifecycle/references/service-mechanics.md b/docs/skills/brew-lifecycle/references/service-mechanics.md index 0d1e6b07..01fd49c3 100644 --- a/docs/skills/brew-lifecycle/references/service-mechanics.md +++ b/docs/skills/brew-lifecycle/references/service-mechanics.md @@ -97,12 +97,12 @@ ChairLift is a managed cask installed for every user from Keep both lines load-bearing: ```ruby -tap "ublue-os/homebrew-tap", trusted: true -cask "ublue-os/homebrew-tap/chairlift" +tap "ublue-os/tap", trusted: true +cask "ublue-os/tap/chairlift" ``` Homebrew 6 requires `trusted: true` for the tap, and the cask must remain -pinned upstream in `ublue-os/homebrew-tap`, which tracks the rebranded +pinned upstream in `ublue-os/tap`, which tracks the rebranded `projectbluefin/chairlift` releases. Bluefin owns the maintainer defaults at `/usr/share/chairlift/config.yml` diff --git a/docs/skills/ci-pitfalls/SKILL.md b/docs/skills/ci-pitfalls/SKILL.md index 77fd6db0..27c69e5b 100644 --- a/docs/skills/ci-pitfalls/SKILL.md +++ b/docs/skills/ci-pitfalls/SKILL.md @@ -55,7 +55,7 @@ This is networked and syncs the declared taps, so it stays outside `just check`. Sync the complete declared tap set before checking any package names. Per-file tap setup makes bare-name resolution depend on traversal order: ChairLift adds -`ublue-os/homebrew-tap`, which also provides the wallpaper casks. +`ublue-os/tap`, which also provides the wallpaper casks. Use fully qualified names in `artwork.Brewfile`; do not hide the collision by isolating taps or skipping unchanged Brewfiles. Zed's Linux cask lives in `ublue-os/tap`, not `ublue-os/experimental-tap`. diff --git a/docs/skills/workflow-map.md b/docs/skills/workflow-map.md index 3ee80d48..bb67aea8 100644 --- a/docs/skills/workflow-map.md +++ b/docs/skills/workflow-map.md @@ -57,7 +57,7 @@ Load this when you need to understand **what each GitHub workflow in `projectblu `validate-chairlift-config.yaml` validates against an external upstream schema that can drift without a common commit. It fetches ChairLift's page, group, and field names and fails closed because unknown keys disable the whole -application. It reads the tag the `ublue-os/homebrew-tap` cask pins rather +application. It reads the tag the `ublue-os/tap` cask pins rather than upstream `main`, so it cannot green-light a key the shipped binary rejects. It is a separate workflow on purpose: `just check` must stay hermetic, so no diff --git a/system_files/shared/usr/libexec/brew-preinstall b/system_files/shared/usr/libexec/brew-preinstall index b71ea1d3..3327f479 100755 --- a/system_files/shared/usr/libexec/brew-preinstall +++ b/system_files/shared/usr/libexec/brew-preinstall @@ -58,7 +58,7 @@ eval "$("${BREW_BIN}" shellenv)" # installability before processing the Brewfile's own tap lines, so on a # system that has never seen the tap it skips the cask as "requires macOS" # and still exits 0 — the cask is silently never installed (first boot of -# any image shipping a tap+cask pair, e.g. ublue-os/homebrew-tap + chairlift). +# any image shipping a tap+cask pair, e.g. ublue-os/tap + chairlift). # Vendored taps are also trusted: brew refuses casks from untrusted taps, # and the Brewfile's own `trusted:` flag doesn't apply to an already-present # tap. This runs every boot, before the hash early-exit, because @@ -100,11 +100,23 @@ bundle_failed=0 # Migrate legacy frostyard/tap/chairlift before bundling new ublue-os tap cask. # Homebrew's cask_installed? matches on the un-namespaced token ("chairlift") # and skips upgrade across different taps, leaving users stranded on v0.10.1. -if [[ "${external_chairlift}" -eq 0 ]]; then - installed_chairlift_tap=$(brew info --cask --json=v2 chairlift 2>/dev/null | jq -r '.casks[0].tap // empty' 2>/dev/null || true) +# +# Gate on *installation*, not resolvability: `brew info --cask chairlift` +# happily resolves the bare token from any tapped source, so a machine that +# merely has frostyard/tap tapped (ChairLift never installed) would otherwise +# uninstall a non-installed cask, fail, and retry the whole run every boot. +if [[ "${external_chairlift}" -eq 0 ]] && brew list --cask chairlift &>/dev/null; then + installed_chairlift_tap=$(brew info --cask --json=v2 chairlift 2>/dev/null \ + | jq -r '.casks[0] | select(.installed != null) | .tap // empty' 2>/dev/null || true) if [[ "${installed_chairlift_tap}" == "frostyard/tap" ]]; then echo "brew-preinstall: migrating legacy frostyard/tap/chairlift before bundle..." - if ! brew uninstall --cask frostyard/tap/chairlift; then + if brew uninstall --cask frostyard/tap/chairlift; then + # Drop the frozen pre-rebrand tap so the bare token can never + # resolve back to it. Best-effort: a leftover tap is harmless + # once the cask is gone, and other casks may still need it. + brew untap frostyard/tap \ + || echo "brew-preinstall: warning: could not untap frostyard/tap" + else echo "brew-preinstall: error: failed to uninstall legacy frostyard/tap/chairlift" bundle_failed=1 fi @@ -169,8 +181,7 @@ previous_casks=$(jq -r --argjson external "${external_chairlift}" ' select($external == 0 or (. != "chairlift" and . != "frostyard/tap/chairlift" - and . != "ublue-os/tap/chairlift" - and . != "ublue-os/homebrew-tap/chairlift")) + and . != "ublue-os/tap/chairlift")) ' "${STATE_FILE}" 2>/dev/null \ | sort -u || true) diff --git a/system_files/shared/usr/share/ublue-os/homebrew/preinstall.d/chairlift.Brewfile b/system_files/shared/usr/share/ublue-os/homebrew/preinstall.d/chairlift.Brewfile index 7578b375..620295f3 100644 --- a/system_files/shared/usr/share/ublue-os/homebrew/preinstall.d/chairlift.Brewfile +++ b/system_files/shared/usr/share/ublue-os/homebrew/preinstall.d/chairlift.Brewfile @@ -1,2 +1,2 @@ -tap "ublue-os/homebrew-tap", trusted: true -cask "ublue-os/homebrew-tap/chairlift" +tap "ublue-os/tap", trusted: true +cask "ublue-os/tap/chairlift" diff --git a/tests/check-chairlift-config b/tests/check-chairlift-config index 70da06f2..281c3709 100755 --- a/tests/check-chairlift-config +++ b/tests/check-chairlift-config @@ -18,7 +18,7 @@ Sources of truth, both read live from upstream at the PINNED tag: ActionConfig in internal/config/config.go. config.yml cannot supply these, because it only exercises a subset of the optional fields. -The tag matters. Bluefin ships whichever release ublue-os/homebrew-tap's +The tag matters. Bluefin ships whichever release ublue-os/tap's chairlift cask pins, so that binary's loader is the only one whose opinion counts. Validating against upstream's moving `main` would false-red on renames that @@ -44,7 +44,7 @@ import urllib.request import yaml #: The ChairLift release Bluefin actually ships, pinned by -#: ublue-os/homebrew-tap's chairlift cask. Single source of truth for every +#: ublue-os/tap's chairlift cask. Single source of truth for every #: upstream fetch below; bump it together with the cask version, never #: independently. CHAIRLIFT_SCHEMA_REF = "v0.12.2" @@ -166,7 +166,7 @@ def main() -> int: print( f"ChairLift schema @ {CHAIRLIFT_SCHEMA_REF} " - "(pinned by the ublue-os/homebrew-tap cask):" + "(pinned by the ublue-os/tap cask):" ) for page, groups in sorted(upstream.items()): print(f" {page}: {', '.join(sorted(groups))}") diff --git a/tests/test_brew_preinstall.bats b/tests/test_brew_preinstall.bats index 7252f5cb..c197940a 100644 --- a/tests/test_brew_preinstall.bats +++ b/tests/test_brew_preinstall.bats @@ -152,7 +152,7 @@ EOF printf 'tap "frostyard/tap", trusted: true\ncask "chairlift"\n' > "${WORKDIR}/preinstall.d/chairlift.Brewfile" printf 'brew "jq"\n' > "${WORKDIR}/preinstall.d/system-cli.Brewfile" mkdir -p "${WORKDIR}/.local/share/ublue-os" - printf '{"hash":"old","packages":["jq"],"casks":["chairlift","frostyard/tap/chairlift","ublue-os/tap/chairlift","ublue-os/homebrew-tap/chairlift","other"]}\n' \ + printf '{"hash":"old","packages":["jq"],"casks":["chairlift","frostyard/tap/chairlift","ublue-os/tap/chairlift","other"]}\n' \ > "${WORKDIR}/.local/share/ublue-os/brew-preinstall-state.json" BREW_LOG="${WORKDIR}/brew.log" run bash "${PATCHED_WRAPPER}" --external-chairlift @@ -797,8 +797,8 @@ BREWMOCK @test "brew-preinstall: uninstalls legacy frostyard/tap/chairlift before bundling new cask" { cat > "${WORKDIR}/preinstall.d/chairlift.Brewfile" << 'BREWFILE' -tap "ublue-os/homebrew-tap", trusted: true -cask "ublue-os/homebrew-tap/chairlift" +tap "ublue-os/tap", trusted: true +cask "ublue-os/tap/chairlift" BREWFILE cat > "${WORKDIR}/bin/brew" << BREWMOCK @@ -810,7 +810,7 @@ case "\$1" in tap|trust) exit 0 ;; info) if [[ "\$*" == *"--cask --json=v2 chairlift"* ]]; then - echo '{"casks":[{"token":"chairlift","tap":"frostyard/tap"}]}' + echo '{"casks":[{"token":"chairlift","tap":"frostyard/tap","installed":"0.10.1"}]}' fi ;; uninstall) @@ -829,12 +829,73 @@ BREWMOCK BREW_LOG="${WORKDIR}/brew.log" run bash "${PATCHED_SCRIPT}" [ "${status}" -eq 0 ] grep -q "brew uninstall --cask frostyard/tap/chairlift" "${WORKDIR}/brew.log" + grep -q "brew untap frostyard/tap" "${WORKDIR}/brew.log" +} + +@test "brew-preinstall: leaves a merely-resolvable legacy chairlift installed elsewhere alone" { + cat > "${WORKDIR}/preinstall.d/chairlift.Brewfile" << 'BREWFILE' +tap "ublue-os/tap", trusted: true +cask "ublue-os/tap/chairlift" +BREWFILE + + # frostyard/tap is still tapped, so the bare token resolves to its frozen + # cask — but ChairLift is not installed. Nothing to migrate. + cat > "${WORKDIR}/bin/brew" << BREWMOCK +#!/usr/bin/env bash +BREW_LOG="\${BREW_LOG:-/dev/null}" +printf 'brew %s\n' "\$*" >> "\${BREW_LOG}" +case "\$1" in + shellenv) printf 'export PATH="%s:\${PATH}"\n' "${WORKDIR}/bin" ;; + tap|trust) exit 0 ;; + info) + echo '{"casks":[{"token":"chairlift","tap":"frostyard/tap"}]}' + ;; + list) + exit 1 + ;; + uninstall|untap) exit 0 ;; + bundle) exit 0 ;; +esac +BREWMOCK + chmod +x "${WORKDIR}/bin/brew" + + BREW_LOG="${WORKDIR}/brew.log" run bash "${PATCHED_SCRIPT}" + [ "${status}" -eq 0 ] + ! grep -q "brew uninstall --cask frostyard/tap/chairlift" "${WORKDIR}/brew.log" + ! grep -q "brew untap frostyard/tap" "${WORKDIR}/brew.log" + [ -f "${WORKDIR}/.local/share/ublue-os/brew-preinstall-state.json" ] +} + +@test "brew-preinstall: leaves an installed non-legacy chairlift alone" { + cat > "${WORKDIR}/preinstall.d/chairlift.Brewfile" << 'BREWFILE' +tap "ublue-os/tap", trusted: true +cask "ublue-os/tap/chairlift" +BREWFILE + + cat > "${WORKDIR}/bin/brew" << BREWMOCK +#!/usr/bin/env bash +BREW_LOG="\${BREW_LOG:-/dev/null}" +printf 'brew %s\n' "\$*" >> "\${BREW_LOG}" +case "\$1" in + shellenv) printf 'export PATH="%s:\${PATH}"\n' "${WORKDIR}/bin" ;; + tap|trust) exit 0 ;; + info) + echo '{"casks":[{"token":"chairlift","tap":"ublue-os/tap","installed":"0.12.2"}]}' + ;; + uninstall|untap|bundle|list) exit 0 ;; +esac +BREWMOCK + chmod +x "${WORKDIR}/bin/brew" + + BREW_LOG="${WORKDIR}/brew.log" run bash "${PATCHED_SCRIPT}" + [ "${status}" -eq 0 ] + ! grep -q "brew uninstall --cask frostyard/tap/chairlift" "${WORKDIR}/brew.log" } @test "brew-preinstall: legacy chairlift migration failure keeps state unstamped and fails" { cat > "${WORKDIR}/preinstall.d/chairlift.Brewfile" << 'BREWFILE' -tap "ublue-os/homebrew-tap", trusted: true -cask "ublue-os/homebrew-tap/chairlift" +tap "ublue-os/tap", trusted: true +cask "ublue-os/tap/chairlift" BREWFILE cat > "${WORKDIR}/bin/brew" << BREWMOCK @@ -844,7 +905,7 @@ case "\$1" in tap|trust) exit 0 ;; info) if [[ "\$*" == *"--cask --json=v2 chairlift"* ]]; then - echo '{"casks":[{"token":"chairlift","tap":"frostyard/tap"}]}' + echo '{"casks":[{"token":"chairlift","tap":"frostyard/tap","installed":"0.10.1"}]}' fi ;; uninstall) @@ -869,8 +930,8 @@ BREWMOCK @test "brew-preinstall: external-chairlift flag skips legacy chairlift migration" { cat > "${WORKDIR}/preinstall.d/chairlift.Brewfile" << 'BREWFILE' -tap "ublue-os/homebrew-tap", trusted: true -cask "ublue-os/homebrew-tap/chairlift" +tap "ublue-os/tap", trusted: true +cask "ublue-os/tap/chairlift" BREWFILE cat > "${WORKDIR}/bin/brew" << BREWMOCK @@ -881,7 +942,7 @@ case "\$1" in shellenv) printf 'export PATH="%s:\${PATH}"\n' "${WORKDIR}/bin" ;; tap|trust) exit 0 ;; info) - echo '{"casks":[{"token":"chairlift","tap":"frostyard/tap"}]}' + echo '{"casks":[{"token":"chairlift","tap":"frostyard/tap","installed":"0.10.1"}]}' ;; uninstall|bundle|list) exit 0 ;; esac diff --git a/tests/test_chairlift_config.py b/tests/test_chairlift_config.py index 67a6c790..9c416e0d 100644 --- a/tests/test_chairlift_config.py +++ b/tests/test_chairlift_config.py @@ -539,12 +539,12 @@ def test_help_links_point_at_bluefin(): def test_brewfile_taps_homebrew_tap_with_trust(): """Homebrew 6 blocks untrusted taps silently; trusted: true is load-bearing. - The cask must stay the rebranded upstream release: ublue-os/homebrew-tap + The cask must stay the rebranded upstream release: ublue-os/tap pins projectbluefin/chairlift, which is the source the schema gate in tests/check-chairlift-config validates against.""" content = BREWFILE.read_text(encoding="utf-8") - assert 'tap "ublue-os/homebrew-tap", trusted: true' in content - assert 'cask "ublue-os/homebrew-tap/chairlift"' in content + assert 'tap "ublue-os/tap", trusted: true' in content + assert 'cask "ublue-os/tap/chairlift"' in content # --------------------------------------------------------------------------- From 882fe6037ca2fc39c1843cf37fd8867208ed57c2 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Wed, 23 Sep 2026 10:56:05 -0400 Subject: [PATCH 5/6] fix(brew-preinstall): detect legacy chairlift from installed state only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The migration resolved the installing tap with a bare-token `brew info --cask --json=v2 chairlift`. By that point ublue-os/tap has already been tapped, so both taps provide the token: brew either raises TapCaskAmbiguityError or answers for the new, still-uninstalled cask. Both answers were swallowed, the migration silently skipped, brew bundle's token-matching cask_installed? saw ChairLift as present, the run exited 0 and stamped its hash — so it never retried and the user stayed stranded on the pre-rebrand v0.10.1 build. Read installed state instead: `brew info --json=v2 --installed` enumerates the Caskroom and resolves each entry from its own installed caskfile, so the tap it reports is the tap that installed it. Skip the migration only on positive evidence (tap is ublue-os/tap, or an unreadable tap whose installed version already matches the cask the new tap ships); anything inconclusive migrates, since the bundle that runs immediately afterwards repairs a redundant uninstall while a skipped migration repairs nothing. Only Brewfiles that actually declare ublue-os/tap/chairlift take part, and the legacy cask is named fully-qualified only while frostyard/tap is still present. Also correct the shipped config.yml comment that still claimed updates_page has exactly four groups; upstream v0.12.2 defines six. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../references/service-mechanics.md | 10 + .../shared/usr/libexec/brew-preinstall | 72 +++++- .../shared/usr/share/chairlift/config.yml | 6 +- tests/test_brew_preinstall.bats | 227 ++++++++++-------- 4 files changed, 195 insertions(+), 120 deletions(-) diff --git a/docs/skills/brew-lifecycle/references/service-mechanics.md b/docs/skills/brew-lifecycle/references/service-mechanics.md index 01fd49c3..3a1e1288 100644 --- a/docs/skills/brew-lifecycle/references/service-mechanics.md +++ b/docs/skills/brew-lifecycle/references/service-mechanics.md @@ -105,6 +105,16 @@ Homebrew 6 requires `trusted: true` for the tap, and the cask must remain pinned upstream in `ublue-os/tap`, which tracks the rebranded `projectbluefin/chairlift` releases. +Machines upgrading from the pre-rebrand `frostyard/tap/chairlift` cask are +migrated by `brew-preinstall` before it bundles. Detection must read +*installed* state only (`brew info --json=v2 --installed`, which enumerates the +Caskroom and resolves each entry from its own installed caskfile): once both +taps are present, the bare token `chairlift` is ambiguous, so +`brew info --cask chairlift` either errors or answers for the new, uninstalled +cask. An inconclusive answer migrates rather than skips — the bundle that runs +immediately afterwards repairs a redundant uninstall, while a skipped migration +strands the user on v0.10.1 with the hash already stamped. + Bluefin owns the maintainer defaults at `/usr/share/chairlift/config.yml` (`system_files/shared/usr/share/chairlift/config.yml` in this repo). Admins own `/etc/chairlift/config.yml`; never overwrite that path from image content, diff --git a/system_files/shared/usr/libexec/brew-preinstall b/system_files/shared/usr/libexec/brew-preinstall index 3327f479..1a96c88f 100755 --- a/system_files/shared/usr/libexec/brew-preinstall +++ b/system_files/shared/usr/libexec/brew-preinstall @@ -101,23 +101,69 @@ bundle_failed=0 # Homebrew's cask_installed? matches on the un-namespaced token ("chairlift") # and skips upgrade across different taps, leaving users stranded on v0.10.1. # -# Gate on *installation*, not resolvability: `brew info --cask chairlift` -# happily resolves the bare token from any tapped source, so a machine that -# merely has frostyard/tap tapped (ChairLift never installed) would otherwise -# uninstall a non-installed cask, fail, and retry the whole run every boot. -if [[ "${external_chairlift}" -eq 0 ]] && brew list --cask chairlift &>/dev/null; then - installed_chairlift_tap=$(brew info --cask --json=v2 chairlift 2>/dev/null \ - | jq -r '.casks[0] | select(.installed != null) | .tap // empty' 2>/dev/null || true) - if [[ "${installed_chairlift_tap}" == "frostyard/tap" ]]; then - echo "brew-preinstall: migrating legacy frostyard/tap/chairlift before bundle..." - if brew uninstall --cask frostyard/tap/chairlift; then +# Gate on *installation*, not resolvability: the bare token resolves from any +# tapped source, so a machine that merely has frostyard/tap tapped (ChairLift +# never installed) would otherwise uninstall a non-installed cask, fail, and +# retry the whole run every boot. +# +# Ask what is *installed*, never what the bare token resolves to: ublue-os/tap +# was tapped above, so both taps now provide "chairlift" and +# `brew info --cask chairlift` either raises an ambiguity error or answers for +# the new, still-uninstalled cask. Either answer would skip the migration, +# brew bundle's token-matching cask_installed? would then see ChairLift as +# present, the run would exit 0 and stamp the hash — stranding the user on the +# pre-rebrand v0.10.1 build forever. `brew info --json=v2 --installed` +# enumerates the Caskroom instead and resolves each entry from its own +# installed caskfile, so the tap it reports is the tap that installed it. +# +# Inconclusive answers migrate rather than skip: a redundant uninstall is +# repaired by the bundle that runs immediately after, a skipped migration is +# not repaired at all. Only images that actually bundle the rebranded cask +# take part, so a Brewfile still pinning the old bare token is left alone. +if [[ "${external_chairlift}" -eq 0 ]] \ + && grep -qs "ublue-os/tap/chairlift" "${brewfiles[@]}" \ + && brew list --cask chairlift &>/dev/null; then + installed_chairlift_tap=$(brew info --json=v2 --installed 2>/dev/null \ + | jq -r 'first(.casks[]? | select(.token == "chairlift") | .tap // empty) // empty' \ + 2>/dev/null || true) + # Fully qualified, so this lookup can never be ambiguous. + tapped_chairlift_version=$(brew info --cask --json=v2 ublue-os/tap/chairlift 2>/dev/null \ + | jq -r 'first(.casks[]?.version // empty) // empty' 2>/dev/null || true) + installed_chairlift_version=$(brew list --cask --versions chairlift 2>/dev/null \ + | awk 'NR == 1 { print $2 }' || true) + + chairlift_migrated=0 + if [[ "${installed_chairlift_tap}" == "ublue-os/tap" ]]; then + chairlift_migrated=1 + elif [[ -z "${installed_chairlift_tap}" \ + && -n "${tapped_chairlift_version}" \ + && "${installed_chairlift_version}" == "${tapped_chairlift_version}" ]]; then + # Installed caskfile records no tap (older brew, or a repaired + # receipt), but the installed build already matches the cask the new + # tap ships — nothing legacy left to migrate. + chairlift_migrated=1 + fi + + if [[ "${chairlift_migrated}" -eq 0 ]]; then + echo "brew-preinstall: migrating legacy chairlift (installed tap: ${installed_chairlift_tap:-unknown}) before bundle..." + # Name the legacy cask explicitly while its tap is still present; + # once it is gone the bare token is unambiguous again. + legacy_chairlift_token=chairlift + frostyard_tapped=0 + if brew tap 2>/dev/null | grep -Fxq frostyard/tap; then + legacy_chairlift_token=frostyard/tap/chairlift + frostyard_tapped=1 + fi + if brew uninstall --cask "${legacy_chairlift_token}"; then # Drop the frozen pre-rebrand tap so the bare token can never # resolve back to it. Best-effort: a leftover tap is harmless # once the cask is gone, and other casks may still need it. - brew untap frostyard/tap \ - || echo "brew-preinstall: warning: could not untap frostyard/tap" + if [[ "${frostyard_tapped}" -eq 1 ]]; then + brew untap frostyard/tap \ + || echo "brew-preinstall: warning: could not untap frostyard/tap" + fi else - echo "brew-preinstall: error: failed to uninstall legacy frostyard/tap/chairlift" + echo "brew-preinstall: error: failed to uninstall legacy ${legacy_chairlift_token}" bundle_failed=1 fi fi diff --git a/system_files/shared/usr/share/chairlift/config.yml b/system_files/shared/usr/share/chairlift/config.yml index 61d63a17..6b9f769c 100644 --- a/system_files/shared/usr/share/chairlift/config.yml +++ b/system_files/shared/usr/share/chairlift/config.yml @@ -31,8 +31,10 @@ updates_page: # Update policy on Bluefin belongs to uupd (silent background staging, # 6-hour timer + AC connect; user reboots on their own schedule). # ChairLift has no update-scheduling group to disable here: upstream's - # updates_page schema is exactly the four groups above. Its bootc group - # is a manual "stage now" action only, so nothing competes with uupd. + # updates_page schema is a fixed set of groups (as of v0.12.2, the four + # above plus update_all_group and sysupdate_updates_group, which we leave + # unlisted so they keep upstream's defaults). Its bootc group is a manual + # "stage now" action only, so nothing competes with uupd. # Do NOT invent a group to express this policy — an unknown group key # fails ChairLift's strict schema validation and disables the entire # app (see docs/skills/brew-lifecycle.md). diff --git a/tests/test_brew_preinstall.bats b/tests/test_brew_preinstall.bats index c197940a..e688e037 100644 --- a/tests/test_brew_preinstall.bats +++ b/tests/test_brew_preinstall.bats @@ -795,36 +795,81 @@ BREWMOCK } -@test "brew-preinstall: uninstalls legacy frostyard/tap/chairlift before bundling new cask" { - cat > "${WORKDIR}/preinstall.d/chairlift.Brewfile" << 'BREWFILE' -tap "ublue-os/tap", trusted: true -cask "ublue-os/tap/chairlift" -BREWFILE +# Models the real dual-tap machine this migration must survive: both +# frostyard/tap and ublue-os/tap provide the bare "chairlift" token, so +# `brew info --cask chairlift` raises TapCaskAmbiguityError (exit 1) just as +# Homebrew does. Only installed-only lookups (`info --json=v2 --installed`, +# `list --cask --versions`) and fully-qualified tokens answer here. +# +# $1 installed tap: a tap name, "unknown" (installed caskfile records no tap) +# or "none" (ChairLift not installed) +# $2 installed version $3 version the new tap ships +# $4 space-separated tapped taps $5 exit code for `brew uninstall` +write_chairlift_brew_mock() { + local installed_tap="$1" installed_version="$2" tapped_version="$3" + local taps="$4" uninstall_rc="${5:-0}" cat > "${WORKDIR}/bin/brew" << BREWMOCK #!/usr/bin/env bash BREW_LOG="\${BREW_LOG:-/dev/null}" printf 'brew %s\n' "\$*" >> "\${BREW_LOG}" +installed_tap='${installed_tap}' +installed_version='${installed_version}' +tapped_version='${tapped_version}' +taps='${taps}' case "\$1" in shellenv) printf 'export PATH="%s:\${PATH}"\n' "${WORKDIR}/bin" ;; - tap|trust) exit 0 ;; - info) - if [[ "\$*" == *"--cask --json=v2 chairlift"* ]]; then - echo '{"casks":[{"token":"chairlift","tap":"frostyard/tap","installed":"0.10.1"}]}' + tap) + if [[ \$# -eq 1 ]]; then + printf '%s\n' \${taps} fi ;; - uninstall) - exit 0 - ;; - bundle) - exit 0 + trust|untap) exit 0 ;; + info) + if [[ "\$*" == *"--json=v2 --installed"* ]]; then + if [[ "\${installed_tap}" == none ]]; then + echo '{"formulae":[],"casks":[]}' + elif [[ "\${installed_tap}" == unknown ]]; then + printf '{"formulae":[],"casks":[{"token":"chairlift","tap":null,"installed":"%s"}]}\n' \\ + "\${installed_version}" + else + printf '{"formulae":[],"casks":[{"token":"chairlift","tap":"%s","installed":"%s"}]}\n' \\ + "\${installed_tap}" "\${installed_version}" + fi + exit 0 + fi + if [[ "\$*" == *"ublue-os/tap/chairlift"* ]]; then + printf '{"casks":[{"token":"chairlift","tap":"ublue-os/tap","version":"%s"}]}\n' \\ + "\${tapped_version}" + exit 0 + fi + echo 'Error: Cask chairlift exists in multiple taps.' >&2 + exit 1 ;; list) + [[ "\${installed_tap}" == none ]] && exit 1 + if [[ "\$*" == *"--versions"* ]]; then + printf 'chairlift %s\n' "\${installed_version}" + fi exit 0 ;; + uninstall) exit ${uninstall_rc} ;; + bundle) exit 0 ;; esac BREWMOCK chmod +x "${WORKDIR}/bin/brew" +} + +write_rebranded_chairlift_brewfile() { + cat > "${WORKDIR}/preinstall.d/chairlift.Brewfile" << 'BREWFILE' +tap "ublue-os/tap", trusted: true +cask "ublue-os/tap/chairlift" +BREWFILE +} + +@test "brew-preinstall: uninstalls legacy frostyard/tap/chairlift before bundling new cask" { + write_rebranded_chairlift_brewfile + write_chairlift_brew_mock frostyard/tap 0.10.1 0.12.2 "frostyard/tap ublue-os/tap" BREW_LOG="${WORKDIR}/brew.log" run bash "${PATCHED_SCRIPT}" [ "${status}" -eq 0 ] @@ -832,95 +877,83 @@ BREWMOCK grep -q "brew untap frostyard/tap" "${WORKDIR}/brew.log" } -@test "brew-preinstall: leaves a merely-resolvable legacy chairlift installed elsewhere alone" { - cat > "${WORKDIR}/preinstall.d/chairlift.Brewfile" << 'BREWFILE' -tap "ublue-os/tap", trusted: true -cask "ublue-os/tap/chairlift" -BREWFILE +@test "brew-preinstall: migrates even when the bare chairlift token is ambiguous" { + # The regression this guards: both taps provide "chairlift", so the old + # bare-token `brew info --cask chairlift` probe errors out. Swallowing + # that error skipped the migration, brew bundle saw the token installed, + # the run stamped its hash and never retried — stranding the user on the + # pre-rebrand build. An unreadable tap must migrate, not skip. + write_rebranded_chairlift_brewfile + write_chairlift_brew_mock unknown 0.10.1 0.12.2 "frostyard/tap ublue-os/tap" + BREW_LOG="${WORKDIR}/brew.log" run bash "${PATCHED_SCRIPT}" + [ "${status}" -eq 0 ] + grep -q "brew uninstall --cask frostyard/tap/chairlift" "${WORKDIR}/brew.log" + [[ "${output}" == *"installed tap: unknown"* ]] +} + +@test "brew-preinstall: unreadable tap on an already-current chairlift is left alone" { + # Same unreadable installed caskfile, but the installed build already + # matches what the new tap ships: nothing legacy to migrate, so no + # pointless uninstall/reinstall churn on every Brewfile change. + write_rebranded_chairlift_brewfile + write_chairlift_brew_mock unknown 0.12.2 0.12.2 "frostyard/tap ublue-os/tap" + + BREW_LOG="${WORKDIR}/brew.log" run bash "${PATCHED_SCRIPT}" + [ "${status}" -eq 0 ] + ! grep -q "brew uninstall --cask" "${WORKDIR}/brew.log" +} + +@test "brew-preinstall: migrates a stranded chairlift after frostyard/tap is gone" { + # The legacy tap can be untapped while its cask stays installed; the + # bare token is then unambiguous and is the only name left to uninstall. + write_rebranded_chairlift_brewfile + write_chairlift_brew_mock unknown 0.10.1 0.12.2 "ublue-os/tap" + + BREW_LOG="${WORKDIR}/brew.log" run bash "${PATCHED_SCRIPT}" + [ "${status}" -eq 0 ] + grep -Fxq "brew uninstall --cask chairlift" "${WORKDIR}/brew.log" + ! grep -q "brew untap" "${WORKDIR}/brew.log" +} + +@test "brew-preinstall: leaves a merely-resolvable legacy chairlift installed elsewhere alone" { # frostyard/tap is still tapped, so the bare token resolves to its frozen # cask — but ChairLift is not installed. Nothing to migrate. - cat > "${WORKDIR}/bin/brew" << BREWMOCK -#!/usr/bin/env bash -BREW_LOG="\${BREW_LOG:-/dev/null}" -printf 'brew %s\n' "\$*" >> "\${BREW_LOG}" -case "\$1" in - shellenv) printf 'export PATH="%s:\${PATH}"\n' "${WORKDIR}/bin" ;; - tap|trust) exit 0 ;; - info) - echo '{"casks":[{"token":"chairlift","tap":"frostyard/tap"}]}' - ;; - list) - exit 1 - ;; - uninstall|untap) exit 0 ;; - bundle) exit 0 ;; -esac -BREWMOCK - chmod +x "${WORKDIR}/bin/brew" + write_rebranded_chairlift_brewfile + write_chairlift_brew_mock none "" 0.12.2 "frostyard/tap ublue-os/tap" BREW_LOG="${WORKDIR}/brew.log" run bash "${PATCHED_SCRIPT}" [ "${status}" -eq 0 ] - ! grep -q "brew uninstall --cask frostyard/tap/chairlift" "${WORKDIR}/brew.log" + ! grep -q "brew uninstall --cask" "${WORKDIR}/brew.log" ! grep -q "brew untap frostyard/tap" "${WORKDIR}/brew.log" [ -f "${WORKDIR}/.local/share/ublue-os/brew-preinstall-state.json" ] } @test "brew-preinstall: leaves an installed non-legacy chairlift alone" { - cat > "${WORKDIR}/preinstall.d/chairlift.Brewfile" << 'BREWFILE' -tap "ublue-os/tap", trusted: true -cask "ublue-os/tap/chairlift" -BREWFILE + write_rebranded_chairlift_brewfile + write_chairlift_brew_mock ublue-os/tap 0.12.2 0.12.2 "frostyard/tap ublue-os/tap" - cat > "${WORKDIR}/bin/brew" << BREWMOCK -#!/usr/bin/env bash -BREW_LOG="\${BREW_LOG:-/dev/null}" -printf 'brew %s\n' "\$*" >> "\${BREW_LOG}" -case "\$1" in - shellenv) printf 'export PATH="%s:\${PATH}"\n' "${WORKDIR}/bin" ;; - tap|trust) exit 0 ;; - info) - echo '{"casks":[{"token":"chairlift","tap":"ublue-os/tap","installed":"0.12.2"}]}' - ;; - uninstall|untap|bundle|list) exit 0 ;; -esac -BREWMOCK - chmod +x "${WORKDIR}/bin/brew" + BREW_LOG="${WORKDIR}/brew.log" run bash "${PATCHED_SCRIPT}" + [ "${status}" -eq 0 ] + ! grep -q "brew uninstall --cask" "${WORKDIR}/brew.log" +} + +@test "brew-preinstall: a Brewfile still on the legacy bare cask never migrates" { + # Images that have not adopted the rebranded cask keep the old lifecycle: + # their bare "chairlift" is the cask they want installed. + printf 'tap "frostyard/tap", trusted: true\ncask "chairlift"\n' \ + > "${WORKDIR}/preinstall.d/chairlift.Brewfile" + write_chairlift_brew_mock unknown 0.10.1 "" "frostyard/tap" BREW_LOG="${WORKDIR}/brew.log" run bash "${PATCHED_SCRIPT}" [ "${status}" -eq 0 ] - ! grep -q "brew uninstall --cask frostyard/tap/chairlift" "${WORKDIR}/brew.log" + ! grep -q "brew uninstall --cask" "${WORKDIR}/brew.log" + ! grep -q "brew untap" "${WORKDIR}/brew.log" } @test "brew-preinstall: legacy chairlift migration failure keeps state unstamped and fails" { - cat > "${WORKDIR}/preinstall.d/chairlift.Brewfile" << 'BREWFILE' -tap "ublue-os/tap", trusted: true -cask "ublue-os/tap/chairlift" -BREWFILE - - cat > "${WORKDIR}/bin/brew" << BREWMOCK -#!/usr/bin/env bash -case "\$1" in - shellenv) printf 'export PATH="%s:\${PATH}"\n' "${WORKDIR}/bin" ;; - tap|trust) exit 0 ;; - info) - if [[ "\$*" == *"--cask --json=v2 chairlift"* ]]; then - echo '{"casks":[{"token":"chairlift","tap":"frostyard/tap","installed":"0.10.1"}]}' - fi - ;; - uninstall) - echo "uninstall simulated error" >&2 - exit 1 - ;; - bundle) - exit 0 - ;; - list) - exit 0 - ;; -esac -BREWMOCK - chmod +x "${WORKDIR}/bin/brew" + write_rebranded_chairlift_brewfile + write_chairlift_brew_mock frostyard/tap 0.10.1 0.12.2 "frostyard/tap ublue-os/tap" 1 run bash "${PATCHED_SCRIPT}" [ "${status}" -eq 1 ] @@ -929,30 +962,14 @@ BREWMOCK } @test "brew-preinstall: external-chairlift flag skips legacy chairlift migration" { - cat > "${WORKDIR}/preinstall.d/chairlift.Brewfile" << 'BREWFILE' -tap "ublue-os/tap", trusted: true -cask "ublue-os/tap/chairlift" -BREWFILE - - cat > "${WORKDIR}/bin/brew" << BREWMOCK -#!/usr/bin/env bash -BREW_LOG="\${BREW_LOG:-/dev/null}" -printf 'brew %s\n' "\$*" >> "\${BREW_LOG}" -case "\$1" in - shellenv) printf 'export PATH="%s:\${PATH}"\n' "${WORKDIR}/bin" ;; - tap|trust) exit 0 ;; - info) - echo '{"casks":[{"token":"chairlift","tap":"frostyard/tap","installed":"0.10.1"}]}' - ;; - uninstall|bundle|list) exit 0 ;; -esac -BREWMOCK - chmod +x "${WORKDIR}/bin/brew" + write_rebranded_chairlift_brewfile + write_chairlift_brew_mock frostyard/tap 0.10.1 0.12.2 "frostyard/tap ublue-os/tap" BREW_LOG="${WORKDIR}/brew.log" run bash "${PATCHED_SCRIPT}" --external-chairlift [ "${status}" -eq 0 ] - ! grep -q "brew uninstall --cask frostyard/tap/chairlift" "${WORKDIR}/brew.log" + ! grep -q "brew uninstall --cask" "${WORKDIR}/brew.log" } + @test "brew-preinstall: never references /dev/stderr (ENXIO under systemd)" { ! sed 's/#.*//' "${BREW_PREINSTALL}" | grep -q "/dev/stderr" } From c260ecdf3dc85bdea858047ec9494633f2726712 Mon Sep 17 00:00:00 2001 From: Robert Date: Wed, 23 Sep 2026 21:38:26 +0000 Subject: [PATCH 6/6] fix(tests): model Homebrew cask list exit codes in brew-preinstall mocks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rebrand migration gate reads the exit code of the named lookup `brew list --cask chairlift`, and the #1109 pre-bundle snapshot calls the bare forms `brew list --formula` / `brew list --cask`, which real Homebrew exits 0 (possibly empty) for. Neither mock in this file modeled that split, so two tests failed once both code paths existed: - "repository manifests leave untracked installs alone" ran the real Brewfiles (including the rebranded chairlift.Brewfile) against the permissive default mock, where every list form exits 0. The gate read "chairlift installed" on a machine that never had it and uninstalled a cask that was not there. Give the test a local mock override — the same idiom as "skips uninstall for cask not installed by brew" — where named cask lookups exit 1. - "leaves a merely-resolvable legacy chairlift installed elsewhere alone" used write_chairlift_brew_mock with installed_tap=none, which exited 1 for every list form. The snapshot's bare queries tripped the script's fail-closed path and the run exited 1. Scope the exit 1 to named cask lookups so bare inventory forms succeed empty, as on a real machine. Full suite: bats sweep green, 112 pytest passed, just check passed. Assisted-by: Qwen3.8-27B via goose CLI --- tests/test_brew_preinstall.bats | 32 +++++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/tests/test_brew_preinstall.bats b/tests/test_brew_preinstall.bats index d788d528..980c2354 100644 --- a/tests/test_brew_preinstall.bats +++ b/tests/test_brew_preinstall.bats @@ -417,6 +417,29 @@ EOF cp "${BATS_TEST_DIRNAME}/../system_files/shared/usr/share/ublue-os/homebrew/preinstall.d/"*.Brewfile \ "${WORKDIR}/preinstall.d/" + # Override brew mock: ChairLift is not installed on this fresh machine, + # so the named cask query must fail. With the permissive default mock + # the rebrand migration gate would read "installed" and "migrate" a + # cask that is not there. + cat > "${WORKDIR}/bin/brew" << BREWMOCK +#!/usr/bin/env bash +BREW_LOG="\${BREW_LOG:-/dev/null}" +printf 'brew %s\n' "\$*" >> "\${BREW_LOG}" +case "\$1" in + shellenv) printf 'export PATH="%s:\${PATH}"\n' "${WORKDIR}/bin" ;; + bundle) ;; + trust|untap) ;; + list) + if [[ "\$2" == "--cask" && -n "\$3" ]]; then + exit 1 + fi + exit 0 + ;; + uninstall) ;; +esac +BREWMOCK + chmod +x "${WORKDIR}/bin/brew" + BREW_LOG="${WORKDIR}/brew.log" run bash "${PATCHED_SCRIPT}" [ "${status}" -eq 0 ] run ! grep -q '^brew uninstall ' "${WORKDIR}/brew.log" @@ -1129,8 +1152,15 @@ case "\$1" in exit 1 ;; list) - [[ "\${installed_tap}" == none ]] && exit 1 + # Real Homebrew: bare inventory queries succeed even when empty, + # but a named cask query for an absent cask exits 1. The pre-bundle + # snapshot uses the bare forms; the migration gate uses the named + # one. + if [[ "\${2:-}" == "--cask" && -n "\${3:-}" && "\${3:-}" != "--versions" ]]; then + [[ "\${installed_tap}" == none ]] && exit 1 + fi if [[ "\$*" == *"--versions"* ]]; then + [[ "\${installed_tap}" == none ]] && exit 1 printf 'chairlift %s\n' "\${installed_version}" fi exit 0