From 384b0be76137e83e3d4fe8f60d217fce1cae6010 Mon Sep 17 00:00:00 2001 From: philippgerard Date: Tue, 29 Sep 2026 11:24:49 +0200 Subject: [PATCH] Add validated bootstrap maintenance workflow --- .github/workflows/validate.yml | 29 ++++ README.md | 8 + docs/app-inventory.md | 3 +- docs/architecture.md | 11 ++ docs/maintenance.md | 75 +++++++++- docs/pre-wipe-checklist.md | 2 + docs/restore-rehearsal.md | 44 ++++++ docs/restore-verification.md | 239 +++++++++--------------------- flake.nix | 44 +++++- modules/darwin/default.nix | 9 +- modules/darwin/manifest.nix | 18 +++ modules/home/default.nix | 9 +- modules/home/development.nix | 51 +++++++ modules/home/fish.nix | 15 +- modules/home/git.nix | 9 -- modules/home/otty.nix | 5 + modules/home/packages.nix | 30 +--- modules/home/topgrade.nix | 3 +- profiles/development.nix | 6 +- profiles/personal.nix | 6 +- scripts/ci | 20 +++ scripts/configure-otty-handlers | 22 +++ scripts/doctor | 212 ++++++++++++++++++++++++++ scripts/mac-setup | 42 ++++++ scripts/preview-system | 53 +++++++ scripts/promote-update | 84 +++++++++++ scripts/rebuild | 14 +- scripts/register-checkout | 33 +++++ scripts/run-with-validation-tools | 4 +- scripts/test-app-inventory | 2 +- scripts/test-filen-menubar-update | 2 +- scripts/test-finish-setup | 2 +- scripts/test-update-transaction | 155 +++++++++++++++++++ scripts/test-workflow-helpers | 73 +++++++++ scripts/update | 101 ++++++++----- scripts/validate | 5 +- setup.sh | 1 + 37 files changed, 1158 insertions(+), 283 deletions(-) create mode 100644 .github/workflows/validate.yml create mode 100644 docs/restore-rehearsal.md create mode 100644 modules/darwin/manifest.nix create mode 100644 modules/home/development.nix create mode 100755 scripts/ci create mode 100755 scripts/configure-otty-handlers create mode 100755 scripts/doctor create mode 100755 scripts/mac-setup create mode 100755 scripts/preview-system create mode 100755 scripts/promote-update create mode 100755 scripts/register-checkout create mode 100755 scripts/test-update-transaction create mode 100755 scripts/test-workflow-helpers diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml new file mode 100644 index 0000000..3547b46 --- /dev/null +++ b/.github/workflows/validate.yml @@ -0,0 +1,29 @@ +name: Validate and build + +on: + pull_request: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: validate-${{ github.ref }} + cancel-in-progress: true + +jobs: + darwin: + runs-on: macos-26 + timeout-minutes: 90 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + persist-credentials: false + # Validate the contributor's history, without a synthetic merge identity. + ref: ${{ github.event.pull_request.head.sha || github.sha }} + - uses: DeterminateSystems/determinate-nix-action@8d87e8d5e5b8a8309d4281094560f127d9a265f1 # v3.22.5 + - name: Validate and build the public configuration + run: scripts/ci diff --git a/README.md b/README.md index 84adcd4..8ff13dc 100644 --- a/README.md +++ b/README.md @@ -110,11 +110,17 @@ scripts/validate # Build without changing the live system scripts/rebuild build +# Review package and Homebrew/MAS declaration changes before activation +scripts/rebuild preview + # Build and activate local changes scripts/rebuild switch # Intentionally update pinned Nix inputs, Filen Menubar, and OMC, then build scripts/update + +# Check installed state without restoring or changing it +scripts/doctor ``` Homebrew and Mac App Store application removal is never automatic. Review @@ -134,6 +140,8 @@ Homebrew and Mac App Store application removal is never automatic. Review application settings - [Post-install verification](docs/restore-verification.md) — thorough automated and manual checks +- [Disposable-Mac rehearsal](docs/restore-rehearsal.md) — first activation, + interrupted restore, and repeated activation - [Pre-wipe checklist](docs/pre-wipe-checklist.md) — required checks before erasing an existing Mac - [Public release safety](docs/public-release.md) — PII and Git-history policy diff --git a/docs/app-inventory.md b/docs/app-inventory.md index 08d26d9..9a16d53 100644 --- a/docs/app-inventory.md +++ b/docs/app-inventory.md @@ -10,7 +10,8 @@ The `mini` host composes: the pinned Erlang/OTP 29 plus Elixir 1.20 toolchain, and the pinned Rust compiler, Cargo, formatter, linter, and language server; - `desktop`: external-display support and desktop menu-bar behavior; -- `personal`: communication, news, media, archive, and document utilities; +- `personal`: communication, news, media, archive, document utilities, and + the pinned Filen Menubar application; - `work`: individual Microsoft Office apps, Teams, and Slack; - `gaming`: GeForce NOW. diff --git a/docs/architecture.md b/docs/architecture.md index 66d9098..1886cc7 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -27,6 +27,17 @@ Homebrew/MAS provide GUI and vendor applications. Homebrew cleanup, automatic updates, and upgrades are disabled during activation; removal is always a separate reviewed operation. +The development profile also imports the Home Manager development toolchain, +OMC, Zed settings, and GitHub CLI. The personal profile owns Filen Menubar. +The base Home Manager configuration keeps core tools and uses Vim unless the +development profile selects Zed. Pure flake checks verify both the full `mini` +composition and a base-only composition. + +Otty's file and SSH URL associations run in Home Manager's user session after +Homebrew installation. Errors remain visible and file handlers are read back. +The generation also retains a public Brewfile and feature manifest under +`etc/mac-setup/` for previews and diagnostics. + Chezmoi and other dotfile managers are intentionally not part of this design. ## Bootstrap model diff --git a/docs/maintenance.md b/docs/maintenance.md index 8554d49..1088af5 100644 --- a/docs/maintenance.md +++ b/docs/maintenance.md @@ -11,6 +11,9 @@ scripts/validate # Build without changing the live system scripts/rebuild build +# Build and show package, closure-size, and Homebrew/MAS declaration changes +scripts/rebuild preview + # Build and activate scripts/rebuild switch @@ -19,12 +22,82 @@ scripts/update # Compare declared Homebrew/MAS state with the live Mac scripts/homebrew-dry-run + +# Read-only post-install checks (use --json for a machine-readable report) +scripts/doctor ``` `scripts/validate` enters the pinned validation shell automatically when the active generation does not yet provide a required tool. It therefore also works before the first activation of a newly added validator. +`scripts/rebuild preview` compares the candidate with `/run/current-system`. +Each new generation saves its Brewfile under `etc/mac-setup/`; when the active +generation predates this metadata, the first preview prints all candidate +declarations. This compares intended app selection, not vendor-managed GUI +application versions. The same built output can be inspected again with +`scripts/preview-system ./result` without repeating validation or the build. + +`scripts/update` prepares all three pin files in a temporary, filtered candidate +checkout, then validates and builds it once. Only a successful candidate whose +original checkout and host selectors remain unchanged is promoted. Failed +downloads, hashing, or builds leave the working pins unchanged. Promotion saves +an ignored recovery journal and restores its own changes on a catchable failure. +An uncatchable interruption can leave `.local/update-recovery.*`; inspect its +`original/` and `candidate/` files before resuming. Concurrent edits are preserved. +Check that no update is active before removing a stale `.local/update.lock`. +If the pins were already staged, restage their reviewed changes to satisfy index +and working-tree parity. No updated pins are committed or activated automatically. + +## Rollback and recovery + +List generations before choosing a rollback: + +```bash +sudo darwin-rebuild --list-generations +``` + +After reviewing the target, restore the previous generation with: + +```bash +sudo darwin-rebuild --rollback +``` + +For a particular listed generation, use +`sudo darwin-rebuild --switch-generation NUMBER`. This runs that generation's +activation. It restores Nix-managed packages and configuration, but does not +reverse vendor app updates, Homebrew/MAS installations, seeded writable settings, +1Password restores, keychain imports, or manual profile approvals. Its Homebrew +activation may install missing apps declared by that older generation. +Do not garbage-collect the known-good generation until recovery is verified. +After rollback, diagnose the checkout before applying it again; rollback does +not change Git files or dependency pins. + +If Fish or the normal terminal is unavailable, use Terminal.app with `/bin/zsh` +and invoke `/run/current-system/sw/bin/darwin-rebuild` explicitly. + +## CI and restore rehearsal + +The macOS Actions workflow runs `scripts/ci`: the repository suite and all flake +checks, including the complete public system build and profile-composition +assertions. It uses Apple Silicon macOS, a pinned Determinate installer action, +full Git history, and read-only repository permissions. It never activates or +requires private state. Run it locally with `scripts/ci` when changing CI checks. +The hosted build uses macOS 26; it does not certify macOS 27 GUI behavior. + +Use the [disposable-Mac rehearsal](restore-rehearsal.md) for activation, +interruption/resume, vendor approvals, and repeated activation. + +## Checkout location and shortcuts + +Setup and successful switching record the selected checkout outside Git at +`~/Library/Application Support/mac-setup/checkout`. The `mac-setup` launcher and +Fish's `rebuild`, `update`, `fishconf`, and `nixconf` use this private record. +`MAC_SETUP_CONFIG_DIR` overrides it for one invocation; the default remains +`~/.config/mac-setup` when no record exists. Candidate builds never change it. + +## Release pin review + `scripts/update` queries GitHub for Filen Menubar's latest published stable release. When a newer version exists, it requires the expected Apple Silicon DMG, verifies the downloaded bytes against GitHub's release-asset SHA-256 @@ -49,7 +122,7 @@ never accepts cleanup, and separately installed apps can remain intentional. `topgrade` updates supported user tools and package managers, including pnpm. It deliberately skips Nix, Home Manager, npm-global packages, and its own self-update because those have repository or project owners. Use -`scripts/update` for Nix inputs. +`scripts/update` for Nix inputs. Docker/container image updates are also disabled. pnpm global executables live below `$PNPM_HOME/bin`, which activation creates and Fish adds to `PATH`. Do not run `pnpm setup`; it would mutate shell diff --git a/docs/pre-wipe-checklist.md b/docs/pre-wipe-checklist.md index 4cc20db..65306c3 100644 --- a/docs/pre-wipe-checklist.md +++ b/docs/pre-wipe-checklist.md @@ -8,6 +8,8 @@ Do not erase the Mac until every final gate is green. - [ ] `scripts/validate` passes. - [ ] `scripts/rebuild build` succeeds twice without an unexpected second change. - [ ] Bootstrap was rehearsed in build-only mode. +- [ ] The [disposable-Mac rehearsal](restore-rehearsal.md) covers first activation, + private-restore resume, and repeated activation for the selected revision. - [ ] Homebrew cleanup remains `none`, or a cleanup dry-run has been reviewed line by line. - [ ] Every required app/tool is declared or documented as a manual/vendor-synced restore. - [ ] No Chezmoi dependency or restore step remains. diff --git a/docs/restore-rehearsal.md b/docs/restore-rehearsal.md new file mode 100644 index 0000000..94bb2d4 --- /dev/null +++ b/docs/restore-rehearsal.md @@ -0,0 +1,44 @@ +# Disposable-Mac restore rehearsal + +Use a spare Apple Silicon Mac or a disposable macOS VM on Apple hardware. +Take a VM snapshot where supported. Perform this rehearsal on macOS 27 or newer +before relying on a clean restore. CI covers public builds, not interactive +macOS activation or vendor authentication. + +Keep results outside the public repository: exact Git revision, macOS version, +chosen profiles/accounts, commands used, observed failures, and recovery steps. +Do not publish private account identifiers, machine paths, or authentication logs. + +1. Follow the README's fresh-Mac prerequisites and build-only bootstrap. For an + unpublished candidate, copy a reviewed public checkout and use its `setup.sh + --config-dir` option. Record `git rev-parse HEAD` plus any uncommitted diff. + Use `scripts/rebuild preview` to inspect the candidate before proceeding. +2. Run `./setup.sh --config-dir "$PWD" --apply` inside that checkout. Complete + App Management approval if needed and use the exact printed resume command. + Confirm Fish is the login shell and double-click a harmless test shell script + from Finder. Verify that an `ssh://` link opens Otty without initiating a + connection to an unreviewed host. +3. Run `scripts/doctor --skip git --skip ssh --skip gpg --skip mail --skip filen`. + Confirm selected baseline checks pass and intentionally omitted private + components are reported as skipped. Run `mac-setup doctor` from outside the + checkout to exercise the recorded location and Fish shortcuts. +4. Start `scripts/finish-setup` with the account/skip options appropriate for the + rehearsal. Stop at a private-restore approval or sign-in prompt. Resume using + the exact `scripts/finish-setup` command printed by setup, or the same direct + invocation if interrupted with Control-C. Verify completed steps remain + intact; do not rerun the public activation to resume private restore. +5. Complete the selected restores and run `scripts/doctor` with matching + `--mail-account` and `--skip` options. Follow the separate interactive checks + in [post-install verification](restore-verification.md), including signed + Git commits, GPG fingerprints, S/MIME decryption, and representative sync. +6. Change an Otty appearance setting and a Zed setting. Run + `scripts/rebuild switch` again, then rerun doctor. Confirm those edits survive, + no duplicate profiles appear, Filen's agent remains loaded, and OMC setup has + not replaced existing user configuration. Check file and URL handlers again. +7. List generations and rehearse the documented rollback on this disposable + machine. Verify what Nix restores and record vendor or mutable state that + remains. Keep a known-good generation until these checks pass. + +A rehearsal is complete only when the actual activation, interruption/resume, +and second activation have been observed. A passing CI job or two cached builds +alone does not complete it. Record any skipped components explicitly. diff --git a/docs/restore-verification.md b/docs/restore-verification.md index 6919725..7e7a95e 100644 --- a/docs/restore-verification.md +++ b/docs/restore-verification.md @@ -15,186 +15,83 @@ - Launch Safari Technology Preview and check for its updates through Software Update. A missing Homebrew receipt does not mean the app bundle is absent. -## Full restore +## Read-only checks -The complete block verifies the regular full restore: Git identity, GPG, the -default Mail/DAV account, Filen, and the configured application profiles. Run -it after `setup.sh --provision` completes, or after a base activation followed -by every equivalent manual restore. +After provisioning, run from the selected checkout: -If a component was intentionally skipped, omit its numbered section rather -than treating that expected absence as a failure. The -`selected_profile_accounts` value assumes `personal-mail`; change it to the -IMAP/DAV account IDs selected for this Mac. +```bash +scripts/doctor +scripts/doctor --json +``` + +The command reports PASS, FAIL, or SKIP for each check, includes a repair step +for failures, and returns nonzero when a selected check fails. It checks the +active generation's metadata, local host selectors, login shell, declared app +receipts, writable settings, file handlers, private-file permissions, selected +Mail profiles, and enabled development/Filen components. A receipt does not +prove an app still launches; the manual checks below remain necessary. + +Match intentional omissions and account selection to the restore you performed: + +```bash +scripts/doctor --skip gpg --skip filen +scripts/doctor --mail-account personal-mail --mail-account work-mail +scripts/doctor --only configs +``` + +Components are `system`, `apps`, `configs`, `git`, `ssh`, `gpg`, `mail`, `filen`, +and `development`. The first explicit Mail account replaces `personal-mail`. +Development and Filen checks use the active generation's selected features. +Before the first activation of a generation with metadata, the system check +fails and optional feature checks are skipped; that is not a complete restore. + +Doctor performs no signing, authentication, restore, activation, or cleanup. +Git and GPG checks establish file presence and permissions, not key usability. +The account-profile check may create private temporary query files, which its +existing helper removes. It never imports identities or approves profiles. -Start `/bin/bash`, then paste the complete block. The parentheses keep a failed -check from closing the parent shell. +## Interactive smoke checks + +These checks can prompt, authenticate, start an agent, or create disposable +state, so they are deliberately separate from doctor. Run them in Bash after +restoring the components you intend to use: ```bash -( - set -euo pipefail - - cd "$HOME/.config/mac-setup" - expected_revision="$(<.local/bootstrap-revision)" - [[ "$expected_revision" =~ ^[0-9a-f]{40}$ ]] - test "$(/usr/bin/stat -f '%Lp' .local/bootstrap-revision)" = 600 - private_state_dir="$HOME/Library/Application Support/mac-setup" - mail_config="$private_state_dir/mail-accounts.json" - mail_profile_dir="$private_state_dir/mail-profiles" - - if [[ -f /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh ]]; then - source /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh - fi - if [[ -x /opt/homebrew/bin/brew ]]; then - eval "$(/opt/homebrew/bin/brew shellenv)" - fi - - # 1. Confirm the recorded checkout revision and public repository safety. - test "$(git rev-parse HEAD)" = "$expected_revision" - test -z "$(git status --porcelain)" - scripts/validate - scripts/check-history-safety HEAD - filtered_source="$(scripts/flake-source)" - test ! -e "$filtered_source/.local" - - # 2. Prove the configuration builds repeatedly and the package inventory is present. - scripts/rebuild build - scripts/rebuild build - scripts/homebrew-dry-run - brew list --versions mole - mo --version - - # 3. Verify 1Password, SSH access, private files, and signed Git commits. - export SSH_AUTH_SOCK="$HOME/Library/Group Containers/2BUA8C4S2C.com.1password/t/agent.sock" - op account get >/dev/null - ssh-add -L >/dev/null - GIT_CONFIG_NOSYSTEM=1 GIT_CONFIG_GLOBAL=/dev/null git ls-remote \ - https://github.com/philippgerard/mac-setup.git HEAD >/dev/null - - for identity_file in identity.inc public-identity.inc allowed_signers; do - identity_path="$HOME/.config/git/$identity_file" - test -s "$identity_path" - test "$(/usr/bin/stat -f '%Lp' "$identity_path")" = 600 - done - git config --get user.email | grep -q '@users\.noreply\.github\.com$' - test -n "$(git config --get user.signingKey)" - test "$(/usr/bin/stat -f '%Lp' "$HOME/.ssh")" = 700 - test "$(/usr/bin/stat -f '%Lp' "$HOME/.ssh/config.d")" = 700 - while IFS= read -r -d '' private_ssh_host; do - test -f "$private_ssh_host" - test ! -L "$private_ssh_host" - test "$(/usr/bin/stat -f '%Lp' "$private_ssh_host")" = 600 - done < <(find "$HOME/.ssh/config.d" -mindepth 1 -maxdepth 1 -print0) - - signing_test_repo="$(mktemp -d "${TMPDIR:-/tmp}/mac-setup-signing.XXXXXX")" - git -C "$signing_test_repo" init -q - git -C "$signing_test_repo" commit --allow-empty -S -m 'SSH signing verification' >/dev/null - git -C "$signing_test_repo" verify-commit HEAD - - # 4. Verify the restored legacy GPG material. - gpg --list-secret-keys --with-colons | grep -q '^sec:' - gpg --list-secret-keys --keyid-format long - gpgconf --list-dirs agent-socket >/dev/null - - # 5. Verify the private account metadata and generated password-free IMAP/DAV profiles. - test -s "$mail_config" - test "$(/usr/bin/stat -f '%Lp' "$mail_config")" = 600 - test -d "$mail_profile_dir" - test "$(/usr/bin/stat -f '%Lp' "$mail_profile_dir")" = 700 - mail_profile_count="$(find "$mail_profile_dir" -maxdepth 1 -type f -name '*.mobileconfig' | wc -l | tr -d '[:space:]')" - test "$mail_profile_count" -ge 1 - while IFS= read -r -d '' mail_profile; do - test "$(/usr/bin/stat -f '%Lp' "$mail_profile")" = 600 - plutil -lint "$mail_profile" >/dev/null - done < <(find "$mail_profile_dir" -maxdepth 1 -type f -name '*.mobileconfig' -print0) - - test "$(scripts/configuration-profile-state \ - configuration-profiles/disable-icloud-mail-calendar-contacts.mobileconfig)" = exact - selected_profile_accounts=(personal-mail) - for profile_id in "${selected_profile_accounts[@]}"; do - profile_file="$mail_profile_dir/$profile_id.mobileconfig" - scripts/validate-mail-account-profile \ - "$profile_id" "$mail_config" "$profile_file" >/dev/null - test "$(scripts/configuration-profile-state "$profile_file")" = exact - done - - # 6. Verify GUI tools own writable configs rather than immutable Nix links. - otty_config="$HOME/.config/otty/config.toml" - test -f "$otty_config" - test ! -L "$otty_config" - test -w "$otty_config" - zed_settings="$HOME/.config/zed/settings.json" - test -f "$zed_settings" - test ! -L "$zed_settings" - test -w "$zed_settings" - - # 7. Verify Filen Menubar, its bundled backend, and the login agent. - filen_config="$HOME/Library/Application Support/filen-menubar/config.json" - test -s "$filen_config" - test "$(/usr/bin/stat -f '%Lp' "$filen_config")" = 600 - test ! -e "$HOME/.local/bin/filen" - filen_app="$HOME/Applications/Home Manager Apps/Filen Menubar.app" - test -d "$filen_app" - test ! -L "$filen_app" - test -x "$filen_app/Contents/Helpers/filen-menubar-cli" - test -s "$filen_app/Contents/Resources/filen-cli/filen-cli.cjs" - test -s "$filen_app/Contents/Resources/filen-cli/node_modules/@jupiterpi/node-keyring/node-keyring.darwin-arm64.node" - for filen_notice in \ - AGPL-3.0.txt \ - NODE-LICENSE.txt \ - THIRD_PARTY_NOTICES.txt \ - runtime.cdx.json; do - test -s "$filen_app/Contents/Resources/licenses/filen-cli/$filen_notice" - done - /usr/bin/codesign --verify --deep --strict "$filen_app" - /usr/bin/codesign --verify --strict \ - "$filen_app/Contents/Helpers/filen-menubar-cli" - /usr/bin/codesign --verify --strict \ - "$filen_app/Contents/Resources/filen-cli/node_modules/@jupiterpi/node-keyring/node-keyring.darwin-arm64.node" - if test -e "$HOME/.filen-cli"; then - filen_state="$HOME/.filen-cli" - else - filen_state="$HOME/Library/Application Support/filen-cli" - fi - test -d "$filen_state" - test "$(/usr/bin/stat -f '%Lp' "$filen_state")" = 700 - filen_credential="$filen_state/.filen-cli-keep-me-logged-in" - test ! -e "$filen_credential" || test "$(/usr/bin/stat -f '%Lp' "$filen_credential")" = 600 - launchctl print "gui/$(id -u)/org.nix-community.home.filen-menubar" >/dev/null - test -d '/Applications/Microsoft Teams.app' - - # 8. Exercise the remaining command-line entry points. - fnm --version - pnpm --version - pnpm bin -g - topgrade --dry-run --only pnpm - erl -noshell -eval 'io:format("OTP ~s~n", [erlang:system_info(otp_release)]), halt().' - elixir --version - mix --version - cargo --version - rustc --version - rustfmt --version - cargo clippy --version - rust-analyzer --version - gh --version - gh auth status - codex --version - claude --version - omc --version - tmux -V - ssh -V - test "$(dscl . -read "/Users/$(id -un)" UserShell)" = 'UserShell: /run/current-system/sw/bin/fish' - - printf 'Automated restore verification passed.\n' - printf 'The disposable signing-test repository is at %s\n' "$signing_test_repo" -) +op account get >/dev/null +ssh-add -L >/dev/null + +git_test_dir="$(mktemp -d "${TMPDIR:-/tmp}/mac-setup-signing.XXXXXX")" +git -C "$git_test_dir" init -q +git -C "$git_test_dir" commit --allow-empty -S -m 'SSH signing verification' +git -C "$git_test_dir" verify-commit HEAD + +gpg --list-secret-keys --keyid-format long +gpgconf --list-dirs agent-socket + +gh auth status +omc --version +fnm --version +pnpm --version +pnpm bin -g +erl -noshell -eval 'io:format("OTP ~s~n", [erlang:system_info(otp_release)]), halt().' +elixir --version +mix --version +cargo --version +rustc --version ``` +Compare GPG fingerprints with the trusted backup. Follow the S/MIME checks in +[Mail and account setup](mail-accounts.md); doctor does not prove certificate +trust, private-key usability, or decryption. Remove the disposable signing test +repository after inspecting its result. + +For clean-install confidence, complete the separate +[disposable-Mac rehearsal](restore-rehearsal.md), including interrupted restore +and repeated activation. Keep its logs and private evidence outside Git. + ## Manual checks -The automated block confirms declared S/MIME certificate/private-key pairs are -present in the login keychain, but cannot prove certificate trust or Mail -decryption. Verify: +Verify the behavior of the restored applications and identities: - every installed Mail account can send and receive; - every S/MIME identity is in the login keychain, the current certificate is diff --git a/flake.nix b/flake.nix index 64013c3..cc2cb39 100644 --- a/flake.nix +++ b/flake.nix @@ -30,10 +30,10 @@ then builtins.fromJSON (builtins.readFile localConfigPath) else import ./local.example.nix; - mkDarwinSystem = { host }: + mkDarwinSystem = { host, hostConfig ? localConfig, extraModules ? [] }: nix-darwin.lib.darwinSystem { inherit system; - specialArgs = { inherit inputs localConfig; }; + specialArgs = { inherit inputs; localConfig = hostConfig; }; modules = [ determinate.darwinModules.default ./hosts/${host} @@ -46,18 +46,51 @@ backupFileExtension = "before-home-manager"; useGlobalPkgs = true; useUserPackages = true; - extraSpecialArgs = { inherit inputs localConfig; }; - users.${localConfig.username} = import ./modules/home; + extraSpecialArgs = { inherit inputs; localConfig = hostConfig; }; + users.${hostConfig.username} = import ./modules/home; }; } - ]; + ] ++ extraModules; }; + publicSystem = mkDarwinSystem { + host = "mini"; + hostConfig = import ./local.example.nix; + }; + baseSystem = mkDarwinSystem { + host = "mini"; + hostConfig = import ./local.example.nix; + extraModules = [{ + disabledModules = [ + ./profiles/development.nix + ./profiles/personal.nix + ./profiles/work.nix + ./profiles/gaming.nix + ./profiles/desktop.nix + ]; + }]; + }; + packageNames = configuration: + map nixpkgs.lib.getName configuration.config.home-manager.users.macuser.home.packages; in { darwinConfigurations = { mini = mkDarwinSystem { host = "mini"; }; }; + checks.${system} = { + system = publicSystem.system; + profile-composition = + assert builtins.elem "oh-my-claudecode" (packageNames publicSystem); + assert builtins.elem "filen-menubar" (packageNames publicSystem); + assert !(builtins.elem "oh-my-claudecode" (packageNames baseSystem)); + assert !(builtins.elem "filen-menubar" (packageNames baseSystem)); + assert !(builtins.elem "rustc" (packageNames baseSystem)); + assert !baseSystem.config.home-manager.users.macuser.programs.gh.enable; + assert baseSystem.config.home-manager.users.macuser.home.sessionVariables.EDITOR == "vim"; + assert publicSystem.config.home-manager.users.macuser.home.sessionVariables.EDITOR == "zed --wait"; + pkgs.runCommand "profile-composition" {} "touch $out"; + }; + apps.${system}.darwin-rebuild = { type = "app"; program = "${nix-darwin.packages.${system}.darwin-rebuild}/bin/darwin-rebuild"; @@ -65,6 +98,7 @@ devShells.${system}.validation = pkgs.mkShell { packages = with pkgs; [ + actionlint bash coreutils fish diff --git a/modules/darwin/default.nix b/modules/darwin/default.nix index 68cf820..3b166e0 100644 --- a/modules/darwin/default.nix +++ b/modules/darwin/default.nix @@ -6,6 +6,7 @@ ./nix.nix ./system.nix ./homebrew.nix + ./manifest.nix ]; system.primaryUser = localConfig.username; @@ -23,12 +24,4 @@ duti # Set default applications for file types ]; - # Otty is the chosen terminal and script handler. - system.activationScripts.postActivation.text = '' - echo "Setting Otty as the default terminal for scripts..." - ${pkgs.duti}/bin/duti -s io.appmakes.otty public.shell-script all - ${pkgs.duti}/bin/duti -s io.appmakes.otty public.unix-executable all - ${pkgs.duti}/bin/duti -s io.appmakes.otty com.apple.terminal.shell-script all 2>/dev/null || true - ${pkgs.duti}/bin/duti -s io.appmakes.otty ssh all 2>/dev/null || true - ''; } diff --git a/modules/darwin/manifest.nix b/modules/darwin/manifest.nix new file mode 100644 index 0000000..a428232 --- /dev/null +++ b/modules/darwin/manifest.nix @@ -0,0 +1,18 @@ +{ config, lib, localConfig, ... }: + +let + home = config.home-manager.users.${localConfig.username}; + packageNames = map lib.getName home.home.packages; +in +{ + # Public generation metadata supports previews and read-only diagnostics. + environment.etc."mac-setup/Brewfile".text = config.homebrew.brewfile; + environment.etc."mac-setup/manifest.json".text = builtins.toJSON { + schema = 1; + development = home.programs.gh.enable; + filen = builtins.elem "filen-menubar" packageNames; + casks = map (cask: cask.name) config.homebrew.casks; + brews = map (brew: brew.name) config.homebrew.brews; + masApps = config.homebrew.masApps; + }; +} diff --git a/modules/home/default.nix b/modules/home/default.nix index cbc50e2..3a7acc6 100644 --- a/modules/home/default.nix +++ b/modules/home/default.nix @@ -1,16 +1,13 @@ -{ localConfig, ... }: +{ lib, localConfig, ... }: { imports = [ ./packages.nix - ./oh-my-claudecode.nix - ./filen-menubar.nix ./fish.nix ./git.nix ./gpg.nix ./ssh.nix ./otty.nix - ./zed.nix ./directories.nix ./tmux.nix ./topgrade.nix @@ -28,8 +25,8 @@ # Environment variables home.sessionVariables = { - EDITOR = "zed --wait"; - VISUAL = "zed --wait"; + EDITOR = lib.mkDefault "vim"; + VISUAL = lib.mkDefault "vim"; TERMINAL = "otty"; LANG = "en_US.UTF-8"; LC_ALL = "en_US.UTF-8"; diff --git a/modules/home/development.nix b/modules/home/development.nix new file mode 100644 index 0000000..fa63666 --- /dev/null +++ b/modules/home/development.nix @@ -0,0 +1,51 @@ +{ pkgs, ... }: + +{ + imports = [ + ./oh-my-claudecode.nix + ./zed.nix + ]; + + home.sessionVariables = { + EDITOR = "zed --wait"; + VISUAL = "zed --wait"; + }; + + programs.gh = { + enable = true; + settings = { + git_protocol = "https"; + prompt = "enabled"; + }; + }; + + home.packages = with pkgs; [ + # Keep the BEAM pair explicit so Mix worktrees get the tested OTP release. + beam.interpreters.erlang_29 + beam.packages.erlang_29.elixir_1_20 + biome + cargo + claude-code + clippy + fastlane + ffmpeg + gh + git-lfs + go + imagemagick + mkcert + mosh + pandoc + pnpm + fnm + rust-analyzer + rustc + rustfmt + sentry-cli + tea + uv + watchman + xcbeautify + xcodegen + ]; +} diff --git a/modules/home/fish.nix b/modules/home/fish.nix index e83bdaf..a92a9f5 100644 --- a/modules/home/fish.nix +++ b/modules/home/fish.nix @@ -1,6 +1,11 @@ { config, pkgs, lib, ... }: { + home.file.".local/bin/mac-setup" = { + source = ../../scripts/mac-setup; + executable = true; + }; + # Fish shell configuration programs.fish = { enable = true; @@ -46,15 +51,15 @@ glog = "git log --oneline --graph --decorate"; # Nix/darwin shortcuts - rebuild = "~/.config/mac-setup/scripts/rebuild switch"; - update = "~/.config/mac-setup/scripts/update"; + rebuild = "mac-setup rebuild switch"; + update = "mac-setup update"; }; # Shell abbreviations (expand on space, better than aliases for some cases) shellAbbrs = { # Quick edits - fishconf = "zed ~/.config/mac-setup/modules/home/fish.nix"; - nixconf = "zed ~/.config/mac-setup"; + fishconf = "mac-setup edit modules/home/fish.nix"; + nixconf = "mac-setup edit"; }; # Interactive shell init @@ -187,6 +192,8 @@ enableFishIntegration = true; settings = { add_newline = true; + # Allow brief filesystem latency during terminal startup. + scan_timeout = 200; command_timeout = 1000; character = { diff --git a/modules/home/git.nix b/modules/home/git.nix index c317aa7..dc72989 100644 --- a/modules/home/git.nix +++ b/modules/home/git.nix @@ -143,13 +143,4 @@ ]; }; - # GitHub CLI - programs.gh = { - enable = true; - settings = { - git_protocol = "https"; - prompt = "enabled"; - }; - }; - } diff --git a/modules/home/otty.nix b/modules/home/otty.nix index 3ed4751..c5cd872 100644 --- a/modules/home/otty.nix +++ b/modules/home/otty.nix @@ -41,6 +41,11 @@ let ''; in { + # Home Manager runs in the primary user's GUI session after Homebrew. + home.activation.configureOttyHandlers = lib.hm.dag.entryAfter [ "linkGeneration" ] '' + $DRY_RUN_CMD ${../../scripts/configure-otty-handlers} ${pkgs.duti}/bin/duti + ''; + # A future xdg.configFile/home.file declaration would turn this back into a # read-only Nix-store link and break Otty's Settings UI. assertions = [ diff --git a/modules/home/packages.nix b/modules/home/packages.nix index 61480cb..ff9cb19 100644 --- a/modules/home/packages.nix +++ b/modules/home/packages.nix @@ -15,36 +15,8 @@ curl wget - # Development - # Keep the BEAM pair explicit so Mix worktrees get the tested OTP release. - beam.interpreters.erlang_29 - beam.packages.erlang_29.elixir_1_20 - biome - cargo - claude-code - clippy - fastlane - ffmpeg - gh - git-lfs - go - imagemagick - mkcert - mosh - pandoc - pnpm - fnm - rust-analyzer - rustc - rustfmt - sentry-cli - tea - uv - watchman - xcbeautify - xcodegen - # Security, maintenance, and repository validation + actionlint _1password-cli gitleaks gnupg diff --git a/modules/home/topgrade.nix b/modules/home/topgrade.nix index 965a3ad..ff48811 100644 --- a/modules/home/topgrade.nix +++ b/modules/home/topgrade.nix @@ -15,7 +15,8 @@ let # Claude Code: Topgrade passes the "synced" scope of account-managed # plugins to `claude plugin update`, which rejects it. Update Claude Code # with `claude update` instead. - disable = ["nix", "home_manager", "node", "claude_code"] + # Skip Docker/container image updates as well. + disable = ["nix", "home_manager", "node", "claude_code", "containers"] # Pre-commands (run before updates) # [pre_commands] diff --git a/profiles/development.nix b/profiles/development.nix index 4e07333..3354441 100644 --- a/profiles/development.nix +++ b/profiles/development.nix @@ -1,6 +1,10 @@ -{ ... }: +{ localConfig, ... }: { + home-manager.users.${localConfig.username}.imports = [ + ../modules/home/development.nix + ]; + homebrew.casks = [ "aqua-voice" "chatgpt" diff --git a/profiles/personal.nix b/profiles/personal.nix index cc5a5d8..d74dff7 100644 --- a/profiles/personal.nix +++ b/profiles/personal.nix @@ -1,6 +1,10 @@ -{ ... }: +{ localConfig, ... }: { + home-manager.users.${localConfig.username}.imports = [ + ../modules/home/filen-menubar.nix + ]; + homebrew.casks = [ "beeper" "imageoptim" diff --git a/scripts/ci b/scripts/ci new file mode 100755 index 0000000..4cc52e4 --- /dev/null +++ b/scripts/ci @@ -0,0 +1,20 @@ +#!/bin/bash -p + +set -euo pipefail + +REPO_ROOT="$(cd "$(/usr/bin/dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" +# shellcheck source=scripts/trusted-path +source "$REPO_ROOT/scripts/trusted-path" +mac_setup_harden_environment +# shellcheck source=scripts/trusted-nix +source "$REPO_ROOT/scripts/trusted-nix" +NIX_BIN="$(mac_setup_trusted_nix)" +[[ "$(/usr/bin/uname -m)" == arm64 ]] || { + echo "CI requires Apple Silicon macOS" >&2 + exit 1 +} +unset MAC_SETUP_LOCAL_CONFIG +"$REPO_ROOT/scripts/validate" +source_path="$("$REPO_ROOT/scripts/flake-source")" +# No local host metadata, 1Password session, App Store login, or activation. +"$NIX_BIN" flake check --no-update-lock-file "$source_path" diff --git a/scripts/configure-otty-handlers b/scripts/configure-otty-handlers new file mode 100755 index 0000000..69aa1d1 --- /dev/null +++ b/scripts/configure-otty-handlers @@ -0,0 +1,22 @@ +#!/bin/bash -p + +set -euo pipefail + +[[ $# -eq 1 && "$1" == /* && -x "$1" ]] || { + echo "usage: configure-otty-handlers " >&2 + exit 2 +} +[[ "$(/usr/bin/id -u)" -ne 0 ]] || { + echo "Otty associations must be configured as the desktop user" >&2 + exit 1 +} + +for content_type in public.shell-script public.unix-executable com.apple.terminal.shell-script; do + "$1" -s io.appmakes.otty "$content_type" all + [[ "$("$1" -d "$content_type")" == io.appmakes.otty ]] || { + echo "Otty association did not take effect for $content_type" >&2 + exit 1 + } +done +# URL schemes take no role argument. Keep failures visible to the operator. +"$1" -s io.appmakes.otty ssh diff --git a/scripts/doctor b/scripts/doctor new file mode 100755 index 0000000..7138fba --- /dev/null +++ b/scripts/doctor @@ -0,0 +1,212 @@ +#!/bin/bash -p + +set -euo pipefail + +REPO_ROOT="$(cd "$(/usr/bin/dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" +ONLY="" +SKIP=" " +JSON=0 +MAIL_ACCOUNTS=(personal-mail) +MAIL_EXPLICIT=0 +COMPONENTS=" system apps configs git ssh gpg mail filen development " + +usage() { + cat <<'EOF' +Usage: scripts/doctor [--json] [--only COMPONENT] [--skip COMPONENT] + [--mail-account ACCOUNT_ID ...] + +Read-only post-install checks. Components: system, apps, configs, git, ssh, +gpg, mail, filen, development. --skip and --mail-account may be repeated. +Exit 0 means all selected checks passed; 1 means a check failed; 2 means bad usage. +No signing, authentication, restore, activation, or cleanup is performed. +EOF +} +while [[ $# -gt 0 ]]; do + case "$1" in + --json) JSON=1; shift ;; + --only|--skip) + [[ $# -ge 2 && "$COMPONENTS" == *" $2 "* ]] || { usage >&2; exit 2; } + if [[ "$1" == --only ]]; then ONLY="$2"; else SKIP+="$2 "; fi + shift 2 + ;; + --mail-account) + [[ $# -ge 2 && "$2" =~ ^[a-z][a-z0-9-]*$ ]] || { usage >&2; exit 2; } + if [[ "$MAIL_EXPLICIT" -eq 0 ]]; then MAIL_ACCOUNTS=("$2"); else MAIL_ACCOUNTS+=("$2"); fi + MAIL_EXPLICIT=1 + shift 2 + ;; + -h|--help) usage; exit 0 ;; + *) usage >&2; exit 2 ;; + esac +done + +# shellcheck source=scripts/trusted-path +source "$REPO_ROOT/scripts/trusted-path" +mac_setup_harden_environment +command -v jq >/dev/null || { echo "jq is missing; activate the base configuration first" >&2; exit 1; } +manifest="/run/current-system/etc/mac-setup/manifest.json" +private_state="$HOME/Library/Application Support/mac-setup" +failures=0 +passed=0 +skipped=0 +records="" + +report() { + local component="$1" status="$2" label="$3" remedy="$4" + case "$status" in + PASS) passed=$((passed + 1)) ;; + FAIL) failures=$((failures + 1)) ;; + SKIP) skipped=$((skipped + 1)) ;; + esac + if [[ "$JSON" -eq 1 ]]; then + records+="$(jq -cn --arg component "$component" --arg status "$status" \ + --arg check "$label" --arg action "$remedy" \ + '{component: $component, status: $status, check: $check, action: $action}')"$'\n' + else + printf '%-4s %s: %s\n' "$status" "$component" "$label" + [[ -z "$remedy" ]] || printf ' %s\n' "$remedy" + fi +} + +selected() { + [[ ( -z "$ONLY" || "$ONLY" == "$1" ) && "$SKIP" != *" $1 "* ]] +} + +check() { + local component="$1" label="$2" remedy="$3" + shift 3 + if ! selected "$component"; then + report "$component" SKIP "$label" "Excluded by the selected scope." + elif "$@" >/dev/null 2>&1; then + report "$component" PASS "$label" "" + else + report "$component" FAIL "$label" "$remedy" + fi +} + +private_file() { + [[ -f "$1" && ! -L "$1" && -O "$1" && \ + "$(/usr/bin/stat -f '%Lp' "$1")" == 600 && -s "$1" ]] +} +private_directory() { + [[ -d "$1" && ! -L "$1" && -O "$1" && \ + "$(/usr/bin/stat -f '%Lp' "$1")" == 700 ]] +} +manifest_valid() { + [[ -f "$manifest" ]] && jq -e ' + .schema == 1 and (.development | type == "boolean") and + (.filen | type == "boolean") and (.casks | type == "array") and + (.brews | type == "array") and (.masApps | type == "object") + ' "$manifest" >/dev/null +} +feature_enabled() { + manifest_valid && jq -e --arg feature "$1" '.[$feature] == true' "$manifest" >/dev/null +} +shell_ready() { + /usr/bin/dscl . -read "/Users/$(/usr/bin/id -un)" UserShell | \ + /usr/bin/grep -Fqx 'UserShell: /run/current-system/sw/bin/fish' +} +handler_ready() { + [[ "$(duti -d "$1")" == io.appmakes.otty ]] +} +ssh_state_ready() { + private_directory "$HOME/.ssh" && private_directory "$HOME/.ssh/config.d" || return 1 + local entry + for entry in "$HOME/.ssh/config.d/"* "$HOME/.ssh/config.d/".[!.]* "$HOME/.ssh/config.d/"..?*; do + [[ -e "$entry" || -L "$entry" ]] || continue + private_file "$entry" || return 1 + done +} +gpg_files_ready() { + private_directory "$HOME/.gnupg" || return 1 + private_directory "$HOME/.gnupg/private-keys-v1.d" || return 1 + local entry found=0 + for entry in "$HOME/.gnupg/private-keys-v1.d/"*.key; do + [[ -e "$entry" || -L "$entry" ]] || continue + private_file "$entry" || return 1 + found=1 + done + [[ "$found" -eq 1 ]] +} +mail_ready() { + local account="$1" profile="$private_state/mail-profiles/$1.mobileconfig" + private_directory "$private_state" && private_directory "$private_state/mail-profiles" && \ + private_file "$private_state/mail-accounts.json" && private_file "$profile" || return 1 + "$REPO_ROOT/scripts/validate-mail-account-profile" "$account" \ + "$private_state/mail-accounts.json" "$profile" >/dev/null || return 1 + [[ "$("$REPO_ROOT/scripts/configuration-profile-state" "$profile")" == exact ]] +} +icloud_profile_ready() { + [[ "$("$REPO_ROOT/scripts/configuration-profile-state" \ + "$REPO_ROOT/configuration-profiles/disable-icloud-mail-calendar-contacts.mobileconfig")" == exact ]] +} +applications_ready() { + manifest_valid || return 1 + [[ -x /opt/homebrew/bin/brew && -x /opt/homebrew/bin/mas ]] || return 1 + local installed_casks installed_brews installed_mas name + installed_casks="$(HOMEBREW_NO_AUTO_UPDATE=1 /opt/homebrew/bin/brew list --cask)" || return 1 + installed_brews="$(HOMEBREW_NO_AUTO_UPDATE=1 /opt/homebrew/bin/brew list --formula)" || return 1 + installed_mas="$(/opt/homebrew/bin/mas list)" || return 1 + while IFS= read -r name; do + /usr/bin/grep -Fxq -- "$name" <<<"$installed_casks" || return 1 + done < <(jq -r '.casks[]' "$manifest") + while IFS= read -r name; do + /usr/bin/grep -Fxq -- "$name" <<<"$installed_brews" || return 1 + done < <(jq -r '.brews[]' "$manifest") + while IFS= read -r name; do + /usr/bin/grep -Eq "^${name}[[:space:]]" <<<"$installed_mas" || return 1 + done < <(jq -r '.masApps[]' "$manifest") +} +development_ready() { + local executable + for executable in erl elixir mix cargo rustc rustfmt rust-analyzer fnm pnpm uv omc; do + command -v "$executable" >/dev/null || return 1 + done +} +filen_app_ready() { + local app="$HOME/Applications/Home Manager Apps/Filen Menubar.app" + [[ -d "$app" && ! -L "$app" && -x "$app/Contents/Helpers/filen-menubar-cli" ]] && \ + /usr/bin/codesign --verify --deep --strict "$app" +} + +check system "Generation metadata" "Build, review, and activate this version of mac-setup." manifest_valid +check system "Validated local host selectors" "Rerun setup from this checkout." \ + "$REPO_ROOT/scripts/validate-local-config" "$REPO_ROOT/.local/config.json" "$(/usr/bin/id -un)" "$HOME" mini +check system "Fish login shell" "Review and activate the base configuration." shell_ready +check apps "Declared Homebrew and MAS receipts" "Run scripts/homebrew-dry-run; complete missing installs and App Store sign-in." applications_ready +check configs "Writable Otty settings" "Activate the Otty module; preserve any existing settings backup." private_file "$HOME/.config/otty/config.toml" +check configs "Shell-script association" "Activate the Otty user module, then verify Finder behavior." handler_ready public.shell-script +check configs "Executable association" "Activate the Otty user module, then verify Finder behavior." handler_ready public.unix-executable +check configs "Command-file association" "Activate the Otty user module, then verify Finder behavior." handler_ready com.apple.terminal.shell-script +for identity_file in identity.inc public-identity.inc allowed_signers; do + check git "$identity_file permissions and presence" "Run scripts/configure-git-identity; test signing separately." \ + private_file "$HOME/.config/git/$identity_file" +done +check ssh "Private SSH host permissions" "Review private hosts, then run scripts/secure-ssh-private-state." ssh_state_ready +check gpg "Legacy secret-key files present" "Restore legacy GPG if required; verify fingerprints and usability separately." gpg_files_ready +check mail "iCloud service restrictions" "Run scripts/open-icloud-service-restrictions-profile and approve the profile." icloud_profile_ready +for account in "${MAIL_ACCOUNTS[@]}"; do + check mail "Selected account profile: $account" "Resume scripts/finish-setup for this account; verify Mail and S/MIME separately." mail_ready "$account" +done +if feature_enabled development; then + check development "Development commands available" "Activate the development profile; run project and authentication smoke tests separately." development_ready + check development "Writable Zed settings" "Activate the development profile; preserve any settings backup." private_file "$HOME/.config/zed/settings.json" +else + report development SKIP "Development tools" "The active manifest does not enable this feature." +fi +if feature_enabled filen; then + check filen "Private sync configuration" "Run scripts/restore-filen-menubar-from-1password." private_file "$HOME/Library/Application Support/filen-menubar/config.json" + check filen "Signed app and bundled backend" "Activate the personal profile and complete App Management approval." filen_app_ready + check filen "Login agent loaded" "Activate the personal profile, then open Filen Menubar." \ + /bin/launchctl print "gui/$(/usr/bin/id -u)/org.nix-community.home.filen-menubar" +else + report filen SKIP "Filen restore" "The active manifest does not enable this feature." +fi + +if [[ "$JSON" -eq 1 ]]; then + printf '%s' "$records" | jq -s --argjson passed "$passed" --argjson failed "$failures" \ + --argjson skipped "$skipped" '{checks: ., summary: {passed: $passed, failed: $failed, skipped: $skipped}}' +else + printf '\n%s passed, %s failed, %s skipped.\n' "$passed" "$failures" "$skipped" +fi +[[ "$failures" -eq 0 ]] diff --git a/scripts/mac-setup b/scripts/mac-setup new file mode 100755 index 0000000..42c4ee1 --- /dev/null +++ b/scripts/mac-setup @@ -0,0 +1,42 @@ +#!/bin/bash -p + +set -euo pipefail + +usage() { + echo "Usage: mac-setup {rebuild [build|preview|switch]|update|doctor [options]|edit [relative-path]}" +} +case "${1:-}" in + rebuild|update|doctor|edit) action="$1"; shift ;; + -h|--help) usage; exit 0 ;; + *) usage >&2; exit 2 ;; +esac + +checkout="${MAC_SETUP_CONFIG_DIR:-}" +record="$HOME/Library/Application Support/mac-setup/checkout" +if [[ -z "$checkout" && ( -e "$record" || -L "$record" ) ]]; then + [[ -f "$record" && ! -L "$record" && -O "$record" && \ + "$(/usr/bin/stat -f '%Lp' "$record")" == 600 ]] || { + echo "checkout record is unsafe; rerun setup from the intended checkout" >&2 + exit 1 + } + checkout="$(/bin/cat "$record")" +fi +checkout="${checkout:-$HOME/.config/mac-setup}" +[[ "$checkout" == /* && -d "$checkout" && -O "$checkout" && \ + -f "$checkout/flake.nix" && -x "$checkout/scripts/rebuild" ]] || { + echo "configuration checkout is unavailable; set MAC_SETUP_CONFIG_DIR or rerun setup" >&2 + exit 1 +} + +if [[ "$action" == edit ]]; then + [[ $# -le 1 ]] || { usage >&2; exit 2; } + relative_path="${1:-.}" + case "$relative_path" in + /*|..|../*|*/../*|*/..) echo "edit expects a path within the checkout" >&2; exit 2 ;; + esac + if command -v zed >/dev/null 2>&1; then + exec zed "$checkout/$relative_path" + fi + exec /usr/bin/vi "$checkout/$relative_path" +fi +exec "$checkout/scripts/$action" "$@" diff --git a/scripts/preview-system b/scripts/preview-system new file mode 100755 index 0000000..42d4e8c --- /dev/null +++ b/scripts/preview-system @@ -0,0 +1,53 @@ +#!/bin/bash -p + +set -euo pipefail + +REPO_ROOT="$(cd "$(/usr/bin/dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" +# shellcheck source=scripts/trusted-path +source "$REPO_ROOT/scripts/trusted-path" +mac_setup_harden_environment +# shellcheck source=scripts/trusted-nix +source "$REPO_ROOT/scripts/trusted-nix" + +[[ $# -ge 1 && $# -le 2 ]] || { + echo "usage: scripts/preview-system [previous-system]" >&2 + exit 2 +} +NIX_BIN="$(mac_setup_trusted_nix)" +candidate="$(/usr/bin/readlink -f "$1")" +previous="${2:-/run/current-system}" +[[ "$candidate" =~ ^/nix/store/[0-9a-z]{32}-[^/]+$ && -f "$candidate/activate" ]] || { + echo "preview requires a built immutable Darwin system" >&2 + exit 1 +} +echo "Candidate: $candidate" +if [[ -e "$previous" ]]; then + previous="$(/usr/bin/readlink -f "$previous")" + [[ "$previous" =~ ^/nix/store/[0-9a-z]{32}-[^/]+$ && -f "$previous/activate" ]] || { + echo "previous system is not an immutable Darwin generation" >&2 + exit 1 + } + echo "Package changes:" + "$NIX_BIN" store diff-closures "$previous" "$candidate" + echo "Closure sizes:" + "$NIX_BIN" path-info --closure-size --human-readable "$previous" "$candidate" +else + echo "No active generation; this is a first installation." + "$NIX_BIN" path-info --closure-size --human-readable "$candidate" +fi + +new_brewfile="$candidate/etc/mac-setup/Brewfile" +old_brewfile="$previous/etc/mac-setup/Brewfile" +[[ -f "$new_brewfile" ]] || { echo "candidate has no saved Brewfile" >&2; exit 1; } +echo "Homebrew/MAS declaration changes:" +if [[ -f "$old_brewfile" ]]; then + diff_status=0 + /usr/bin/diff -u "$old_brewfile" "$new_brewfile" || diff_status=$? + [[ "$diff_status" -le 1 ]] || exit "$diff_status" + [[ "$diff_status" -ne 0 ]] || echo "No declaration changes." +else + echo "The previous generation has no saved Brewfile; showing all candidate declarations." + /bin/cat "$new_brewfile" +fi +echo "GUI application versions remain managed by Homebrew, MAS, and their vendors." +echo "Nothing was activated. See docs/maintenance.md for activation and rollback." diff --git a/scripts/promote-update b/scripts/promote-update new file mode 100755 index 0000000..7d0446a --- /dev/null +++ b/scripts/promote-update @@ -0,0 +1,84 @@ +#!/bin/bash -p + +set -euo pipefail +umask 077 + +REPO_ROOT="$(cd "$(/usr/bin/dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" +[[ $# -eq 2 ]] || { echo "usage: promote-update " >&2; exit 2; } +baseline="$1" +candidate="$2" +pins=(flake.lock modules/home/filen-menubar-release.json modules/home/oh-my-claudecode-release.json) + +safe_file() { + [[ -f "$1" && ! -L "$1" && -O "$1" ]] +} +for directory in "$REPO_ROOT/.local" "$REPO_ROOT/modules" "$REPO_ROOT/modules/home" "$baseline" "$candidate"; do + [[ -d "$directory" && ! -L "$directory" && -O "$directory" ]] || { + echo "update promotion directory is unsafe" >&2 + exit 1 + } +done +for pin in "${pins[@]}"; do + if ! { safe_file "$REPO_ROOT/$pin" && safe_file "$baseline/$pin" && safe_file "$candidate/$pin"; }; then + echo "update pin is missing or unsafe: $pin" >&2 + exit 1 + fi + /usr/bin/cmp -s "$REPO_ROOT/$pin" "$baseline/$pin" || { + echo "update pin changed before promotion: $pin" >&2 + exit 1 + } +done + +# Three files cannot be renamed in one operation. Save originals and undo a +# failed promotion only when doing so will not overwrite a concurrent edit. +journal="$(/usr/bin/mktemp -d "$REPO_ROOT/.local/update-recovery.XXXXXX")" +/bin/mkdir -p "$journal/original/modules/home" "$journal/candidate/modules/home" +for pin in "${pins[@]}"; do + /bin/cp -p "$baseline/$pin" "$journal/original/$pin" + /bin/cp -p "$candidate/$pin" "$journal/candidate/$pin" +done +complete=0 +temporary_pin="" +cleanup() { + local status="$?" pin recovery_failed=0 + trap - EXIT INT TERM HUP + [[ -z "$temporary_pin" ]] || /bin/rm -f -- "$temporary_pin" + if [[ "$complete" -eq 0 ]]; then + for pin in "${pins[@]}"; do + if safe_file "$REPO_ROOT/$pin" && /usr/bin/cmp -s "$REPO_ROOT/$pin" "$journal/original/$pin"; then + continue + fi + if safe_file "$REPO_ROOT/$pin" && /usr/bin/cmp -s "$REPO_ROOT/$pin" "$journal/candidate/$pin"; then + if /bin/cp -p "$journal/original/$pin" "$REPO_ROOT/$pin"; then continue; fi + fi + recovery_failed=1 + done + fi + if [[ "$recovery_failed" -eq 1 ]]; then + echo "Promotion could not be fully restored without replacing concurrent edits." >&2 + echo "Review the saved originals and candidate in $journal" >&2 + else + /bin/rm -rf -- "$journal" + fi + exit "$status" +} +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM +trap 'exit 129' HUP +for pin in "${pins[@]}"; do + if ! { safe_file "$REPO_ROOT/$pin" && /usr/bin/cmp -s "$REPO_ROOT/$pin" "$journal/original/$pin"; }; then + echo "update pin changed during promotion: $pin" >&2 + exit 1 + fi + temporary_pin="$(/usr/bin/mktemp "$(/usr/bin/dirname "$REPO_ROOT/$pin")/.mac-setup-pin.XXXXXX")" + /bin/cp "$journal/candidate/$pin" "$temporary_pin" + /bin/chmod 644 "$temporary_pin" + if ! { safe_file "$REPO_ROOT/$pin" && /usr/bin/cmp -s "$REPO_ROOT/$pin" "$journal/original/$pin"; }; then + echo "update pin changed before replacement: $pin" >&2 + exit 1 + fi + /bin/mv "$temporary_pin" "$REPO_ROOT/$pin" + temporary_pin="" +done +complete=1 diff --git a/scripts/rebuild b/scripts/rebuild index 112d4f3..d627185 100755 --- a/scripts/rebuild +++ b/scripts/rebuild @@ -6,6 +6,12 @@ umask 077 REPO_ROOT="$(cd "$(/usr/bin/dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" LOCAL_CONFIG_JSON="$REPO_ROOT/.local/config.json" MODE="${1:-build}" +[[ $# -le 1 ]] || { echo "usage: scripts/rebuild [build|preview|switch]" >&2; exit 2; } +case "$MODE" in + build|preview|switch) ;; + -h|--help) echo "usage: scripts/rebuild [build|preview|switch]"; exit 0 ;; + *) echo "usage: scripts/rebuild [build|preview|switch]" >&2; exit 2 ;; +esac # shellcheck source=scripts/trusted-path source "$REPO_ROOT/scripts/trusted-path" mac_setup_harden_environment @@ -60,10 +66,13 @@ assert_snapshot_unchanged() { } case "$MODE" in - build) + build|preview) assert_snapshot_unchanged MAC_SETUP_LOCAL_CONFIG="$LOCAL_CONFIG_SNAPSHOT" \ "$NIX_BIN" build --impure "$FLAKE_REF#darwinConfigurations.mini.system" + if [[ "$MODE" == preview ]]; then + "$REPO_ROOT/scripts/preview-system" "$REPO_ROOT/result" + fi ;; switch) assert_snapshot_unchanged @@ -73,9 +82,10 @@ case "$MODE" in /usr/bin/sudo -H /usr/bin/env MAC_SETUP_LOCAL_CONFIG="$LOCAL_CONFIG_SNAPSHOT" \ "$NIX_BIN" run --impure "$FLAKE_REF#darwin-rebuild" -- \ switch --impure --flake "$FLAKE_REF#mini" + "$REPO_ROOT/scripts/register-checkout" ;; *) - echo "usage: scripts/rebuild [build|switch]" >&2 + echo "usage: scripts/rebuild [build|preview|switch]" >&2 exit 2 ;; esac diff --git a/scripts/register-checkout b/scripts/register-checkout new file mode 100755 index 0000000..1d13683 --- /dev/null +++ b/scripts/register-checkout @@ -0,0 +1,33 @@ +#!/bin/bash -p + +set -euo pipefail +umask 077 + +REPO_ROOT="$(cd "$(/usr/bin/dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" +state_dir="$HOME/Library/Application Support/mac-setup" +for directory in "$HOME" "$HOME/Library" "$HOME/Library/Application Support" "$state_dir"; do + if [[ -e "$directory" || -L "$directory" ]]; then + [[ -d "$directory" && ! -L "$directory" && -O "$directory" ]] || { + echo "checkout record directory is unsafe" >&2 + exit 1 + } + else + /bin/mkdir -m 700 "$directory" + fi +done +state_dir="$(cd "$state_dir" && pwd -P)" +case "$state_dir/" in + "$REPO_ROOT/"*) echo "checkout records must remain outside the repository" >&2; exit 1 ;; +esac +/bin/chmod 700 "$state_dir" +record="$state_dir/checkout" +[[ ! -e "$record" && ! -L "$record" || -f "$record" && ! -L "$record" && -O "$record" ]] || { + echo "checkout record is unsafe" >&2 + exit 1 +} +[[ "$REPO_ROOT" != *$'\n'* ]] || exit 1 +temporary_record="$(/usr/bin/mktemp "$state_dir/.checkout.XXXXXX")" +trap '/bin/rm -f -- "$temporary_record"' EXIT +printf '%s\n' "$REPO_ROOT" >"$temporary_record" +/bin/chmod 600 "$temporary_record" +/bin/mv "$temporary_record" "$record" diff --git a/scripts/run-with-validation-tools b/scripts/run-with-validation-tools index 9f7a954..17591c2 100755 --- a/scripts/run-with-validation-tools +++ b/scripts/run-with-validation-tools @@ -68,7 +68,7 @@ if ! command -v bash >/dev/null 2>&1 || \ ! bash -c '(( BASH_VERSINFO[0] >= 4 ))'; then missing_tools+=("bash>=4") fi -for command_name in fish gitleaks jq plutil rg shellcheck; do +for command_name in actionlint fish gitleaks jq plutil rg shellcheck; do command -v "$command_name" >/dev/null 2>&1 || \ missing_tools+=("$command_name") done @@ -78,6 +78,6 @@ fi read_locked_nixpkgs NIX_BIN="$(mac_setup_trusted_nix)" -validation_expression="let pkgs = import (builtins.fetchTarball { url = \"https://github.com/NixOS/nixpkgs/archive/$LOCKED_NIXPKGS_REV.tar.gz\"; sha256 = \"$LOCKED_NIXPKGS_HASH\"; }) { system = \"aarch64-darwin\"; }; in with pkgs; [ bash coreutils fish gitleaks jq ripgrep shellcheck ]" +validation_expression="let pkgs = import (builtins.fetchTarball { url = \"https://github.com/NixOS/nixpkgs/archive/$LOCKED_NIXPKGS_REV.tar.gz\"; sha256 = \"$LOCKED_NIXPKGS_HASH\"; }) { system = \"aarch64-darwin\"; }; in with pkgs; [ actionlint bash coreutils fish gitleaks jq ripgrep shellcheck ]" exec "$NIX_BIN" shell --impure --expr "$validation_expression" --command "$@" diff --git a/scripts/test-app-inventory b/scripts/test-app-inventory index d4348da..48d6471 100755 --- a/scripts/test-app-inventory +++ b/scripts/test-app-inventory @@ -5,7 +5,7 @@ set -euo pipefail REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" PROFILE_FILES=("$REPO_ROOT"/profiles/*.nix) HOMEBREW_MODULE="$REPO_ROOT/modules/darwin/homebrew.nix" -NIX_PACKAGE_MODULE="$REPO_ROOT/modules/home/packages.nix" +NIX_PACKAGE_MODULE="$REPO_ROOT/modules/home/development.nix" HOMEBREW_DRY_RUN="$REPO_ROOT/scripts/homebrew-dry-run" for unwanted_declaration in \ diff --git a/scripts/test-filen-menubar-update b/scripts/test-filen-menubar-update index cdc355f..0c5f41c 100755 --- a/scripts/test-filen-menubar-update +++ b/scripts/test-filen-menubar-update @@ -25,7 +25,7 @@ IFS= read -r updater_shebang <"$UPDATER" 'https://api.github.com/repos/philippgerard/filen-menubar/releases/latest' \ "$UPDATER" # shellcheck disable=SC2016 # Match the literal delegated update command. -/usr/bin/grep -Fq '"$REPO_ROOT/scripts/update-filen-menubar"' "$UPDATE_SCRIPT" +/usr/bin/grep -Fq '"$candidate/scripts/update-filen-menubar"' "$UPDATE_SCRIPT" if /usr/bin/grep -Eq \ '(^|[^/[:alnum:]_])(curl|mktemp)([[:space:]]|$)' "$UPDATER"; then echo "Filen Menubar updater resolves a sensitive command through PATH" >&2 diff --git a/scripts/test-finish-setup b/scripts/test-finish-setup index a7285a2..30b697f 100755 --- a/scripts/test-finish-setup +++ b/scripts/test-finish-setup @@ -492,7 +492,7 @@ grep -Fq 'mac_setup_harden_environment' "$UPDATE_SCRIPT" # shellcheck disable=SC2016 # Match the literal absolute command invocations. grep -Fq 'work_dir="$(/usr/bin/mktemp -d ' "$UPDATE_SCRIPT" # shellcheck disable=SC2016 # Match the literal absolute command invocations. -grep -Fq 'lock_tmp="$(/usr/bin/mktemp ' "$UPDATE_SCRIPT" +grep -Fq 'temporary_pin="$(/usr/bin/mktemp ' "$REPO_ROOT/scripts/promote-update" if grep -Eq '(^|[^/[:alnum:]_])mktemp([[:space:]]|$)' "$UPDATE_SCRIPT"; then echo "dependency update script resolves mktemp through PATH" >&2 exit 1 diff --git a/scripts/test-update-transaction b/scripts/test-update-transaction new file mode 100755 index 0000000..9ebe0e3 --- /dev/null +++ b/scripts/test-update-transaction @@ -0,0 +1,155 @@ +#!/bin/bash + +set -euo pipefail +umask 077 + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +test_root="$(/usr/bin/mktemp -d "${TMPDIR:-/tmp}/mac-setup-transaction-test.XXXXXX")" +trap '/bin/chmod -R u+w "$test_root"; /bin/rm -rf -- "$test_root"' EXIT +fixture="$test_root/repository" +baseline="$test_root/baseline" +candidate="$test_root/candidate" +pins=(flake.lock modules/home/filen-menubar-release.json modules/home/oh-my-claudecode-release.json) +for directory in "$fixture" "$baseline" "$candidate"; do + /bin/mkdir -p "$directory/modules/home" +done +/bin/mkdir -p "$fixture/scripts" "$fixture/.local" +/bin/cp "$REPO_ROOT/scripts/promote-update" "$fixture/scripts/" +/bin/chmod 700 "$fixture/scripts/promote-update" +for pin in "${pins[@]}"; do + printf 'old\n' >"$baseline/$pin" + printf 'new\n' >"$candidate/$pin" + /bin/cp "$baseline/$pin" "$fixture/$pin" +done + +"$fixture/scripts/promote-update" "$baseline" "$candidate" +for pin in "${pins[@]}"; do + /usr/bin/cmp "$candidate/$pin" "$fixture/$pin" + /bin/cp "$baseline/$pin" "$fixture/$pin" +done + +# Fail the second replacement after the first succeeds; the first must roll back. +/bin/chmod 500 "$fixture/modules/home" +if "$fixture/scripts/promote-update" "$baseline" "$candidate" >"$test_root/error" 2>&1; then + echo "promotion unexpectedly wrote a non-writable directory" >&2; exit 1 +fi +/bin/chmod 700 "$fixture/modules/home" +for pin in "${pins[@]}"; do /usr/bin/cmp "$baseline/$pin" "$fixture/$pin"; done +[[ -z "$(/usr/bin/find "$fixture/.local" -mindepth 1 -print -quit)" ]] + +printf 'concurrent edit\n' >"$fixture/flake.lock" +if "$fixture/scripts/promote-update" "$baseline" "$candidate" >"$test_root/error" 2>&1; then + echo "promotion accepted a concurrent edit" >&2; exit 1 +fi +/usr/bin/grep -Fxq 'concurrent edit' "$fixture/flake.lock" +/usr/bin/cmp "$baseline/modules/home/filen-menubar-release.json" "$fixture/modules/home/filen-menubar-release.json" + +# A failing updater must never reach promotion, and only the candidate may change. +workflow="$test_root/workflow" +/bin/mkdir -p "$workflow/scripts" "$workflow/.local" "$workflow/modules/home" +/bin/cp "$REPO_ROOT/scripts/update" "$REPO_ROOT/scripts/trusted-path" \ + "$REPO_ROOT/scripts/trusted-git" "$REPO_ROOT/scripts/promote-update" "$workflow/scripts/" +printf 'private fixture\n' >"$workflow/.local/config.json" +printf '.local/\n' >"$workflow/.gitignore" +printf 'old\n' >"$workflow/flake.lock" +printf 'unchanged\n' >"$workflow/source-marker" +for pin in "${pins[@]}"; do printf 'old\n' >"$workflow/$pin"; done +cat >"$workflow/scripts/validate-local-config" <<'EOF' +#!/bin/bash +exit 0 +EOF +cat >"$workflow/scripts/trusted-nix" <<'EOF' +#!/bin/bash +mac_setup_trusted_nix() { echo "$UPDATE_TEST_NIX"; } +EOF +cat >"$workflow/scripts/flake-source" <<'EOF' +#!/bin/bash +set -euo pipefail +root="$(cd "$(dirname "$0")/.." && pwd)" +[[ "$1" == --copy-to ]] +while IFS= read -r path; do + /bin/mkdir -p "$(dirname "$2/$path")" + /bin/cp -p "$root/$path" "$2/$path" +done < <(/usr/bin/git -C "$root" ls-files) +EOF +cat >"$workflow/scripts/update-filen-menubar" <<'EOF' +#!/bin/bash +root="$(cd "$(dirname "$0")/.." && pwd)" +printf 'new\n' >"$root/modules/home/filen-menubar-release.json" +[[ "$UPDATE_TEST_STAGE" != filen ]] +EOF +cat >"$workflow/scripts/update-oh-my-claudecode" <<'EOF' +#!/bin/bash +root="$(cd "$(dirname "$0")/.." && pwd)" +printf 'new\n' >"$root/modules/home/oh-my-claudecode-release.json" +[[ "$UPDATE_TEST_STAGE" != omc ]] +EOF +cat >"$workflow/scripts/rebuild" <<'EOF' +#!/bin/bash +[[ "$1" == build ]] || exit 99 +printf 'build\n' >>"$UPDATE_TEST_TRACE" +if [[ "$UPDATE_TEST_STAGE" == concurrent ]]; then + printf 'concurrent edit\n' >"$UPDATE_TEST_ORIGINAL/source-marker" +fi +if [[ "$UPDATE_TEST_STAGE" == concurrent-mode ]]; then + /bin/chmod 600 "$UPDATE_TEST_ORIGINAL/scripts/rebuild" +fi +[[ "$UPDATE_TEST_STAGE" != build ]] +EOF +cat >"$workflow/scripts/preview-system" <<'EOF' +#!/bin/bash +exit 0 +EOF +cat >"$test_root/nix" <<'EOF' +#!/bin/bash +set -euo pipefail +if [[ "$1 $2" == 'store add-path' ]]; then + [[ $# -eq 5 && "$3 $4" == '--name source' ]] || exit 99 + cd "$5" + while IFS= read -r path; do + /usr/bin/stat -f '%Lp' "$path" + /usr/bin/shasum -a 256 "$path" + done < <(/usr/bin/find . -type f | LC_ALL=C /usr/bin/sort) | /usr/bin/shasum -a 256 + exit 0 +fi +[[ "$*" == 'flake update' ]] || exit 99 +printf 'new\n' >flake.lock +[[ "$UPDATE_TEST_STAGE" != nix ]] +EOF +/bin/chmod 700 "$test_root/nix" +/bin/chmod 700 "$workflow/scripts/"* +/usr/bin/git -C "$workflow" init -q +/usr/bin/git -C "$workflow" add --all +fixture_domain=example.invalid +/usr/bin/git -C "$workflow" -c user.name=Fixture -c user.email="fixture@$fixture_domain" \ + -c commit.gpgsign=false -c core.hooksPath=/dev/null commit -qm fixture +export UPDATE_TEST_NIX="$test_root/nix" +export UPDATE_TEST_TRACE="$test_root/builds" +export UPDATE_TEST_ORIGINAL="$workflow" +for UPDATE_TEST_STAGE in filen nix omc build concurrent concurrent-mode; do + export UPDATE_TEST_STAGE + : >"$UPDATE_TEST_TRACE" + if "$workflow/scripts/update" >"$test_root/error" 2>&1; then + echo "failed candidate stage unexpectedly succeeded: $UPDATE_TEST_STAGE" >&2; exit 1 + fi + for pin in "${pins[@]}"; do /usr/bin/grep -Fxq old "$workflow/$pin"; done + [[ ! -e "$workflow/.local/update.lock" ]] + if [[ "$UPDATE_TEST_STAGE" == concurrent ]]; then + /usr/bin/grep -Fxq 'concurrent edit' "$workflow/source-marker" + printf 'unchanged\n' >"$workflow/source-marker" + fi + if [[ "$UPDATE_TEST_STAGE" == concurrent-mode ]]; then + [[ "$(/usr/bin/stat -f '%Lp' "$workflow/scripts/rebuild")" == 600 ]] + /bin/chmod 700 "$workflow/scripts/rebuild" + fi + [[ -z "$(/usr/bin/git -C "$workflow" status --porcelain)" ]] +done +: >"$UPDATE_TEST_TRACE" +UPDATE_TEST_STAGE=success "$workflow/scripts/update" >"$test_root/success" 2>&1 || { + /bin/cat "$test_root/success" >&2 + exit 1 +} +for pin in "${pins[@]}"; do /usr/bin/grep -Fxq new "$workflow/$pin"; done +[[ "$(/usr/bin/wc -l <"$UPDATE_TEST_TRACE" | /usr/bin/tr -d ' ')" == 1 ]] + +echo "candidate isolation, promotion rollback, and concurrent edit tests passed" diff --git a/scripts/test-workflow-helpers b/scripts/test-workflow-helpers new file mode 100755 index 0000000..e7a5a48 --- /dev/null +++ b/scripts/test-workflow-helpers @@ -0,0 +1,73 @@ +#!/bin/bash + +set -euo pipefail +umask 077 + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +test_root="$(/usr/bin/mktemp -d "${TMPDIR:-/tmp}/mac-setup-workflow-test.XXXXXX")" +trap '/bin/chmod -R u+w "$test_root"; /bin/rm -rf -- "$test_root"' EXIT + +# Exercise the actual association helper without changing Launch Services. +cat >"$test_root/duti" <<'EOF' +#!/bin/bash +set -eu +printf '%s\n' "$*" >>"$DUTI_TRACE" +if [[ "$1" == -d ]]; then echo io.appmakes.otty; fi +if [[ "$*" == '-s io.appmakes.otty ssh' && "${DUTI_FAIL:-0}" == 1 ]]; then exit 9; fi +EOF +/bin/chmod 700 "$test_root/duti" +DUTI_TRACE="$test_root/trace" "$REPO_ROOT/scripts/configure-otty-handlers" "$test_root/duti" +/usr/bin/grep -Fxq -- '-s io.appmakes.otty ssh' "$test_root/trace" +if /usr/bin/grep -Fq -- 'ssh all' "$test_root/trace"; then exit 1; fi +if DUTI_TRACE="$test_root/trace" DUTI_FAIL=1 \ + "$REPO_ROOT/scripts/configure-otty-handlers" "$test_root/duti"; then + echo "Otty helper concealed an association failure" >&2 + exit 1 +fi + +# Register and resolve a non-default checkout, including paths with spaces. +task_home="$test_root/home" +checkout="$test_root/custom checkout" +/bin/mkdir -p "$task_home" "$checkout/scripts" +checkout="$(cd "$checkout" && pwd -P)" +/bin/cp "$REPO_ROOT/scripts/register-checkout" "$checkout/scripts/" +: >"$checkout/flake.nix" +cat >"$checkout/scripts/rebuild" <<'EOF' +#!/bin/bash +printf '%s\n' "$PWD" "$@" +EOF +/bin/chmod 700 "$checkout/scripts/"* +/usr/bin/env HOME="$task_home" "$checkout/scripts/register-checkout" +record="$task_home/Library/Application Support/mac-setup/checkout" +[[ "$(/bin/cat "$record")" == "$checkout" ]] +[[ "$(/usr/bin/stat -f '%Lp' "$record")" == 600 ]] +/usr/bin/env HOME="$task_home" "$REPO_ROOT/scripts/mac-setup" rebuild preview >"$test_root/launcher" +/usr/bin/grep -Fxq preview "$test_root/launcher" +/bin/chmod 644 "$record" +if /usr/bin/env HOME="$task_home" "$REPO_ROOT/scripts/mac-setup" rebuild build >/dev/null 2>&1; then + echo "launcher accepted an unsafe checkout record" >&2; exit 1 +fi +/usr/bin/env HOME="$task_home" MAC_SETUP_CONFIG_DIR="$checkout" \ + "$REPO_ROOT/scripts/mac-setup" rebuild build >"$test_root/launcher" +/usr/bin/grep -Fxq build "$test_root/launcher" + +# Doctor must report failures and explicit skips without touching private state. +/bin/mkdir -p "$task_home/.config/git" +for name in identity.inc public-identity.inc allowed_signers; do + printf 'fixture\n' >"$task_home/.config/git/$name" +done +/usr/bin/find "$task_home" -type f -exec /usr/bin/shasum -a 256 {} \; >"$test_root/before" +/usr/bin/env HOME="$task_home" "$REPO_ROOT/scripts/doctor" --only git --json >"$test_root/doctor" +jq -e '.summary.failed == 0 and .summary.passed == 3 and .summary.skipped > 0' "$test_root/doctor" >/dev/null +/usr/bin/find "$task_home" -type f -exec /usr/bin/shasum -a 256 {} \; >"$test_root/after" +/usr/bin/cmp "$test_root/before" "$test_root/after" +/bin/chmod 644 "$task_home/.config/git/identity.inc" +if /usr/bin/env HOME="$task_home" "$REPO_ROOT/scripts/doctor" --only git --json >"$test_root/doctor"; then + echo "doctor accepted unsafe identity permissions" >&2; exit 1 +fi +jq -e '.summary.failed == 1 and ([.checks[] | select(.status == "FAIL")][0].action | length > 0)' "$test_root/doctor" >/dev/null +/usr/bin/env HOME="$task_home" "$REPO_ROOT/scripts/doctor" --only git --skip git --json >"$test_root/doctor" +jq -e '.summary.failed == 0 and .summary.passed == 0' "$test_root/doctor" >/dev/null +if "$REPO_ROOT/scripts/doctor" --skip misspelled >/dev/null 2>&1; then exit 1; fi + +echo "Otty, checkout launcher, and read-only doctor tests passed" diff --git a/scripts/update b/scripts/update index df91a15..932c777 100755 --- a/scripts/update +++ b/scripts/update @@ -4,57 +4,82 @@ set -euo pipefail umask 077 REPO_ROOT="$(cd "$(/usr/bin/dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" +[[ $# -eq 0 ]] || { echo "usage: scripts/update" >&2; exit 2; } # shellcheck source=scripts/trusted-path source "$REPO_ROOT/scripts/trusted-path" mac_setup_harden_environment # shellcheck source=scripts/trusted-nix source "$REPO_ROOT/scripts/trusted-nix" +# shellcheck source=scripts/trusted-git +source "$REPO_ROOT/scripts/trusted-git" NIX_BIN="$(mac_setup_trusted_nix)" - -"$REPO_ROOT/scripts/update-filen-menubar" - -temp_base="${TMPDIR:-/tmp}" -temp_base="${temp_base%/}" -work_dir="$(/usr/bin/mktemp -d "$temp_base/mac-setup-update.XXXXXX")" -source_dir="$work_dir/source" -/bin/mkdir -m 700 "$source_dir" -lock_tmp="" +local_config="$REPO_ROOT/.local/config.json" +"$REPO_ROOT/scripts/validate-local-config" "$local_config" "$(/usr/bin/id -un)" "$HOME" mini >/dev/null +[[ -d "$REPO_ROOT/.local" && ! -L "$REPO_ROOT/.local" && -O "$REPO_ROOT/.local" ]] || { + echo "private update-state directory is unsafe" >&2 + exit 1 +} +lock_dir="$REPO_ROOT/.local/update.lock" +/bin/mkdir -m 700 "$lock_dir" 2>/dev/null || { + echo "An update lock exists. Check for an active update before removing .local/update.lock." >&2 + exit 1 +} +work_dir="" cleanup() { - if [[ -n "${lock_tmp:-}" && -f "$lock_tmp" && ! -L "$lock_tmp" ]]; then - /bin/rm -f -- "$lock_tmp" >/dev/null 2>&1 || true - fi - if [[ -n "${work_dir:-}" && -d "$work_dir" && ! -L "$work_dir" && \ - "$work_dir" == "$temp_base"/mac-setup-update.* ]]; then - /bin/rm -rf -- "$work_dir" >/dev/null 2>&1 || true + if [[ -n "$work_dir" && -d "$work_dir" && ! -L "$work_dir" ]]; then + /bin/rm -rf -- "$work_dir" fi + /bin/rmdir "$lock_dir" 2>/dev/null || true } trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM +trap 'exit 129' HUP +work_dir="$(/usr/bin/mktemp -d "${TMPDIR:-/tmp}/mac-setup-update.XXXXXX")" +baseline="$work_dir/baseline" +candidate="$work_dir/candidate" +lock_source="$work_dir/lock-source" +current_source="$work_dir/current" +/bin/mkdir -m 700 "$baseline" "$candidate" "$lock_source" "$current_source" +"$REPO_ROOT/scripts/flake-source" --copy-to "$baseline" >/dev/null +baseline_source="$("$NIX_BIN" store add-path --name source "$baseline")" +/bin/cp -R "$baseline/." "$candidate/" -"$REPO_ROOT/scripts/flake-source" --copy-to "$source_dir" >/dev/null -( - cd "$source_dir" - "$NIX_BIN" flake update -) +# Preserve history for its scan without changing the caller's index or branches. +# Every Nix source is still produced by scripts/flake-source. +mac_setup_safe_git "$candidate" init -q +mac_setup_safe_git "$candidate" fetch -q --no-tags "$REPO_ROOT" HEAD +mac_setup_safe_git "$candidate" update-ref refs/heads/candidate FETCH_HEAD +mac_setup_safe_git "$candidate" symbolic-ref HEAD refs/heads/candidate +mac_setup_safe_git "$candidate" add --all +/bin/mkdir -m 700 "$candidate/.local" +/bin/cp "$local_config" "$candidate/.local/config.json" +/bin/chmod 600 "$candidate/.local/config.json" -[[ -f "$source_dir/flake.lock" && ! -L "$source_dir/flake.lock" ]] || { - echo "Nix did not produce a safe updated lock file" >&2 +echo "Preparing dependency updates in a private candidate checkout..." +"$candidate/scripts/update-filen-menubar" +mac_setup_safe_git "$candidate" add --all +"$candidate/scripts/flake-source" --copy-to "$lock_source" >/dev/null +(cd "$lock_source" && "$NIX_BIN" flake update) +/bin/cp "$lock_source/flake.lock" "$candidate/flake.lock" +mac_setup_safe_git "$candidate" add --all +"$candidate/scripts/update-oh-my-claudecode" +mac_setup_safe_git "$candidate" add --all + +# Rebuild owns one full validation pass and one validated host snapshot. +"$candidate/scripts/rebuild" build +"$candidate/scripts/preview-system" "$candidate/result" + +# Refuse a candidate if public files or host selectors changed during the build. +"$REPO_ROOT/scripts/flake-source" --copy-to "$current_source" >/dev/null +[[ "$("$NIX_BIN" store add-path --name source "$current_source")" == "$baseline_source" ]] || { + echo "The checkout changed during the update; its files were left untouched." >&2 exit 1 } -[[ -f "$REPO_ROOT/flake.lock" && ! -L "$REPO_ROOT/flake.lock" && \ - -O "$REPO_ROOT/flake.lock" ]] || { - echo "repository lock file is unsafe" >&2 +/usr/bin/cmp -s "$local_config" "$candidate/.local/config.json" || { + echo "Local host selectors changed during the update; its pins were left untouched." >&2 exit 1 } - -lock_tmp="$(/usr/bin/mktemp "$REPO_ROOT/.flake.lock.XXXXXX")" -/bin/cp "$source_dir/flake.lock" "$lock_tmp" -/bin/chmod 644 "$lock_tmp" -/bin/mv "$lock_tmp" "$REPO_ROOT/flake.lock" -lock_tmp="" - -"$REPO_ROOT/scripts/update-oh-my-claudecode" - -"$REPO_ROOT/scripts/validate" -"$REPO_ROOT/scripts/rebuild" build - -echo "Dependencies and release pins were updated; review and commit the diff before switching." +"$REPO_ROOT/scripts/promote-update" "$baseline" "$candidate" +echo "The candidate passed validation and build. Review the three pin files before activation." +echo "If those files were already staged, restage the reviewed updates before rebuilding." diff --git a/scripts/validate b/scripts/validate index 84da4e0..c408b0b 100755 --- a/scripts/validate +++ b/scripts/validate @@ -16,7 +16,7 @@ if ! command -v bash >/dev/null 2>&1 || \ ! bash -c '(( BASH_VERSINFO[0] >= 4 ))'; then missing_validation_tools+=("bash>=4") fi -for command_name in fish gitleaks jq plutil rg shellcheck; do +for command_name in actionlint fish gitleaks jq plutil rg shellcheck; do command -v "$command_name" >/dev/null 2>&1 || \ missing_validation_tools+=("$command_name") done @@ -37,6 +37,7 @@ for script in setup.sh scripts/*; do done shellcheck "${shell_scripts[@]}" +actionlint .github/workflows/*.yml bash "$REPO_ROOT/scripts/check-index-worktree-parity" bash "$REPO_ROOT/scripts/check-public-safety" @@ -69,6 +70,8 @@ bash "$REPO_ROOT/scripts/test-ssh-private-state" bash "$REPO_ROOT/scripts/test-trusted-nix" bash "$REPO_ROOT/scripts/test-validation-tool-bootstrap" bash "$REPO_ROOT/scripts/test-zed-config" +bash "$REPO_ROOT/scripts/test-workflow-helpers" +bash "$REPO_ROOT/scripts/test-update-transaction" [[ -f flake.lock ]] || { echo "flake.lock is required" >&2 diff --git a/setup.sh b/setup.sh index 5f512f8..dfc4379 100755 --- a/setup.sh +++ b/setup.sh @@ -829,6 +829,7 @@ if [[ "$APPLY" -eq 1 ]]; then else "$CONFIG_DIR/scripts/rebuild" build || \ die "The configuration did not build successfully." + "$CONFIG_DIR/scripts/register-checkout" info "Build succeeded. After review, re-run with --provision for the regular fresh-machine flow." fi