diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..807262d --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,16 @@ +## Summary + + + +## Evidence + +- **Before:** + **After:** +- [ ] `bash scripts/check.sh` passes; CI covers Linux and macOS. +- [ ] README, CHANGELOG, VERSION, and relevant lessons in learnings/dump.md align. +- [ ] Hardware/manual checks and limitations are recorded when applicable. + +## Merge Danger + +**Door:** +**Blast Radius:** diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..4f66673 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,46 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + workflow_dispatch: + workflow_call: + +permissions: + contents: read + +jobs: + regression: + name: Regression (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + runs-on: ${{ matrix.os }} + timeout-minutes: 15 + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - name: Install Linux test dependencies + if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install -y jq zsh + - name: Run syntax, documentation, release, and regression checks + shell: bash + run: /bin/bash scripts/check.sh + + lint: + name: Shell and workflow lint + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - name: Install linters + run: sudo apt-get update && sudo apt-get install -y shellcheck zsh + - name: Lint shell scripts + run: python3 scripts/check-repo.py --lint + - name: Install pinned actionlint + env: + GOBIN: ${{ runner.temp }}/actionlint-bin + run: go install github.com/rhysd/actionlint/cmd/actionlint@v1.7.7 + - name: Validate GitHub workflows + run: '"$RUNNER_TEMP/actionlint-bin/actionlint" -color' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..9dec630 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,60 @@ +name: Release + +on: + push: + tags: ['v*'] + workflow_dispatch: + inputs: + version: + description: Stable SemVer tag matching VERSION (for example v0.1.0) + required: true + default: v0.1.0 + type: string + +permissions: + contents: read + +concurrency: + group: release-${{ github.event.inputs.version || github.ref_name }} + cancel-in-progress: false + +jobs: + checks: + uses: ./.github/workflows/ci.yml + + publish: + name: Publish validated release + needs: checks + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: write + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 0 + - name: Validate version, notes, and main ancestry + env: + RELEASE_TAG: ${{ github.event.inputs.version || github.ref_name }} + run: | + if [[ "$GITHUB_EVENT_NAME" == workflow_dispatch && "$GITHUB_REF" != refs/heads/main ]]; then + echo 'Dispatch releases from main only.' >&2 + exit 1 + fi + git fetch origin main + git merge-base --is-ancestor "$GITHUB_SHA" origin/main + python3 scripts/release.py --tag "$RELEASE_TAG" --notes "$RUNNER_TEMP/release-notes.md" + if git show-ref --verify --quiet "refs/tags/$RELEASE_TAG"; then + test "$(git rev-parse "$RELEASE_TAG^{commit}")" = "$GITHUB_SHA" + fi + - name: Publish GitHub release (existing releases are never replaced) + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + RELEASE_TAG: ${{ github.event.inputs.version || github.ref_name }} + run: | + gh release create "$RELEASE_TAG" \ + --target "$GITHUB_SHA" \ + --title "$RELEASE_TAG" \ + --notes-file "$RUNNER_TEMP/release-notes.md" \ + --latest diff --git a/.gitignore b/.gitignore index 28bb66a..b115e90 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,8 @@ Thumbs.db *.tmp *.bak *.backup +__pycache__/ +*.pyc # Local planning/PR notes docs/plans/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..b40cd98 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,15 @@ +# Repository workflow + +Before changing code or documentation, read [learnings/dump.md](learnings/dump.md), +[README.md](README.md), and the latest entries in [CHANGELOG.md](CHANGELOG.md). +Use the lessons relevant to the files being changed. + +- Add a regression test for changed behavior. Run `bash scripts/check.sh` before + submitting; report any checks or hardware verification you could not perform. +- Keep README usage, CHANGELOG user-visible changes, and VERSION aligned in the + same PR. Released changelog entries are historical; add new work to Unreleased. +- Update `learnings/dump.md` when a failure reveals a reusable lesson: record the + symptom, cause, prevention, and test or evidence. Revise obsolete guidance. +- Keep macOS scripts compatible with system Bash 3.2. Test authentication using + mocks; privileged operations and package updates require explicit human testing. +- Follow the release procedure in README; publish only validated main commits. diff --git a/CHANGELOG.md b/CHANGELOG.md index b513ef7..cf55790 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,39 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.1.0] - 2026-10-19 + +### Added +- Opt-in `update-all --setup-touchid` using the local `mo` helper, with active PAM + checks, repeat-safe setup, and verification; package updates are not run by setup. +- Foreground macOS sudo validation before interactive Homebrew updates, reusing + cached credentials and native Touch ID/password fallback; `--no-auth` opt-out. +- Linux/macOS PR and main CI: shell syntax, ShellCheck errors, workflow lint, + maintained documentation links, release metadata, and existing/new regressions. +- Mocked authentication/setup, package-manager isolation, cask-ignore, timeout, + and release validation tests; shared `scripts/check.sh` runner. +- Stable SemVer `VERSION` and validated main-only GitHub release workflow for + `v0.1.0`, using the changelog for notes and read-only PR workflow permissions. +- Living `learnings/dump.md`, root agent guidance, and PR documentation checklist. +- Main branch protection requiring all three CI checks and up-to-date branches, + including administrator enforcement (configured in GitHub, not by workflows). + +### Fixed +- Update-all's test sandbox intercepts the actual `cargo install-update` dispatcher, + isolates inherited Cargo/Rustup directories, and tests a deliberately broken + source guard without allowing real package updates. + +### Changed +- Sourcing `scripts/update-all.sh` now exposes functions without starting updates. +- Authentication failure stops updates before they start. CI, noninteractive + sessions, non-macOS systems, and non-Homebrew-only modes skip upfront sudo. +- README documents setup, password fallback, limitations, rollback, regression + checks, branch protection requirements, and the post-merge release procedure. +- Physical Mac acceptance recorded from the repository owner: Touch ID succeeded + without a password prompt; release authorized on 2026-10-19. +- This initial SemVer release includes the previously unreleased changes below; + older dated entries are retained as historical notes. + ### Changed - Skills consolidated under a top-level `skills/` directory (#129) - Moved `project-bootstrap` and `code-review-checklist` from `ai-prompts/skills/` to `skills/` diff --git a/README.md b/README.md index cfa860d..6a8bd53 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ A modular, maintainable approach to managing shell configurations and development environment across multiple macOS machines. +**Current version: 0.1.0** — see [CHANGELOG.md](CHANGELOG.md) for release notes. + ## Overview This repository contains my personal dotfiles configuration, designed with modularity and maintainability in mind. The configuration is split into logical components, making it easy to understand, modify, and sync across different workstations. @@ -14,6 +16,7 @@ This repository contains my personal dotfiles configuration, designed with modul - **Cross-Machine Sync**: Easy synchronization between personal and work computers - **Automated Setup**: One-command installation for new machines - **Package Management**: Centralized Homebrew package management via Brewfile +- **Touch ID for Updates**: Opt-in macOS sudo setup, with native password fallback - **Version Control**: All configurations tracked in Git for easy rollback and history - **Oh My Zsh Integration**: Pre-configured with useful plugins and Powerlevel10k theme - **Neovim Configuration**: Modern IDE-like setup with LSP, Treesitter, and productivity plugins @@ -397,6 +400,42 @@ brew bundle dump --force --file=~/dotfiles/Brewfile ~/dotfiles/scripts/update-all.sh --ai-tools-only ``` +#### Touch ID and password authentication (macOS) + +Enable sudo Touch ID once from your terminal: + +```bash +~/dotfiles/scripts/update-all.sh --setup-touchid +``` + +Authorize setup when prompted; macOS may accept Touch ID or request your password. +The command checks existing configuration, enables Touch ID, and verifies the +result without updating packages. +Repeating setup leaves Touch ID enabled. It requires a current local `mo` helper; +missing-helper errors include setup guidance. This is opt-in because it +changes system-wide sudo authentication. + +On subsequent interactive macOS runs that include Homebrew, `update-all` validates +sudo **before** captured or timed installer output. A valid sudo timestamp is +reused; otherwise macOS offers Touch ID when available and retains its normal +password fallback (including unavailable hardware, lockout, or cancelled Touch ID). +No passwords are read or stored by this script, and Homebrew never runs as root. +Without the helper or configured Touch ID, normal password authentication still works. + +`--npm-only`, `--pip-only`, and `--ai-tools-only` do not pre-authenticate. CI and +redirected/noninteractive runs skip the upfront prompt; downstream installers can +still require credentials. Use `--no-auth` to opt out of pre-authentication for a +Homebrew run. No sudo keepalive is installed; long updates may prompt again after +the normal sudo timestamp expires. Authentication failure stops the run before +updates start. + +Touch ID requires supported hardware, enrolled fingerprints, and a compatible +local terminal session. macOS/PAM decides availability; this does not replace +password dialogs owned by apps or guarantee biometrics over SSH, in tmux, or with +a closed laptop lid. Use your password when Touch ID is unavailable. To undo the +system setting, run `mo touchid` and choose Disable; reverting this PR alone does +not undo previously enabled PAM settings. + ### Updating AI/LLM CLI Tools The dotfiles include automated updates for AI CLI tools like `gemini-cli`, `codex`, `orca`, `headroom`, and `claude-code`, as well as the supporting `rtk` and `gh-stack` CLI extensions (see [docs/ai-tools/INSTALLATION.md](docs/ai-tools/INSTALLATION.md) for install/update/uninstall details): @@ -411,8 +450,10 @@ The dotfiles include automated updates for AI CLI tools like `gemini-cli`, `code # Update only AI tools (alternative method) source ~/dotfiles/scripts/update-all.sh && update_ai_tools -# Skip AI tools during system update -UPDATE_AI_TOOLS=false ~/dotfiles/scripts/update-all.sh +# Source without starting updates; disable a manager before calling main +source ~/dotfiles/scripts/update-all.sh +UPDATE_AI_TOOLS=false +main # Manual update of individual tools brew upgrade gemini-cli codex # Formula-based tools @@ -426,6 +467,85 @@ brew upgrade --cask claude-code # Cask-based tools - Integrated with package manager isolation flags - Non-blocking: failures on one tool don't prevent updates to others +## Contributing and regression checks + +Read [AGENTS.md](AGENTS.md) and [learnings/dump.md](learnings/dump.md) before changes. +Keep this README (usage), CHANGELOG (user-visible history), and the lessons (causes +and prevention) current in the same PR. Add behavior tests for each regression. + +```bash +# macOS test dependencies (Linux CI installs jq and zsh with apt) +brew install jq +bash scripts/check.sh + +# Optional local lint; CI always runs it +brew install shellcheck zsh +python3 scripts/check-repo.py --lint +``` + +Python 3, Git, jq, and Zsh are required. The runner checks repository shell syntax, +local links in the four maintained docs, VERSION/README/changelog consistency, +mocked update/authentication and release validation tests, and existing statusline +and skill tests. ShellCheck errors and actionlint workflow validation run in CI. +External links and the entire historical documentation library are not link-checked. +Every PR (including forks) and main push runs Linux and macOS checks with system +`/bin/bash`, including macOS Bash 3.2. Tests never invoke real sudo, setup helpers, +or package updates. Hosted runners cannot verify a physical fingerprint sensor. + +Before merging an authentication change, manually check on a Touch ID Mac: + +1. Run `update-all --setup-touchid` and authorize the change if requested. +2. Run `sudo -k`, then `update-all --brew-only --no-cleanup`; confirm Touch ID is + offered before package operations. **This command performs real updates.** +3. Repeat with Touch ID cancelled/unavailable and confirm password fallback. +4. Repeat setup; confirm it leaves Touch ID enabled. Test `--no-auth` as needed. + +For authentication-only testing without package updates, replace the update +command in steps 2–3 with: + +```bash +bash -c 'source "$HOME/dotfiles/scripts/update-all.sh"; prepare_update_auth' +``` + +Record the tested commit, macOS version, terminal, and each checklist outcome in +the PR. Never include passwords or fingerprint data. Passing mocked/hosted tests +does not satisfy this physical authentication gate. + +The repository's main branch protection requires `Regression (ubuntu-latest)`, +`Regression (macos-latest)`, and `Shell and workflow lint`, with up-to-date branches +and enforcement for administrators. Approval requirements are unchanged. These +are GitHub repository settings, not applied by workflows; configure the same +requirements separately for forks or a replacement repository. + +## Releases and semantic versioning + +`VERSION` is the stable release source of truth (currently `0.1.0`); tags use +`vX.Y.Z`. Before `1.0.0`, minor versions may introduce incompatible changes and +patch versions contain backward-compatible fixes. At/after `1.0.0`, use standard +SemVer: major for breaking changes, minor for features, patch for fixes. + +**0.1.0 acceptance:** On 2026-10-19, the repository owner confirmed local physical +Mac testing: Touch ID authentication succeeded without requesting a password, and +release was authorized. This is owner-reported hardware evidence, separate from +automated CI. See [PR #135](https://github.com/iamgerwin/dotfiles/pull/135) and +[releases](https://github.com/iamgerwin/dotfiles/releases) for the acceptance and +publication records. + +1. Prepare a PR updating VERSION, the current version above, and a dated changelog + section. Keep an Unreleased section for future work. Update relevant lessons. +2. Merge only after PR checks pass and any required hardware verification is done. +3. From **main**, dispatch the Release workflow with `v0.1.0` for this release: + `gh workflow run release.yml --ref main -f version=v0.1.0`. + Alternatively push a `vX.Y.Z` tag pointing to a validated main commit. +4. The pipeline reruns the full CI suite, checks SemVer, matching metadata and + main ancestry, then creates the tag (for dispatch) and GitHub release using + that version's changelog notes. GitHub provides source archives automatically. + +Releases are never published from unmerged PR branches, existing tags must point +to the selected commit, and existing releases are not overwritten. Release jobs +alone receive `contents: write`; PR checks have read-only permissions and no +secrets. Publish tags and releases only after the corresponding PR has merged. + ## Customization ### Machine-Specific Settings diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..6e8bf73 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.1.0 diff --git a/learnings/dump.md b/learnings/dump.md new file mode 100644 index 0000000..e878467 --- /dev/null +++ b/learnings/dump.md @@ -0,0 +1,81 @@ +# Living engineering lessons + +Read this before making changes. Add concrete lessons as they are discovered, +link their regression tests, and revise entries when the behavior changes. +README owns usage and release instructions; CHANGELOG owns user-visible history. + +## Authentication must stay on the foreground terminal + +- **Symptom:** Homebrew cask updates wait for credentials inside captured output + or time out without a visible prompt. +- **Cause:** Authentication starts inside background or redirected commands. +- **Prevention:** Validate sudo before Homebrew updates in an interactive macOS + terminal. Let macOS PAM offer Touch ID and password fallback; keep Homebrew + unprivileged. CI and redirected runs skip upfront authentication. Never collect + passwords in shell variables, disable password fallback, or change PAM silently. +- **Evidence:** `tests/update_all_test.py` covers authentication order, cached + credentials, failure, isolation flags, noninteractive runs, and CI. + +## Touch ID setup is opt-in and idempotent + +- **Symptom:** Repeating setup can turn off working Touch ID. +- **Cause:** `mo touchid` is a toggle, and commented PAM examples are not active + configuration. A sudo_local rule is ineffective without its sudo include. +- **Prevention:** Check active rules and the include before invoking the helper's explicit + `mo touchid enable`, not its bare toggle. Use `update-all --setup-touchid` only + from a terminal; verify setup afterward. The helper + owns privileged configuration and its password prompt; update-all writes no PAM + files. Password fallback and hardware availability remain macOS responsibilities. +- **Evidence:** `tests/update_all_test.py` tests active, commented, missing, and + legacy policies; setup success, cancellation, missing helper, and repeated setup. +- **Documentation:** Lead with this repository's `update-all --setup-touchid`; + mention external helpers only for prerequisites and troubleshooting. + +## Compatibility and release evidence must be reproducible + +- **Symptom:** A check passes on a developer's modern Bash but fails on a Mac. +- **Cause:** macOS ships Bash 3.2, and GNU-only tools differ from BSD tools. +- **Prevention:** Run the same check runner on Linux and macOS using `/bin/bash`. + Add mocked regressions rather than running real installers in CI. Record the + physical Touch ID verification separately; hosted runners have no sensor. +- **Evidence:** `.github/workflows/ci.yml` and `scripts/check.sh`. Release checks + validate VERSION, changelog notes, and main ancestry before creating `vX.Y.Z`. +- **Merge gate:** A workflow alone does not block merging. Keep GitHub branch + protection check names aligned with CI job names; README records the live + requirements. Repository settings survive code rollback and need separate changes. + +## Terminal tests must drain their output + +- **Symptom:** A mocked full update hangs even though all commands finish quickly. +- **Cause:** An unread pseudo-terminal fills its small output buffer and blocks + the foreground process, particularly on macOS. +- **Prevention:** Drain PTY output concurrently and close/reap terminal resources. +- **Evidence:** The full-main authentication-order regression in + `tests/update_all_test.py` exercises this test harness on both CI platforms. + +## Mock the actual dispatcher, not just its plugin executable + +- **Symptom:** A broken source guard could escape the package-update sandbox. +- **Cause:** The updater checks for `cargo-install-update` but executes + `cargo install-update -a`; mocking only the plugin leaves `cargo` reachable. + Inherited `CARGO_HOME`/`RUSTUP_HOME` can also point outside a temporary HOME. +- **Prevention:** Export mocks for both executable names, isolate toolchain homes, + and force the failure path in a throwaway script. Use a harmless PATH decoy to + detect escapes without ever invoking a real installer. +- **Evidence:** `tests/update_all_test.py` verifies unexpected main execution and + inherited toolchain-home isolation. Both regressions failed safely before the + fix and passed afterward. Standards and spec reviews independently found this. + +## Hardware acceptance is separate from CI + +- **Symptom:** Green CI can be mistaken for proof that physical Touch ID works. +- **Cause:** Hosted runners and mocked sudo do not exercise a fingerprint sensor + or native cancellation/password fallback. +- **Prevention:** Record physical Mac checklist results before merging/releasing + authentication changes. Report unverified behavior explicitly; user approval to + run CI is not evidence of a completed hardware check. +- **Evidence:** After initially holding PR #135, the owner confirmed local physical + Mac testing on 2026-10-19: Touch ID succeeded without a password prompt, and + release was authorized. Record this as owner-reported evidence; do not infer + unreported OS/terminal details or individual fallback outcomes. README retains + the checklist and an authentication-only, no-update test command. diff --git a/scripts/check-repo.py b/scripts/check-repo.py new file mode 100755 index 0000000..a7a4e4b --- /dev/null +++ b/scripts/check-repo.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 +"""Check shell syntax, maintained documentation links, and release alignment.""" + +import argparse +from pathlib import Path +import re +import subprocess +from urllib.parse import unquote, urlsplit + +from release import release_notes + + +ROOT = Path(__file__).resolve().parents[1] +MAINTAINED_DOCS = ("README.md", "CHANGELOG.md", "AGENTS.md", "learnings/dump.md") + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--lint", action="store_true", help="also require ShellCheck") + args = parser.parse_args() + paths = subprocess.check_output( + ["git", "ls-files", "--cached", "--others", "--exclude-standard", "-z"], cwd=ROOT + ).decode().split("\0") + bash_files = [] + for name in sorted(set(paths) - {""}): + path = ROOT / name + if not path.is_file() or path.is_symlink(): + continue + if path.suffix == ".zsh" or name == ".zshrc": + subprocess.run(["zsh", "-n", str(path)], check=True) + elif path.suffix == ".sh" or path.read_bytes()[:100].split(b"\n")[0] in ( + b"#!/usr/bin/env bash", b"#!/bin/bash", + ): + subprocess.run(["/bin/bash", "-n", str(path)], check=True) + bash_files.append(name) + if args.lint: + # Legacy scripts have style warnings. Gate errors repository-wide rather + # than hiding them behind excludes or blocking on unrelated reformatting. + subprocess.run(["shellcheck", "--severity=error", *bash_files], cwd=ROOT, check=True) + for name in MAINTAINED_DOCS: + path = ROOT / name + content = path.read_text() + if not content.startswith("# ") or not content.endswith("\n"): + raise ValueError(f"{name}: requires a title and final newline") + for target in re.findall(r"\[[^\]\n]+\]\(([^)\s]+)\)", content): + url = urlsplit(target) + if url.scheme or url.netloc or not url.path: + continue + if not (path.parent / unquote(url.path)).exists(): + raise ValueError(f"{name}: missing local link target {target}") + release_notes(ROOT) + print(f"Syntax checked {len(bash_files)} Bash scripts; maintained docs and release metadata aligned") + + +if __name__ == "__main__": + main() diff --git a/scripts/check.sh b/scripts/check.sh new file mode 100755 index 0000000..a66f5c3 --- /dev/null +++ b/scripts/check.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# Same safe regression checks locally and on both CI platforms. +set -euo pipefail +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" +export PYTHONDONTWRITEBYTECODE=1 +export BASH_UNDER_TEST=/bin/bash + +for tool in python3 jq git zsh; do + command -v "$tool" >/dev/null || { echo "Required test dependency: $tool" >&2; exit 1; } +done + +python3 scripts/check-repo.py "$@" +python3 -m unittest discover -s tests -p '*_test.py' -v +/bin/bash tests/claude/statusline.test.sh +/bin/bash tests/skills/parse-issue-url.test.sh +/bin/bash tests/skills/validate-skills.sh diff --git a/scripts/release.py b/scripts/release.py new file mode 100755 index 0000000..330beba --- /dev/null +++ b/scripts/release.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 +"""Validate release metadata and extract notes without creating a tag/release.""" + +import argparse +from datetime import date +from pathlib import Path +import re + + +ROOT = Path(__file__).resolve().parents[1] +# Stable releases only; GitHub tags use a v prefix, VERSION does not. +SEMVER = re.compile(r"(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)") + + +def release_notes(root, tag=None): + version = (root / "VERSION").read_text().strip() + if not SEMVER.fullmatch(version): + raise ValueError("VERSION must be a stable SemVer X.Y.Z (no leading zeros)") + if tag is not None and tag != f"v{version}": + raise ValueError(f"release tag must match VERSION: v{version}") + changelog = (root / "CHANGELOG.md").read_text() + if "## [Unreleased]" not in changelog: + raise ValueError("CHANGELOG.md must retain an Unreleased section") + entries = re.findall( + rf"^## \[{re.escape(version)}\] - (\d{{4}}-\d{{2}}-\d{{2}})\n(.*?)(?=^## |\Z)", + changelog, re.MULTILINE | re.DOTALL, + ) + if len(entries) != 1: + raise ValueError(f"CHANGELOG.md must have exactly one dated [{version}] entry") + release_date, notes = entries[0] + date.fromisoformat(release_date) + if not re.search(r"^- \S", notes, re.MULTILINE): + raise ValueError("release notes must contain at least one change") + if f"**Current version: {version}**" not in (root / "README.md").read_text(): + raise ValueError("README current version must match VERSION") + return notes.strip() + "\n" + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--tag", help="expected vX.Y.Z tag") + parser.add_argument("--notes", type=Path, help="write validated release notes") + args = parser.parse_args() + try: + notes = release_notes(ROOT, args.tag) + except (ValueError, OSError) as error: + parser.exit(1, f"Release validation failed: {error}\n") + if args.notes: + args.notes.write_text(notes) + print("Release metadata is aligned") + + +if __name__ == "__main__": + main() diff --git a/scripts/update-all.sh b/scripts/update-all.sh index ac9bf1c..c331a17 100755 --- a/scripts/update-all.sh +++ b/scripts/update-all.sh @@ -10,6 +10,8 @@ # --pip-only Only update pip packages # --no-cleanup Skip cleanup operations # --verbose Show detailed output +# --setup-touchid Enable macOS sudo Touch ID via Mole (no updates) +# --no-auth Skip upfront sudo authentication # Allow script to continue on errors but track them set -uo pipefail @@ -39,6 +41,8 @@ UPDATE_TMUX=true UPDATE_NEOVIM=true UPDATE_MACOS=true REMEDIATE_CASKS=true +SETUP_TOUCHID=false +AUTHENTICATE=true # Cask remediation configuration SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -131,6 +135,14 @@ while [[ $# -gt 0 ]]; do VERBOSE=true shift ;; + --setup-touchid) + SETUP_TOUCHID=true + shift + ;; + --no-auth) + AUTHENTICATE=false + shift + ;; --help) echo "Usage: $0 [options]" echo "Options:" @@ -141,6 +153,8 @@ while [[ $# -gt 0 ]]; do echo " --no-cleanup Skip cleanup operations" echo " --cask-no-remediation Skip cask remediation logic" echo " --verbose Show detailed output" + echo " --setup-touchid Enable sudo Touch ID via Mole; do not run updates" + echo " --no-auth Skip upfront sudo authentication" exit 0 ;; *) @@ -215,6 +229,75 @@ command_exists() { command -v "$1" >/dev/null 2>&1 } +# Match active PAM rules only; commented examples do not enable Touch ID. +touchid_configured() { + local sudo_file="${1:-/etc/pam.d/sudo}" + local local_file="${2:-/etc/pam.d/sudo_local}" + local tid_rule='^[[:space:]]*auth[[:space:]]+sufficient[[:space:]]+([^[:space:]]*/)?pam_tid\.so([[:space:]]|$)' + + grep -Eq "$tid_rule" "$sudo_file" 2>/dev/null && return 0 + # sudo_local is effective only when the main sudo policy includes it. + if grep -Eq '^[[:space:]]*auth[[:space:]]+include[[:space:]]+sudo_local([[:space:]]|$)' "$sudo_file" 2>/dev/null; then + grep -Eq "$tid_rule" "$local_file" 2>/dev/null && return 0 + fi + return 1 +} + +interactive_auth_available() { + [[ -t 0 && -t 1 && -z "${CI:-}" ]] +} + +setup_touchid() { + if [[ "$(uname -s)" != Darwin ]]; then + log_error "Touch ID setup is available only on macOS." + return 1 + fi + if ! interactive_auth_available; then + log_error "Touch ID setup requires an interactive terminal outside CI." + return 1 + fi + # The bare mo touchid command toggles; check first and request enable only. + if touchid_configured; then + log_success "Touch ID is already configured for sudo." + return 0 + fi + if ! command_exists mo; then + log_error "Install Mole first: brew install mole; then run update-all --setup-touchid (or mo touchid)." + return 1 + fi + log_info "Mole will ask you to enable sudo Touch ID and authorize the change with your password." + if ! mo touchid enable; then + log_error "Touch ID setup failed or was cancelled; password authentication remains available." + return 1 + fi + if ! touchid_configured; then + log_error "Touch ID was not enabled. Run mo touchid and choose Enable to try again." + return 1 + fi + log_success "sudo can now use Touch ID, with the macOS password fallback." +} + +prepare_update_auth() { + # Only Homebrew/casks may need elevation. Never run Homebrew itself as root. + [[ "$AUTHENTICATE" == true && "$UPDATE_BREW" == true ]] || return 0 + [[ "$(uname -s)" == Darwin ]] || return 0 + command_exists brew && command_exists sudo || return 0 + interactive_auth_available || return 0 + + if ! touchid_configured; then + log_info "Optional Touch ID setup: update-all --setup-touchid (uses mo touchid)." + fi + # Reuse a valid timestamp without forcing an unnecessary biometric prompt. + sudo -n -v 2>/dev/null && return 0 + log_info "Authorize Homebrew installers: use Touch ID if offered, or your password." + # Foreground, before captured/timeout commands: PAM owns biometric selection, + # cancellation and password fallback. Never read or store the password here. + if ! sudo -v; then + log_error "Administrator authentication failed; updates were not started. Retry or use --no-auth." + return 1 + fi +} + # Load ignore list for problematic casks load_cask_ignore_list() { if [[ -f "$CASK_IGNORE_FILE" ]]; then @@ -1082,6 +1165,12 @@ update_neovim_plugins() { # Main execution main() { + if [[ "$SETUP_TOUCHID" == true ]]; then + setup_touchid + return $? + fi + prepare_update_auth || return 1 + echo -e "${CYAN}" echo "╔════════════════════════════════════════════════════════════╗" echo "║ Package Update Manager ║" @@ -1138,5 +1227,7 @@ main() { fi } -# Run main function -main +# Sourcing exposes functions for focused tests without running package updates. +if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then + main +fi diff --git a/tests/release_test.py b/tests/release_test.py new file mode 100644 index 0000000..34a92f6 --- /dev/null +++ b/tests/release_test.py @@ -0,0 +1,61 @@ +"""Release validation tests use throwaway metadata, never GitHub or git tags.""" + +import importlib.util +from pathlib import Path +import tempfile +import unittest + + +SPEC = importlib.util.spec_from_file_location( + "release", Path(__file__).resolve().parents[1] / "scripts/release.py" +) +RELEASE = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(RELEASE) + + +class ReleaseTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + self.write("VERSION", "0.1.0\n") + self.write("README.md", "# Dotfiles\n\n**Current version: 0.1.0**\n") + self.write("CHANGELOG.md", "# Changelog\n\n## [Unreleased]\n\n" + "## [0.1.0] - 2026-10-18\n\n### Added\n- Touch ID\n\n" + "## [Initial Release]\n- Older notes\n") + + def write(self, name, value): + (self.root / name).write_text(value) + + def test_release_notes_only_include_requested_version(self): + self.assertEqual( + RELEASE.release_notes(self.root, "v0.1.0"), "### Added\n- Touch ID\n" + ) + + def test_invalid_semver(self): + for value in ("v0.1.0", "01.1.0", "0.1", "0.1.0-rc.1", "0.1.0+build", "1.0.00"): + with self.subTest(value=value), self.assertRaises(ValueError): + self.write("VERSION", value) + RELEASE.release_notes(self.root) + + def test_mismatched_tag(self): + for tag in ("0.1.0", "v0.2.0", "v0.1.0; echo unsafe"): + with self.subTest(tag=tag), self.assertRaises(ValueError): + RELEASE.release_notes(self.root, tag) + + def test_missing_or_invalid_changelog(self): + for text in ("## [Unreleased]\n", "## [0.1.0] - 2026-10-18\n- Change\n", + "## [Unreleased]\n## [0.1.0] - 2026-02-30\n- Change\n", + "## [Unreleased]\n## [0.1.0] - 2026-10-18\n### Added\n"): + with self.subTest(text=text), self.assertRaises(ValueError): + self.write("CHANGELOG.md", text) + RELEASE.release_notes(self.root) + + def test_stale_readme(self): + self.write("README.md", "**Current version: 0.0.1**\n") + with self.assertRaises(ValueError): + RELEASE.release_notes(self.root) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/update_all_test.py b/tests/update_all_test.py new file mode 100644 index 0000000..0adb403 --- /dev/null +++ b/tests/update_all_test.py @@ -0,0 +1,308 @@ +"""Mocked Bash behavior tests: never authenticate or update real packages.""" + +import os +from pathlib import Path +import pty +import subprocess +import tempfile +import threading +import unittest +from unittest import mock + + +ROOT = Path(__file__).resolve().parents[1] +BASH = os.environ.get("BASH_UNDER_TEST", "/bin/bash") +SCRIPT = ROOT / "scripts/update-all.sh" + +# Install a safety net BEFORE sourcing. Even a regression in the source guard +# must not reach a developer's real package managers, sudo, or home directory. +SANDBOX = r''' +for test_command in sudo mo brew npm pip3 gem rustup cargo composer cargo-install-update go nvim tmux softwareupdate; do + eval "$test_command() { printf 'UNEXPECTED: $test_command\\n' >> \"\$CALL_LOG\"; return 99; }" + export -f "$test_command" +done +''' + +MOCKS = r''' +uname() { printf '%s\n' "${TEST_OS:-Darwin}"; } +brew() { printf 'brew %s\n' "$*" >> "$CALL_LOG"; } +sudo() { + printf 'sudo %s\n' "$*" >> "$CALL_LOG" + case "$*" in + '-n -v') return "${CACHE_STATUS:-1}" ;; + '-v') printf 'native Touch ID/password prompt\n' >&2; return "${AUTH_STATUS:-0}" ;; + *) return 99 ;; + esac +} +mo() { + printf 'mo %s\n' "$*" >> "$CALL_LOG" + [[ "${MO_STATUS:-0}" == 0 ]] || return "$MO_STATUS" + [[ "${ENABLE_TOUCHID:-true}" != true ]] || touch "$AUTH_STATE" +} +touchid_configured() { [[ -f "$AUTH_STATE" ]]; } +update_homebrew() { brew update; } +''' +OTHER_UPDATES = ( + "update_npm", "update_pip", "update_gems", "update_composer", "update_rust", + "update_go", "update_ai_tools", "update_oh_my_zsh", "update_tmux_plugins", + "update_neovim_plugins", "update_macos", +) +MOCKS += "\n".join( + f'{name}() {{ printf "{name}\\n" >> "$CALL_LOG"; }}' for name in OTHER_UPDATES +) + "\n" + + +class UpdateAllTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.log = Path(self.temp.name) / "calls" + self.log.touch() + self.state = Path(self.temp.name) / "enabled" + + def run_shell(self, body, *, mocks=True, terminal=False, args=(), **env): + environment = os.environ.copy() + environment.pop("CI", None) + environment.update( + CALL_LOG=str(self.log), AUTH_STATE=str(self.state), SCRIPT=str(SCRIPT), + HOME=self.temp.name, + CARGO_HOME=str(Path(self.temp.name) / "cargo"), + RUSTUP_HOME=str(Path(self.temp.name) / "rustup"), + ) + environment.update(env) + program = SANDBOX + 'source "$SCRIPT" "$@"\n' + (MOCKS if mocks else "") + body + command = [BASH, "-c", program, "update-all-test", *args] + if terminal: + # Exercise the real -t gate without any real sudo or Mole process. + master, slave = pty.openpty() + output = [] + + def drain_terminal(): + try: + while True: + chunk = os.read(master, 65536) + if not chunk: + break + output.append(chunk) + except OSError: # PTYs report EOF as EIO on some platforms. + pass + + reader = threading.Thread(target=drain_terminal, daemon=True) + reader.start() + try: + result = subprocess.run( + command, env=environment, stdin=slave, stdout=slave, + stderr=subprocess.PIPE, text=True, timeout=10, + ) + finally: + os.close(slave) + reader.join(timeout=3) + os.close(master) + result.stdout = b"".join(output).decode(errors="replace") + return result + return subprocess.run( + command, env=environment, capture_output=True, text=True, timeout=10 + ) + + def calls(self): + return self.log.read_text().splitlines() + + def assert_ok(self, result): + self.assertEqual(result.returncode, 0, result.stdout or result.stderr) + + def test_source_does_not_run_updates(self): + result = self.run_shell('echo "sourced"', mocks=False) + self.assert_ok(result) + self.assertEqual(result.stdout, "sourced\n") + self.assertEqual(self.calls(), []) + + def test_unexpected_main_cannot_escape_package_sandbox(self): + # Mutate a throwaway copy to simulate the source guard regressing. A + # harmless PATH decoy records escapes; it never executes real Cargo. + directory = Path(self.temp.name) + bin_dir = directory / "bin" + bin_dir.mkdir() + cargo = bin_dir / "cargo" + cargo.write_text('#!/bin/bash\nprintf "ESCAPED cargo %s\\n" "$*" >> "$CALL_LOG"\nexit 99\n') + cargo.chmod(0o755) + footer = 'if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then\n main\nfi' + source = SCRIPT.read_text() + self.assertIn(footer, source) + mutated = directory / "update-all.sh" + mutated.write_text(source.replace( + footer, + # The watchdog is unrelated to this safety test. Avoid spawning + # sleeping watchers while exercising the actual update functions. + 'run_with_timeout() { shift; bash -c "$*"; }\nmain', + )) + result = self.run_shell( + ':', mocks=False, SCRIPT=str(mutated), + PATH=str(bin_dir) + os.pathsep + os.environ["PATH"], + ) + self.assert_ok(result) + self.assertIn("UNEXPECTED: rustup", self.calls()) + self.assertIn("UNEXPECTED: cargo", self.calls()) + self.assertFalse(any(line.startswith("ESCAPED") for line in self.calls()), self.calls()) + + def test_toolchain_homes_are_isolated_from_inherited_environment(self): + with mock.patch.dict(os.environ, { + "CARGO_HOME": "/must-not-use-inherited-cargo", + "RUSTUP_HOME": "/must-not-use-inherited-rustup", + }): + result = self.run_shell('printf "%s\\n" "$CARGO_HOME" "$RUSTUP_HOME"', mocks=False) + self.assert_ok(result) + self.assertEqual(result.stdout.splitlines(), [ + str(Path(self.temp.name) / "cargo"), + str(Path(self.temp.name) / "rustup"), + ]) + + def test_help_and_unknown_options(self): + result = self.run_shell("", args=("--help",)) + self.assert_ok(result) + self.assertIn("--setup-touchid", result.stdout) + self.assertIn("--no-auth", result.stdout) + self.assertNotEqual(self.run_shell("", args=("--unknown",)).returncode, 0) + self.assertEqual(self.calls(), []) + + def test_foreground_auth_before_homebrew(self): + result = self.run_shell('main', terminal=True) + self.assert_ok(result) + self.assertEqual(self.calls(), ["sudo -n -v", "sudo -v", "brew update", *OTHER_UPDATES]) + self.assertIn("native Touch ID/password prompt", result.stderr) + + def test_cached_credentials_skip_prompt(self): + self.assert_ok(self.run_shell("prepare_update_auth", terminal=True, CACHE_STATUS="0")) + self.assertEqual(self.calls(), ["sudo -n -v"]) + + def test_auth_failure_stops_main_before_updates(self): + self.assertNotEqual( + self.run_shell("main", terminal=True, AUTH_STATUS="1").returncode, 0 + ) + self.assertEqual(self.calls(), ["sudo -n -v", "sudo -v"]) + + def test_password_works_without_touchid_setup(self): + result = self.run_shell("prepare_update_auth", terminal=True) + self.assert_ok(result) + self.assertEqual(self.calls(), ["sudo -n -v", "sudo -v"]) + self.assertFalse(self.state.exists()) # No implicit mo/PAM modification. + + def test_redirected_runs_skip_auth(self): + self.assert_ok(self.run_shell("prepare_update_auth")) + self.assertEqual(self.calls(), []) + + def test_ci_even_with_terminal_skips_auth(self): + self.assert_ok(self.run_shell("prepare_update_auth", terminal=True, CI="true")) + self.assertEqual(self.calls(), []) + + def test_linux_skips_auth(self): + self.assert_ok(self.run_shell("prepare_update_auth", terminal=True, TEST_OS="Linux")) + self.assertEqual(self.calls(), []) + + def test_no_auth_and_manager_isolation(self): + for flag in ("--no-auth", "--npm-only", "--pip-only", "--ai-tools-only"): + with self.subTest(flag=flag): + self.assert_ok(self.run_shell("prepare_update_auth", terminal=True, args=(flag,))) + self.assertEqual(self.calls(), []) + + def test_missing_brew_or_sudo_skips_auth(self): + for missing in ("brew", "sudo"): + result = self.run_shell( + f'command_exists() {{ [[ "$1" != {missing} ]]; }}; prepare_update_auth', + terminal=True, + ) + self.assert_ok(result) + self.assertEqual(self.calls(), []) + + def test_setup_runs_mole_only_and_is_idempotent(self): + self.assert_ok(self.run_shell("main", terminal=True, args=("--setup-touchid",))) + self.assertTrue(self.state.exists()) + self.assert_ok(self.run_shell("main", terminal=True, args=("--setup-touchid",))) + self.assertEqual(self.calls(), ["mo touchid enable"]) + + def test_setup_checks_postcondition(self): + result = self.run_shell("setup_touchid", terminal=True, ENABLE_TOUCHID="false") + self.assertNotEqual(result.returncode, 0) + self.assertEqual(self.calls(), ["mo touchid enable"]) + + def test_cancelled_setup_reports_failure(self): + result = self.run_shell("setup_touchid", terminal=True, MO_STATUS="1") + self.assertNotEqual(result.returncode, 0) + self.assertFalse(self.state.exists()) + + def test_setup_missing_mole(self): + result = self.run_shell( + 'command_exists() { [[ "$1" != mo ]]; }; setup_touchid', terminal=True + ) + self.assertNotEqual(result.returncode, 0) + self.assertEqual(self.calls(), []) + + def test_setup_requires_mac_interactive_non_ci(self): + for terminal, env in ((False, {}), (True, {"TEST_OS": "Linux"}), (True, {"CI": "true"})): + with self.subTest(terminal=terminal, env=env): + self.assertNotEqual( + self.run_shell("setup_touchid", terminal=terminal, **env).returncode, 0 + ) + self.assertEqual(self.calls(), []) + + def test_active_pam_rules(self): + sudo_file = Path(self.temp.name) / "sudo" + local_file = Path(self.temp.name) / "sudo_local" + cases = ( + ("auth include sudo_local\n", "auth sufficient pam_tid.so\n", True), + ("auth sufficient /usr/lib/pam/pam_tid.so\n", "", True), + ("# auth sufficient pam_tid.so\n", "", False), + ("auth include sudo_local\n", "#auth sufficient pam_tid.so\n", False), + ("# auth include sudo_local\n", "auth sufficient pam_tid.so\n", False), + ("auth include sudo_local\n", "auth sufficient pam_tidXso\n", False), + ("auth required pam_opendirectory.so\n", "auth sufficient pam_tid.so\n", False), + ) + for sudo_policy, local_policy, enabled in cases: + with self.subTest(sudo=sudo_policy, local=local_policy): + sudo_file.write_text(sudo_policy) + local_file.write_text(local_policy) + result = self.run_shell( + 'touchid_configured "$SUDO_FILE" "$LOCAL_FILE"', mocks=False, + SUDO_FILE=str(sudo_file), LOCAL_FILE=str(local_file), + ) + self.assertEqual(result.returncode == 0, enabled, result.stderr) + sudo_file.unlink() + local_file.unlink() + result = self.run_shell( + 'touchid_configured "$SUDO_FILE" "$LOCAL_FILE"', mocks=False, + SUDO_FILE=str(sudo_file), LOCAL_FILE=str(local_file), + ) + self.assertEqual(result.returncode, 1) + + def test_manager_flags_preserve_selection(self): + for flag, selected in (("--brew-only", "BREW"), ("--npm-only", "NPM"), + ("--pip-only", "PIP"), ("--ai-tools-only", "AI_TOOLS")): + result = self.run_shell( + 'for name in BREW NPM PIP GEM COMPOSER RUST GO AI_TOOLS OH_MY_ZSH TMUX NEOVIM MACOS; do ' + 'var="UPDATE_$name"; [[ "${!var}" != true ]] || echo "$name"; done', + args=(flag,), + ) + self.assert_ok(result) + self.assertEqual(result.stdout.strip(), selected) + + def test_cask_ignore_comments_whitespace_and_filtering(self): + ignore = Path(self.temp.name) / "ignore" + ignore.write_text("# comment\n\n arc # broken\nopera\n") + result = self.run_shell( + 'load_cask_ignore_list >/dev/null; ' + 'get_outdated_casks() { printf "arc\\nopera\\nfirefox\\n"; }; ' + 'get_cask_upgrade_list', CASK_IGNORE_FILE=str(ignore), + ) + self.assert_ok(result) + self.assertEqual(result.stdout.strip(), "firefox") + + def test_empty_cask_ignore_list(self): + result = self.run_shell('is_cask_ignored firefox', CASK_IGNORE_FILE=str(self.log)) + self.assertEqual(result.returncode, 1) + + def test_timeout_preserves_command_failure(self): + result = self.run_shell('run_with_timeout 2 "exit 7"') + self.assertEqual(result.returncode, 7) + + +if __name__ == "__main__": + unittest.main()