diff --git a/README.md b/README.md index 42086aa..156e5b0 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,11 @@ omacase uninstall remove Omacase-managed config; keep applications Set `OMACASE_DRYRUN=1` before `omacase install` to preview changes. Set `OMACASE_SKIP_MISE_UPGRADE=1` before `omacase update` to leave mise-managed tools at their current versions. Set `OMACASE_INSTALL_GROK=1` to explicitly -allow xAI's unpinned Grok installer; it is skipped by default. +allow xAI's unpinned Grok installer; it is skipped by default. Set +`OMACASE_CHANNEL=dev` to make `omacase update` pull the default branch +(maintainer machines); the default `stable` channel checks out the latest +`v*` release tag. `omacase update --check` lists pending changes without +applying them; `omacase update --rollback` returns to the previous payload. ## Agent multiplexing diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..2e1f034 --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,34 @@ +# Releasing Omacase + +Versioned tags are the unit of review. Public installs (`OMACASE_CHANNEL=stable`, +the default) check out the greatest `v*` tag. Maintainer machines set +`OMACASE_CHANNEL=dev` and keep `git pull --ff-only` on the default branch. + +## Cut a release + +1. Bump `VERSION` to `X.Y.Z`. +2. Commit the bump (and any pin updates below) on `main`. +3. `git tag -a vX.Y.Z -m ""` +4. `git push --follow-tags` + +The first stable target after this model landed is `v0.2.0` (matches `VERSION`). + +## Homebrew installer pin + +`boot.sh` / `site/install` fetch a **commit-pinned** `install.sh` from +`Homebrew/install` (that repo has no tags) and verify its sha256. When the +installer needs a bump: + +1. Pick a reviewed commit on `Homebrew/install`. +2. `curl -fsSL https://raw.githubusercontent.com/Homebrew/install//install.sh | shasum -a 256` +3. Update `HOMEBREW_INSTALLER_VERSION` and `HOMEBREW_INSTALLER_SHA256` in + `boot.sh`, then `cp boot.sh site/install`. + +A checksum mismatch fails closed and tells the user to update Omacase or +install Homebrew by hand from brew.sh. + +## mise / npm pins + +`home/dot_config/mise/config.toml` uses exact versions. `mise outdated` lists +candidates. Bump pins in a dedicated commit; do not restore `@latest`. +Do not pin tools that self-update by design (Claude Code, grok). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..d8f3440 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,22 @@ +# Security + +Omacase installs software and runs it on a personal Mac. The trust boundary is +the **reviewed git tag** (`vX.Y.Z`). `OMACASE_CHANNEL=dev` opts out of that +and tracks the default branch. + +There is no GPG/SSH tag-signature verification yet. Distributing a signing key +inside this same repository is circular; revisit when there is an out-of-band +channel (for example a Homebrew formula). TLS protects transport. + +## Trust model + +| Surface | Mutable? | Why | +|---|---|---| +| Omacase payload (`omacase update`) | **Pinned** on `stable` to the greatest `v*` tag. `dev` pulls the default branch. | Tags are the unit of review. `--check` inspects pending changes; `--rollback` returns one SHA. | +| Homebrew installer (`boot.sh`) | **Pinned** to a `Homebrew/install` commit + sha256 | That repo has no tags. Fail closed on mismatch; install Homebrew from brew.sh by hand if needed. | +| Homebrew formulae / casks | **Mutable** by design | Brew's own trust chain. Brewfile pins names, not versions; brew has no supported lockfile. | +| mise / npm CLIs | **Pinned** to exact versions in `mise/config.toml` | Bumps are commits. `mise upgrade` converges to pins. | +| Claude Code | **Vendor-rolling** | Self-updating, vendor-signed. Out of mise. | +| Grok CLI | **Vendor-rolling, opt-in** | `OMACASE_INSTALL_GROK=1`. Installer is unversioned; a checksum would break on every vendor release with no signal to us. Opt-in is the control. | +| Omarchy theme assets (`$OMACASE_DATA/upstream`) | Content, not code | Parsed as TOML / images, never executed. | +| herdr tap | Maintainer-owned | Declared third-party tap, trusted by exact formula/cask name. | diff --git a/bin/omacase b/bin/omacase index dd87aa2..ffd460d 100755 --- a/bin/omacase +++ b/bin/omacase @@ -22,7 +22,7 @@ Omacase — opinionated tiling macOS, managed from one command usage: omacase [args] install Full idempotent setup (re-runnable) - update git pull + brew bundle + re-apply dotfiles & defaults + migrations + update Apply latest payload (stable tag or dev pull) + brew + mise [--check|--rollback] outdated Print the count of outdated Homebrew packages migrate Apply pending one-time migrations (also run by update) theme [name] Apply a theme everywhere (no name = list/pick) diff --git a/boot.sh b/boot.sh index f1b0587..fe31d80 100755 --- a/boot.sh +++ b/boot.sh @@ -64,9 +64,14 @@ if ! command -v brew >/dev/null 2>&1; then installer="$(mktemp)" trap 'rm -f "$installer"' EXIT info "Installing Homebrew…" + # Homebrew/install publishes no tags; pin a reviewed commit + sha256. + HOMEBREW_INSTALLER_VERSION=cced90146ea6d3057c03a636b668fef177415eb3 + HOMEBREW_INSTALLER_SHA256=12479a24be3f5307eecac7cde670fad7118640f031229e964f544b1367b52a41 curl --proto '=https' --tlsv1.2 -fsSL \ - https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh \ + "https://raw.githubusercontent.com/Homebrew/install/${HOMEBREW_INSTALLER_VERSION}/install.sh" \ -o "$installer" + printf '%s %s\n' "$HOMEBREW_INSTALLER_SHA256" "$installer" | shasum -a 256 -c -- >/dev/null 2>&1 \ + || abort "Homebrew installer checksum mismatch — refusing to run it. (Upstream may have released a new version; update omacase or install Homebrew manually from brew.sh, then re-run.)" NONINTERACTIVE=1 /bin/bash "$installer" rm -f "$installer" trap - EXIT @@ -79,6 +84,52 @@ else fi # 3. Clone or update the payload. +# stable (default) checks out the greatest v* tag; OMACASE_CHANNEL=dev tracks +# the default branch. A missing tag (pre-first-release) stays on the default +# branch rather than aborting bootstrap. +_omacase_remote_default_branch() { + local root="$1" ref + ref="$(git -C "$root" symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null || true)" + ref="${ref#origin/}" + printf '%s\n' "${ref:-main}" +} + +# stable leaves a detached tag checkout. Dev must attach to the remote +# default branch without reset --hard / checkout -B (those discard work). +_omacase_attach_dev() { + local root="$1" branch + branch="$(_omacase_remote_default_branch "$root")" + git -C "$root" fetch origin "$branch" + if git -C "$root" symbolic-ref -q HEAD >/dev/null; then + git -C "$root" merge --ff-only "origin/$branch" + return + fi + info "Attaching detached checkout to origin/$branch (dev channel)…" + if git -C "$root" show-ref --verify --quiet "refs/heads/$branch"; then + git -C "$root" checkout -q "$branch" \ + || abort "Could not check out $branch (local changes?). Resolve them or stay on OMACASE_CHANNEL=stable." + else + git -C "$root" checkout -q --track "origin/$branch" \ + || abort "Could not attach to origin/$branch (local changes?). Resolve them or stay on OMACASE_CHANNEL=stable." + fi + git -C "$root" merge --ff-only "origin/$branch" \ + || abort "git merge --ff-only origin/$branch failed (local changes?). Resolve it before updating." +} + +_omacase_checkout_channel() { + local root="$1" tag + if [ "${OMACASE_CHANNEL:-stable}" = dev ]; then + _omacase_attach_dev "$root" + return + fi + git -C "$root" fetch --tags --depth 1 origin 2>/dev/null || true + tag="$(git -C "$root" tag --list 'v*' --sort=-v:refname | head -1)" + if [ -n "$tag" ]; then + git -C "$root" checkout -q "$tag" + else + info "No release tags found; staying on the default branch (set OMACASE_CHANNEL=dev to keep tracking it)." + fi +} # Older public installs cloned into the data root itself. Keep using that # checkout rather than forking a second copy next to its caches. if [ -z "${OMACASE_PREFIX:-}" ] && [ -d "$HOME/.local/share/omacase/.git" ]; then @@ -88,11 +139,12 @@ fi if [ -d "$PREFIX/.git" ]; then info "Updating existing omacase payload at $PREFIX…" _recover_legacy_login_items "$PREFIX" - git -C "$PREFIX" pull --ff-only + _omacase_checkout_channel "$PREFIX" else info "Cloning omacase → $PREFIX…" mkdir -p "$(dirname "$PREFIX")" - git clone --depth 1 "$REPO" "$PREFIX" + git clone --tags --depth 1 "$REPO" "$PREFIX" + _omacase_checkout_channel "$PREFIX" fi # 4. Hand off. diff --git a/completions/_omacase b/completions/_omacase index edf02dc..4ac9990 100644 --- a/completions/_omacase +++ b/completions/_omacase @@ -20,7 +20,7 @@ _omacase() { command) local -a subcommands=( 'install:full idempotent setup (re-runnable)' - 'update:git pull + brew bundle + re-apply dotfiles & defaults' + 'update:apply latest payload + brew + mise (--check / --rollback)' 'migrate:apply pending one-time migrations (also run by update)' 'outdated:print the count of outdated Homebrew packages' 'theme:apply a theme everywhere (no name = list/pick)' @@ -79,6 +79,11 @@ _omacase() { wm) _values 'wm action' 'menu[open the OmniWM app menu]' 'palette[open the OmniWM command palette]' 'settings[open the OmniWM settings window]' && ret=0 ;; + update) + _values 'update flag' \ + '--check[fetch and list pending changes without applying]' \ + '--rollback[return to the SHA from the last payload switch]' && ret=0 + ;; restore) local backups=${OMACASE_STATE:-$HOME/.local/state/omacase}/backups local -a snapshots=( $backups/*(N/:t) ) flags=( '--list:list snapshots' ) diff --git a/home/dot_config/mise/config.toml b/home/dot_config/mise/config.toml index 1f898c4..8943f3c 100644 --- a/home/dot_config/mise/config.toml +++ b/home/dot_config/mise/config.toml @@ -3,12 +3,12 @@ # PATH via its shims. # # Why these live here and not in the Brewfile: they ship on npm sooner than they -# reach Homebrew. Pinned to "latest"; `omacase update` runs `mise upgrade` to pull -# the newest versions the moment they're published — no waiting on a brew bump. +# reach Homebrew. Versions are exact pins; bump them deliberately (see +# RELEASING.md). `omacase update` runs `mise upgrade` to converge to the pins. # (Self-updating tools like Claude Code stay on their own installer; native # binaries like codex stay on Homebrew.) [tools] -node = "lts" # one isolated node; runs the npm: CLIs below -"npm:@google/gemini-cli" = "latest" # gemini — Google Gemini CLI -"npm:@mermaid-js/mermaid-cli" = "latest" # mmdc — Mermaid diagram renderer -"npm:@earendil-works/pi-coding-agent" = "latest" # pi — pi.dev coding agent (successor to @mariozechner/*) +node = "24.19.0" # one isolated node; runs the npm: CLIs below +"npm:@google/gemini-cli" = "0.55.1" # gemini — Google Gemini CLI +"npm:@mermaid-js/mermaid-cli" = "11.16.0" # mmdc — Mermaid diagram renderer +"npm:@earendil-works/pi-coding-agent" = "0.84.2" # pi — pi.dev coding agent (successor to @mariozechner/*) diff --git a/lib/update.sh b/lib/update.sh index 2866dbc..0fc08c9 100644 --- a/lib/update.sh +++ b/lib/update.sh @@ -1,14 +1,158 @@ # shellcheck shell=bash -# `omacase update` — pull latest payload, then re-run the install engine. +# `omacase update` — move the payload to the selected channel, then re-run +# the install engine. +# +# OMACASE_CHANNEL=stable (default) fetch tags, check out the greatest v* +# OMACASE_CHANNEL=dev pull --ff-only on the default branch +# +# omacase update --check fetch and print pending changes; no checkout +# omacase update --rollback return to the SHA recorded before the +# last payload switch, then re-run install + +OMACASE_CHANNEL="${OMACASE_CHANNEL:-stable}" + +_latest_release_tag() { + git -C "$OMACASE_ROOT" tag --list 'v*' --sort=-v:refname | head -1 +} + +_current_exact_tag() { + git -C "$OMACASE_ROOT" describe --tags --exact-match 2>/dev/null || true +} + +_update_record_prev() { + is_dryrun && return 0 + mkdir -p "$OMACASE_STATE" + git -C "$OMACASE_ROOT" rev-parse HEAD > "$OMACASE_STATE/update-prev" +} + +_update_target_ref() { + if [ "$OMACASE_CHANNEL" = dev ]; then + git -C "$OMACASE_ROOT" rev-parse --abbrev-ref --symbolic-full-name '@{u}' 2>/dev/null \ + || printf '%s\n' origin/main + else + _latest_release_tag + fi +} + +_update_check() { + [ -d "$OMACASE_ROOT/.git" ] || abort "No git checkout at $OMACASE_ROOT." + if [ "$OMACASE_CHANNEL" = dev ]; then + git -C "$OMACASE_ROOT" fetch origin + else + git -C "$OMACASE_ROOT" fetch --tags origin + fi + local target current tag + target="$(_update_target_ref)" + current="$(git -C "$OMACASE_ROOT" rev-parse --short HEAD)" + [ -n "$target" ] || abort "No update target (channel=$OMACASE_CHANNEL). Cut a v* tag or set OMACASE_CHANNEL=dev." + tag="$(_current_exact_tag)" + printf 'channel: %s\n' "$OMACASE_CHANNEL" + if [ -n "$tag" ]; then + printf 'current: %s (%s)\n' "$current" "$tag" + else + printf 'current: %s\n' "$current" + fi + printf 'target: %s\n' "$target" + if [ "$(git -C "$OMACASE_ROOT" rev-parse HEAD)" = "$(git -C "$OMACASE_ROOT" rev-parse "$target^{commit}")" ]; then + info "Already up to date." + return 0 + fi + local n + n="$(git -C "$OMACASE_ROOT" rev-list --count "HEAD..$target" 2>/dev/null || echo 0)" + printf 'pending: %s commit(s)\n' "$n" + git -C "$OMACASE_ROOT" log --oneline "HEAD..$target" + git -C "$OMACASE_ROOT" diff --stat "HEAD..$target" +} + +_update_rollback() { + local prev="$OMACASE_STATE/update-prev" + [ -f "$prev" ] || abort "No previous update SHA recorded. (omacase update --rollback is one level deep.)" + local sha + sha="$(cat "$prev")" + [ -n "$sha" ] || abort "Empty rollback SHA in $prev." + warn "Rolling back payload to $sha" + git -C "$OMACASE_ROOT" checkout -q "$sha" \ + || abort "git checkout $sha failed." + is_dryrun && return 0 + exec "$OMACASE_ROOT/bin/omacase" install +} + +_update_remote_default_branch() { + local ref + ref="$(git -C "$OMACASE_ROOT" symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null || true)" + ref="${ref#origin/}" + printf '%s\n' "${ref:-main}" +} + +# stable leaves a detached tag checkout. Dev must attach to the remote +# default branch without reset --hard / checkout -B (those discard work). +_update_attach_dev() { + local branch + branch="$(_update_remote_default_branch)" + if is_dryrun; then + log "[dry-run] would attach to origin/$branch and fast-forward" + return 0 + fi + git -C "$OMACASE_ROOT" fetch origin "$branch" \ + || abort "git fetch origin $branch failed." + if git -C "$OMACASE_ROOT" symbolic-ref -q HEAD >/dev/null; then + git -C "$OMACASE_ROOT" merge --ff-only "origin/$branch" \ + || abort "git merge --ff-only origin/$branch failed (local changes?). Resolve it before updating." + return + fi + info "Attaching detached checkout to origin/$branch (dev channel)…" + if git -C "$OMACASE_ROOT" show-ref --verify --quiet "refs/heads/$branch"; then + git -C "$OMACASE_ROOT" checkout -q "$branch" \ + || abort "Could not check out $branch (local changes?). Resolve them or stay on OMACASE_CHANNEL=stable." + else + git -C "$OMACASE_ROOT" checkout -q --track "origin/$branch" \ + || abort "Could not attach to origin/$branch (local changes?). Resolve them or stay on OMACASE_CHANNEL=stable." + fi + git -C "$OMACASE_ROOT" merge --ff-only "origin/$branch" \ + || abort "git merge --ff-only origin/$branch failed (local changes?). Resolve it before updating." +} + +_update_switch_payload() { + # A legacy login-items edit dirties a tracked file, which blocks both the dev + # ff-only pull and the stable tag checkout — recover it before any movement. + _recover_legacy_login_items "$OMACASE_ROOT" + case "$OMACASE_CHANNEL" in + dev) + step "Pulling latest omacase (dev channel)" + _update_record_prev + _update_attach_dev ;; + stable) + step "Fetching omacase release tags (stable channel)" + run git -C "$OMACASE_ROOT" fetch --tags origin \ + || abort "git fetch --tags failed." + local tag current + tag="$(_latest_release_tag)" + [ -n "$tag" ] || abort "No v* release tags found. Cut a release or set OMACASE_CHANNEL=dev." + current="$(_current_exact_tag)" + if [ "$tag" = "$current" ]; then + info "Already on $tag" + else + _update_record_prev + run git -C "$OMACASE_ROOT" checkout -q "$tag" \ + || abort "git checkout $tag failed." + fi ;; + *) + abort "Unknown OMACASE_CHANNEL='$OMACASE_CHANNEL' (use stable or dev)." ;; + esac +} omacase_update() { ensure_brew_env dryrun_banner + case "${1:-}" in + --check) _update_check; return ;; + --rollback) _update_rollback; return ;; + "" ) ;; + *) abort "unknown update flag: $1 (try --check or --rollback)" ;; + esac if [ -d "$OMACASE_ROOT/.git" ] && [ -z "${OMACASE_UPDATE_REEXECED:-}" ]; then - step "Pulling latest omacase" - _recover_legacy_login_items "$OMACASE_ROOT" - run git -C "$OMACASE_ROOT" pull --ff-only || abort "git pull failed (local changes?). Resolve it before updating." - # Everything sourced so far (common.sh, this file) came from the pre-pull + _update_switch_payload + # Everything sourced so far (common.sh, this file) came from the pre-switch # checkout; re-exec into the fresh tree so the rest of the update runs a # single, consistent version instead of a mix of old and new lib files. if ! is_dryrun; then @@ -31,7 +175,7 @@ omacase_update() { info "Skipping mise tool upgrades (OMACASE_SKIP_MISE_UPGRADE is set)." elif have mise; then step "Upgrading mise tools (node + npm CLIs)" - warn "mise tools include npm packages pinned to latest; set OMACASE_SKIP_MISE_UPGRADE=1 to skip." + warn "mise upgrade converges to the pinned versions; set OMACASE_SKIP_MISE_UPGRADE=1 to skip." require "mise upgrade" mise upgrade fi step "Upgrading outdated formulae & casks" diff --git a/site/install b/site/install index f1b0587..fe31d80 100755 --- a/site/install +++ b/site/install @@ -64,9 +64,14 @@ if ! command -v brew >/dev/null 2>&1; then installer="$(mktemp)" trap 'rm -f "$installer"' EXIT info "Installing Homebrew…" + # Homebrew/install publishes no tags; pin a reviewed commit + sha256. + HOMEBREW_INSTALLER_VERSION=cced90146ea6d3057c03a636b668fef177415eb3 + HOMEBREW_INSTALLER_SHA256=12479a24be3f5307eecac7cde670fad7118640f031229e964f544b1367b52a41 curl --proto '=https' --tlsv1.2 -fsSL \ - https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh \ + "https://raw.githubusercontent.com/Homebrew/install/${HOMEBREW_INSTALLER_VERSION}/install.sh" \ -o "$installer" + printf '%s %s\n' "$HOMEBREW_INSTALLER_SHA256" "$installer" | shasum -a 256 -c -- >/dev/null 2>&1 \ + || abort "Homebrew installer checksum mismatch — refusing to run it. (Upstream may have released a new version; update omacase or install Homebrew manually from brew.sh, then re-run.)" NONINTERACTIVE=1 /bin/bash "$installer" rm -f "$installer" trap - EXIT @@ -79,6 +84,52 @@ else fi # 3. Clone or update the payload. +# stable (default) checks out the greatest v* tag; OMACASE_CHANNEL=dev tracks +# the default branch. A missing tag (pre-first-release) stays on the default +# branch rather than aborting bootstrap. +_omacase_remote_default_branch() { + local root="$1" ref + ref="$(git -C "$root" symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null || true)" + ref="${ref#origin/}" + printf '%s\n' "${ref:-main}" +} + +# stable leaves a detached tag checkout. Dev must attach to the remote +# default branch without reset --hard / checkout -B (those discard work). +_omacase_attach_dev() { + local root="$1" branch + branch="$(_omacase_remote_default_branch "$root")" + git -C "$root" fetch origin "$branch" + if git -C "$root" symbolic-ref -q HEAD >/dev/null; then + git -C "$root" merge --ff-only "origin/$branch" + return + fi + info "Attaching detached checkout to origin/$branch (dev channel)…" + if git -C "$root" show-ref --verify --quiet "refs/heads/$branch"; then + git -C "$root" checkout -q "$branch" \ + || abort "Could not check out $branch (local changes?). Resolve them or stay on OMACASE_CHANNEL=stable." + else + git -C "$root" checkout -q --track "origin/$branch" \ + || abort "Could not attach to origin/$branch (local changes?). Resolve them or stay on OMACASE_CHANNEL=stable." + fi + git -C "$root" merge --ff-only "origin/$branch" \ + || abort "git merge --ff-only origin/$branch failed (local changes?). Resolve it before updating." +} + +_omacase_checkout_channel() { + local root="$1" tag + if [ "${OMACASE_CHANNEL:-stable}" = dev ]; then + _omacase_attach_dev "$root" + return + fi + git -C "$root" fetch --tags --depth 1 origin 2>/dev/null || true + tag="$(git -C "$root" tag --list 'v*' --sort=-v:refname | head -1)" + if [ -n "$tag" ]; then + git -C "$root" checkout -q "$tag" + else + info "No release tags found; staying on the default branch (set OMACASE_CHANNEL=dev to keep tracking it)." + fi +} # Older public installs cloned into the data root itself. Keep using that # checkout rather than forking a second copy next to its caches. if [ -z "${OMACASE_PREFIX:-}" ] && [ -d "$HOME/.local/share/omacase/.git" ]; then @@ -88,11 +139,12 @@ fi if [ -d "$PREFIX/.git" ]; then info "Updating existing omacase payload at $PREFIX…" _recover_legacy_login_items "$PREFIX" - git -C "$PREFIX" pull --ff-only + _omacase_checkout_channel "$PREFIX" else info "Cloning omacase → $PREFIX…" mkdir -p "$(dirname "$PREFIX")" - git clone --depth 1 "$REPO" "$PREFIX" + git clone --tags --depth 1 "$REPO" "$PREFIX" + _omacase_checkout_channel "$PREFIX" fi # 4. Hand off. diff --git a/tests/run.sh b/tests/run.sh index c9694fb..cde03f9 100644 --- a/tests/run.sh +++ b/tests/run.sh @@ -602,6 +602,7 @@ test_update_fails_when_self_pull_fails() { OMACASE_ROOT="$tmp/repo" HOME="$tmp/home" OMACASE_STATE="$tmp/state" + export OMACASE_CHANNEL=dev mkdir -p "$OMACASE_ROOT/.git" "$HOME" out="$tmp/out" ( @@ -612,7 +613,175 @@ test_update_fails_when_self_pull_fails() { omacase_update ) >"$out" 2>&1 # shellcheck disable=SC2181 # status is intentionally captured after the subshell - [ $? -ne 0 ] && grep -q "git pull failed" "$out" + [ $? -ne 0 ] && grep -qE 'git (pull|fetch|merge)' "$out" +} + +_test_git_identity() { + git -C "$1" config user.email "omacase-test@example.com" + git -C "$1" config user.name "omacase-test" +} + +test_latest_release_tag_picks_greatest_semver() { + local tmp + tmp="$(mktemp -d)" + git init -q "$tmp" + _test_git_identity "$tmp" + git -C "$tmp" checkout -q -B main + git -C "$tmp" commit --allow-empty -q -m init + git -C "$tmp" tag v1.9.0 + git -C "$tmp" tag v1.10.0 + git -C "$tmp" tag v2.0.0 + ( + OMACASE_ROOT="$tmp" + # shellcheck source=/dev/null + source "$ROOT/lib/common.sh" + # shellcheck source=/dev/null + source "$ROOT/lib/update.sh" + [ "$(_latest_release_tag)" = v2.0.0 ] + ) +} + +test_update_check_mutates_nothing() { + local origin clone out before after + origin="$(mktemp -d)" + clone="$(mktemp -d)" + out="$(mktemp)" + git init -q "$origin" + _test_git_identity "$origin" + git -C "$origin" checkout -q -B main + printf 'a\n' > "$origin/file" + git -C "$origin" add file + git -C "$origin" commit -q -m first + git clone -q "$origin" "$clone" + _test_git_identity "$clone" + printf 'b\n' > "$origin/file" + git -C "$origin" commit -q -am second + before="$(git -C "$clone" rev-parse HEAD)" + ( + OMACASE_ROOT="$clone" + OMACASE_CHANNEL=dev + HOME="$(mktemp -d)" + OMACASE_STATE="$HOME/state" + # shellcheck source=/dev/null + source "$ROOT/lib/common.sh" + # shellcheck source=/dev/null + source "$ROOT/lib/update.sh" + _update_check + ) >"$out" 2>&1 + after="$(git -C "$clone" rev-parse HEAD)" + [ "$before" = "$after" ] && + grep -q 'pending:' "$out" && + grep -q 'second' "$out" +} + +test_homebrew_installer_checksum_is_enforced() { + local tmp + tmp="$(mktemp)" + printf 'tampered\n' > "$tmp" + ! printf '%s %s\n' \ + "12479a24be3f5307eecac7cde670fad7118640f031229e964f544b1367b52a41" \ + "$tmp" | shasum -a 256 -c -- >/dev/null 2>&1 && + grep -q 'shasum -a 256 -c' "$ROOT/boot.sh" && + grep -q 'shasum -a 256 -c' "$ROOT/site/install" && + grep -q 'Homebrew installer checksum mismatch' "$ROOT/boot.sh" +} + +test_mise_tools_are_pinned() { + ! grep -q '@latest' "$ROOT/home/dot_config/mise/config.toml" && + ! grep -Eq 'node = "lts"' "$ROOT/home/dot_config/mise/config.toml" +} + +test_update_rollback_restores_recorded_sha() { + local origin clone prev + origin="$(mktemp -d)" + git init -q "$origin" + _test_git_identity "$origin" + git -C "$origin" checkout -q -B main + printf 'one\n' > "$origin/file" + git -C "$origin" add file + git -C "$origin" commit -q -m one + git -C "$origin" tag v0.1.0 + printf 'two\n' > "$origin/file" + git -C "$origin" commit -q -am two + git -C "$origin" tag v0.2.0 + clone="$(mktemp -d)" + git clone -q "$origin" "$clone" + _test_git_identity "$clone" + git -C "$clone" checkout -q v0.2.0 + prev="$(git -C "$clone" rev-parse v0.1.0)" + mkdir -p "$clone/bin" + printf '#!/bin/bash\nexit 0\n' > "$clone/bin/omacase" + chmod +x "$clone/bin/omacase" + ( + OMACASE_ROOT="$clone" + OMACASE_STATE="$(mktemp -d)" + HOME="$(mktemp -d)" + printf '%s\n' "$prev" > "$OMACASE_STATE/update-prev" + # shellcheck source=/dev/null + source "$ROOT/lib/common.sh" + # shellcheck source=/dev/null + source "$ROOT/lib/update.sh" + _update_rollback >/dev/null 2>&1 + ) + [ "$(git -C "$clone" rev-parse HEAD)" = "$prev" ] +} + +_test_stable_then_dev_fixture() { + # origin: v1.0.0 then a later main commit. clone starts detached on the tag. + local origin="$1" clone="$2" + git init -q "$origin" + _test_git_identity "$origin" + git -C "$origin" checkout -q -B main + printf 'one\n' > "$origin/file" + git -C "$origin" add file + git -C "$origin" commit -q -m one + git -C "$origin" tag v1.0.0 + printf 'two\n' > "$origin/file" + git -C "$origin" commit -q -am two + git clone -q "$origin" "$clone" + _test_git_identity "$clone" + git -C "$clone" checkout -q v1.0.0 +} + +test_update_dev_attaches_from_stable_tag() { + local origin clone tip + origin="$(mktemp -d)" + clone="$(mktemp -d)" + _test_stable_then_dev_fixture "$origin" "$clone" + tip="$(git -C "$origin" rev-parse HEAD)" + ! git -C "$clone" symbolic-ref -q HEAD >/dev/null || return 1 + ( + OMACASE_ROOT="$clone" + OMACASE_CHANNEL=dev + OMACASE_STATE="$(mktemp -d)" + HOME="$(mktemp -d)" + # shellcheck source=/dev/null + source "$ROOT/lib/common.sh" + # shellcheck source=/dev/null + source "$ROOT/lib/update.sh" + _update_attach_dev >/dev/null 2>&1 + ) + [ "$(git -C "$clone" symbolic-ref --short HEAD)" = main ] && + [ "$(git -C "$clone" rev-parse HEAD)" = "$tip" ] +} + +test_boot_dev_attaches_from_stable_tag() { + local origin clone tip + origin="$(mktemp -d)" + clone="$(mktemp -d)" + _test_stable_then_dev_fixture "$origin" "$clone" + tip="$(git -C "$origin" rev-parse HEAD)" + ! git -C "$clone" symbolic-ref -q HEAD >/dev/null || return 1 + ( + info() { :; } + abort() { printf '%s\n' "$*" >&2; return 1; } + # shellcheck disable=SC1090 + eval "$(sed -n '/^_omacase_remote_default_branch()/,/^_omacase_checkout_channel()/{ /^_omacase_checkout_channel()/q; p; }' "$ROOT/boot.sh")" + OMACASE_CHANNEL=dev + _omacase_attach_dev "$clone" >/dev/null 2>&1 + ) + [ "$(git -C "$clone" symbolic-ref --short HEAD)" = main ] && + [ "$(git -C "$clone" rev-parse HEAD)" = "$tip" ] } test_bootstrap_copies_are_identical() { @@ -1320,6 +1489,13 @@ run_test "partial brew upgrade fails update" test_partial_brew_upgrade_fails_upd run_test "partial brew update fails update" test_partial_brew_update_fails_update run_test "update stops on non-ledgered install failure" test_update_stops_on_non_ledgered_install_failure run_test "update fails on self-update failure" test_update_fails_when_self_pull_fails +run_test "latest release tag is greatest semver" test_latest_release_tag_picks_greatest_semver +run_test "update --check does not mutate the worktree" test_update_check_mutates_nothing +run_test "homebrew installer checksum is enforced" test_homebrew_installer_checksum_is_enforced +run_test "mise tools are pinned to exact versions" test_mise_tools_are_pinned +run_test "update --rollback restores the recorded SHA" test_update_rollback_restores_recorded_sha +run_test "update dev attaches a detached stable tag to main" test_update_dev_attaches_from_stable_tag +run_test "boot dev attaches a detached stable tag to main" test_boot_dev_attaches_from_stable_tag run_test "backup domains cover macos/defaults.sh" test_backup_domains_cover_defaults_sh run_test "defaults disable Stage Manager" test_stage_manager_is_disabled_by_defaults run_test "Homebrew trust is scoped to exact third-party packages" test_brew_trust_is_scoped