From 0918c7d8c5bbab662107588bae9e7de138575ba1 Mon Sep 17 00:00:00 2001 From: fredericrous Date: Wed, 30 Sep 2026 00:43:50 +0200 Subject: [PATCH 1/8] docs(plan): dotfiles ci The approved plan, landed as the branch's first commit (work.plan-lives-in-the-repo). Panel: backend, platform; body-sha f5d3133cd045. Co-Authored-By: Claude Fable 5.1 --- docs/plans/2026-09-30-dotfiles-ci.md | 259 +++++++++++++++++++ docs/plans/2026-09-30-dotfiles-ci.reviews.md | 36 +++ 2 files changed, 295 insertions(+) create mode 100644 docs/plans/2026-09-30-dotfiles-ci.md create mode 100644 docs/plans/2026-09-30-dotfiles-ci.reviews.md diff --git a/docs/plans/2026-09-30-dotfiles-ci.md b/docs/plans/2026-09-30-dotfiles-ci.md new file mode 100644 index 0000000..0731b79 --- /dev/null +++ b/docs/plans/2026-09-30-dotfiles-ci.md @@ -0,0 +1,259 @@ +--- +status: active +branch: feat/ci +repos: [dotfiles] +adrs: [ADR-0017] +--- +# dotfiles gets the CI its rules require + +## Review panel + +๐Ÿ‘‰ **Decide:** none โ€” approve if dotfiles gets one `make check` (pinned tools in `.tools/`, hermetic render, shellcheck, tree-wide private-refs) mirrored by GitHub Actions on Linux and macOS, and this PR is the first live F4b cycle. +๐Ÿ“ dotfiles ยท nothing built yet ยท next: worktree, plan commit, Phase 2. Panel: backend, platform (`adds=ops`). +**Changed by review:** `.chezmoiignore` covers the new machinery (it would have been deployed to `$HOME`); release assets with pinned SHA-256s instead of an installer piped to `sh`; the `PRIVATE_TERMS` secret is created before the push, passed via `env:`, required on same-repo runs, names-only in logs. +**Verdicts:** backend round 1 approve-with-changes (7, all applied); round 2 backend + platform approve-with-changes (5 + 5, all applied); final binding backend + platform approve-with-changes. +**Carried into Phase 2** (final binding, no body change): NUL-safe `git ls-files -z` loops in `--tree`, staged mode and `lint` (a tracked path holds a space); the render loop prints `render: $f` on failure and lists templates with a git pathspec; `--persistent-state` on every chezmoi call; an empty pattern under `PRIVATE_REFS_REQUIRE_TERMS=1` is a failure. + +๐Ÿ“„ Full reviews: [2026-09-30-dotfiles-ci.reviews.md](2026-09-30-dotfiles-ci.reviews.md) + +## Context + +`fredericrous/dotfiles` is a public GitHub repository with an amont +pre-commit gate and no continuous-integration workflow. ADR-0017 makes that +a defect (`ci.from-first-commit`: a repository has a workflow in its first +commit or in the commit that adds its first check target), and the working +method's soak notes recorded it as a trap on 2026-09-28. The repository is +also where every Claude skill and agent lives, so a template that no longer +renders, or a private hostname that slips into a plaintext file, reaches +every session and the public. + +This change is also **Phase 4 of the implementation-review plan** +(`decisions:docs/plans/2026-09-29-implementation-reviewer-before-push.md`): +the first real F4b cycle, on a code diff with a plan, whose tokens, seconds, +verdict and finding quality feed the 2026-10-06 soak review. + +## Goal + +One check target, `make check`, that a workstation and GitHub Actions run +with the same commands and pinned tools: every template renders with +placeholder data, the shell scripts pass shellcheck, and no plaintext file in +the tree carries a private identifier. The workflow runs it on Linux and +macOS for every push to `main` and every pull request. + +## Non-goals + +- A Windows render. The `AppData` and PowerShell branches stay unverified + until a runner is worth its minutes. +- Forgejo. The repository is public on GitHub, so GitHub Actions + (`ci.system-follows-visibility`). +- Changing what amont's pre-commit checks; `private-refs` at pre-commit + keeps scanning the staged files. +- Publishing the private terms. They stay outside the tree on the + workstation and in a repository secret in CI. +- A fleet CI template or reusable workflow; one repository, its own file. + +## Behaviour + +### `make check`, the one target (`ci.mirrors-the-local-check`) + +`check: tools render lint private-refs`, each a target of its own so a red +job names its step. The Makefile is POSIX make plus `/bin/sh`, since the +repository has no language of its own. + +- **`tools`** installs the pinned tools into `.tools/` (gitignored) and + never uses the machine's (`toolchain.pinned-exactly-in-the-repository`, + `toolchain.tools-pinned-with-it`, `toolchain.one-pin-read-by-both`): + - `tools.env` holds the only pins: `CHEZMOI=v2.72.1`, + `SHELLCHECK=v0.11.0`. The Makefile reads it; nothing else states a + version. + - Both tools are downloaded as the **release asset of the pinned tag**, + straight from GitHub releases, never through an installer script + piped to `sh`: `chezmoi___.tar.gz` and + `shellcheck-...tar.xz` for the host + โ€” chezmoi names them `linux_amd64`, `darwin_amd64`, `darwin_arm64`, + shellcheck `linux.x86_64`, `darwin.x86_64`, `darwin.aarch64`; the + Makefile maps `uname -s`/`uname -m` to both spellings. `tools.env` + holds, beside each version, the **SHA-256 of each of the six assets**: + chezmoi's from its published `chezmoi__checksums.txt`; shellcheck + publishes no checksums file, so its three are computed at pin time + from the asset downloaded over HTTPS from the GitHub release, and pin + what was reviewed rather than what upstream attests. The Makefile + verifies every download with `shasum -a 256 -c` before extracting; a + mismatch fails naming the asset. An unknown host is a failure that + names the host, not a fallback to PATH. + - Afterwards `.tools/chezmoi --version` and `.tools/shellcheck + --version` must contain their pins, or the target fails. + - Idempotent: a `.tools/` already at the pinned version is not + re-downloaded, so a second `make tools` sends no request. +- **`render`** proves every template renders: + - `ci/chezmoi.toml` (committed) carries placeholder `[data]`: + `bwserver = ""`, `forge.host = "forge.example.invalid"`, + `forge.slug = "example"`, `work.dir = ""`, `work.config = ""` โ€” the + five keys the templates read, none of them a real value. + - `.tools/chezmoi --config ci/chezmoi.toml --persistent-state + "$dst/state.boltdb" --source . --destination "$dst" apply --exclude + encrypted,externals,scripts` with `dst` a fresh temp directory: every + template is executed against the placeholders and written there; + encrypted files need no key, externals no network, and no `run_once_*` + script runs. The state file goes into `dst` too, because chezmoi + otherwise writes it beside the config file and dirties the tree. + Afterwards the target asserts that `$dst/.zshrc`, + `$dst/.claude/CLAUDE.md` and `$dst/.claude/skills/worktree-task/SKILL.md` + exist and are non-empty, that `$dst/Makefile`, `$dst/tools.env` and + `$dst/ci` do NOT exist (the repository machinery never reaches a home + directory), and removes `dst`. + - The two `run_once_*.tmpl` scripts are excluded from the apply (they + must not run) but are the only templates that call `include` and + `lookPath`, so each is rendered on its own: `.tools/chezmoi --config + ci/chezmoi.toml --source . execute-template < $f` for every tracked + `run_once_*.tmpl`, output discarded, exit status kept. + - The config template itself: `.tools/chezmoi --config ci/chezmoi.toml + execute-template --init --promptString "bitwarden server url=x" + < .chezmoi.toml.tmpl | grep -q 'encryption = "gpg"'` โ€” with the ci + config, so `promptStringOnce` never reads the workstation's real data + and the run is the same at home and on the runner. +- **`lint`**: `.tools/shellcheck` over every tracked `*.sh` and every + tracked file whose first line is a `sh`/`bash` shebang + (`scripts/check-private-refs.sh`, `install_dependencies_debian.sh`, + `mac_defaults.sh` today; the list is computed, not written down). fish + and PowerShell files are not shellcheck's business. +- **`private-refs`**: `scripts/check-private-refs.sh --tree` โ€” a new mode + of the existing script that scans every tracked plaintext file + (`git ls-files`, `encrypted_*` skipped) instead of the staged set, with + the same terms file (`PRIVATE_TERMS_FILE`, default + `~/.config/chezmoi/private-terms`). Two differences from the staged + mode, both because a CI log is public: on a hit it prints **file names + only**, never the matching lines (a regex term defeats GitHub's secret + masking), and a path that itself matches a term is printed as + ``; and with `PRIVATE_REFS_REQUIRE_TERMS=1` a missing terms file is + a failure, not the loud pass โ€” CI sets it on every push to `main` and on + a pull request from this repository, and leaves it unset only for a + fork's pull request, which has no secret (`general.no-disabled-safety`). + Without the flag the script behaves exactly as today, so amont's + pre-commit entry is untouched. + +### The workflow (`.github/workflows/ci.yaml`) + +- `on: push: branches: [main]` and `pull_request`; `concurrency` cancels a + superseded run; `timeout-minutes: 10`. +- `permissions: contents: read` at the top level (the default token in a + public repository may carry write), and `actions/checkout` pinned by + commit SHA with its tag in a comment (`toolchain.tools-pinned-with-it` + applies to actions too). +- One job, `check`, `strategy.matrix.os: [ubuntu-latest, macos-latest]` + (`fail-fast: false`, so both branches of the templates report): the + checkout, then a step that receives the `PRIVATE_TERMS` repository secret + through `env:` (never inside the `run:` text) and writes it with + `printf '%s\n' "$PRIVATE_TERMS" > "$RUNNER_TEMP/private-terms"` โ€” outside + `$GITHUB_WORKSPACE` โ€” exporting `PRIVATE_TERMS_FILE` when it is + non-empty, and exporting `PRIVATE_REFS_REQUIRE_TERMS=1` when + `github.event_name == 'push'` or the pull request's head repository is + this one โ€” so a missing secret fails the job where the secret should + exist and only a fork's pull request gets the loud pass; then `make + check`. +- The job name and the step are the same words as the Makefile targets, so + a red line in the run is a target to run at home. + +### The workstation side + +- `amont.conf` gains `pre-push check * block make check`, so a push + runs what CI runs before the remote sees it (ADR-0009). Its cost is + measured in Phase 2 (a warm `make check` wall time) before the entry is + added, and recorded below; if the warm run is over 20 s, the pre-push + entry runs `make lint private-refs` only and the render stays CI's. +- `.gitignore` (new, at the source root; chezmoi ignores dot-files there, + so nothing is deployed) holds `.tools/`. +- `.chezmoiignore` gains `Makefile`, `tools.env` and `ci/`: chezmoi + deploys every non-dot source file it is not told to ignore, and these + three are repository machinery like `amont.conf` and `scripts/`. +- `README.md` gains the CI badge and one sentence: `make check` is what CI + runs. + +## Phases + +- [ ] Phase 1 โ€” this plan, `docs/plans/2026-09-30-dotfiles-ci.md`, the + first commit. +- [ ] Phase 2 โ€” `tools.env` (versions and asset checksums), `Makefile` + (`tools`, `render`, `lint`, `private-refs`, `check`), `ci/chezmoi.toml`, + `.gitignore`, `.chezmoiignore` entries, and the `--tree` mode of + `scripts/check-private-refs.sh` (names only, `PRIVATE_REFS_REQUIRE_TERMS`). + `make check` green locally on this Mac; recorded before Phase 3: the + warm and the cold (empty `.tools/`) wall time of `make check`, the count + of shellcheck findings the existing scripts produce today, and the + `--tree` hit count (file names) against the real terms file โ€” a hit + already in the tree is fixed in this phase, since `--tree` fails on it + where the staged mode never did. +- [ ] Phase 3 โ€” `.github/workflows/ci.yaml`, the `amont.conf` pre-push + entry, the README line. **Before the push**: `gh secret set + PRIVATE_TERMS --repo fredericrous/dotfiles < ~/.config/chezmoi/private-terms` + (stdin, never argv), otherwise the first same-repository run fails by + design. Then F4b (the first real implementation review), push, both + runners green, merge. +- [ ] Phase 4 โ€” record the F4b cycle (tokens, seconds, verdict, whether + each finding was real) in the implementation-review plan's Verification, + in decisions, as its Phase 4; set that plan's Phase 4 ticked. + +## Decision log + +- 2026-09-30 โ€” tools are downloaded by release tag into `.tools/`, not + taken from Homebrew or apt: the fleet's toolchain rules want one pin read + by both the workstation and the runner, and a `brew install shellcheck` + in the workflow would be a second, floating version. +- 2026-09-30 โ€” the render check applies into a temp directory rather than + `--dry-run`: a dry run also executes templates, but writing the files + lets the target assert on what came out, and `--exclude + encrypted,externals,scripts` keeps it hermetic. +- 2026-09-30 โ€” `private-refs --tree` is a mode of the existing script, not + a second script: one term file, one message, one place to fix. +- 2026-09-30 โ€” the `PRIVATE_TERMS` secret is a copy of the workstation's + terms file and can drift from it; whoever edits the file re-sets the + secret (`gh secret set โ€ฆ < file`), and the README says so. + +## Verification (input โ†’ expected โ†’ actual) + +- `make tools` on this Mac โ†’ `.tools/chezmoi --version` prints v2.72.1, + `.tools/shellcheck --version` prints 0.11.0; a second run downloads + nothing. +- `tools.env` edited to a version that does not exist โ†’ `make tools` fails + naming the tool and the tag; one checksum digit changed โ†’ fails naming + the asset (falsifications), then restored. +- `git status --porcelain` after `make check` โ†’ empty (no state file, no + tool, nothing rendered inside the tree). +- `.tools/chezmoi --config ci/chezmoi.toml --source . managed | grep -E + '^(Makefile|tools.env|ci)'` โ†’ prints nothing. +- `make render` โ†’ the temp destination holds `.zshrc`, `.claude/CLAUDE.md`, + `.claude/skills/worktree-task/SKILL.md`; a template broken on purpose + (an unclosed `{{`) โ†’ `make render` fails naming the file + (falsification), then restored. +- `make lint` โ†’ shellcheck runs over the computed list (printed) and exits + 0; a script with an unquoted `$var` in a for loop added on purpose โ†’ + fails (falsification), then removed. +- `make private-refs` with `PRIVATE_TERMS_FILE` pointing at a temp file + containing a term that IS in the tree (a word from `README.md`) โ†’ exit 1 + naming the file and not the line; with the real terms file โ†’ exit 0; + with no file โ†’ the loud NOT-checking line and exit 0; + `PRIVATE_REFS_REQUIRE_TERMS=1 PRIVATE_TERMS_FILE=/nonexistent make + private-refs` โ†’ exit 1 naming the missing file. +- `make render` with `run_once_fisher.fish.tmpl` broken on purpose (an + unclosed `{{`) โ†’ fails naming that file (falsification), then restored. +- `make check` โ†’ all four, green, wall time recorded. +- GitHub Actions: the PR's `check (ubuntu-latest)` and `check + (macos-latest)` โ†’ both green; the run's step names are the target names; + the downloaded run log contains no `NOT checking` line and no term from + the secret. +- A deliberate hit on a throwaway branch (a placeholder term added to the + secret's file locally, `--tree` run with it) โ†’ exit 1 naming the file + and NOT printing the term. +- Pre-push cost: warm `make check` wall time on this Mac, recorded, and the + shellcheck finding count on the existing scripts before any fix. +- F4b: `amont-agent tree-sha --block` โ†’ the block; the + `implementation-review` agent โ†’ its verdict and findings; each finding + fixed or `deliberate:`; the push โ†’ silent, journal `unconfirmed reviewed` + (the hook's live `reviewed` case, still unverified). Tokens and seconds + recorded here and in the implementation-review plan. + +## Outcome + +(filled when the plan closes) + + diff --git a/docs/plans/2026-09-30-dotfiles-ci.reviews.md b/docs/plans/2026-09-30-dotfiles-ci.reviews.md new file mode 100644 index 0000000..ff53ac7 --- /dev/null +++ b/docs/plans/2026-09-30-dotfiles-ci.reviews.md @@ -0,0 +1,36 @@ +# Full reviews โ€” dotfiles gets the CI its rules require + +Reference for [the plan](2026-09-30-dotfiles-ci.md). Written by the panel on 2026-09-30; not part of the reviewed body. + +## Full reviews (reference) +### plan-review-backend, round 1 โ€” approve-with-changes (39k, 62 s) + +1. [blocking] `Makefile`, `tools.env`, `ci/` would be deployed to `$HOME` (`.chezmoiignore:1-7`). โ†’ applied: ignored; `render` asserts their absence. +2. [blocking] CI infrastructure undeclared. โ†’ applied: `adds=ops`. +3. [high] `ci/chezmoistate.boltdb` would dirty the tree. โ†’ applied: `--persistent-state "$dst/state.boltdb"`; `git status --porcelain` check. +4. [high] Matching lines printed into a public log. โ†’ applied: names only in `--tree`. +5. [medium] Silent pass on a missing secret for pushes to `main`. โ†’ applied: `PRIVATE_REFS_REQUIRE_TERMS=1` on push and same-repo PRs. +6. [medium] Installer script piped to `sh`, no checksums. โ†’ applied: release assets + SHA-256 per asset in `tools.env`. +7. [low] Unmeasured pre-push cost, no shellcheck baseline. โ†’ applied: measured in Phase 2. +Would measure: `chezmoi managed` shows none of the three; `git status --porcelain` empty; a deliberate hit names the file, not the term. + +### plan-review-backend, round 2 โ€” approve-with-changes (36k, 57 s) + +1โ€“7 resolved. New: 1. [medium] no test of the `REQUIRE_TERMS` failure path โ†’ applied. 2. [medium] shellcheck may publish no checksums; asset naming โ†’ applied (computed at pin time; mapping written out). 3. [low] config-template check without the ci config โ†’ applied. 4. [low] pre-push cost unbounded โ†’ applied (20 s, else `lint private-refs`). 5. [low] a path can carry a term โ†’ applied (``). + +### plan-review-platform, round 1 โ€” approve-with-changes (36k, 50 s) + +1. [high] `run_once_*.tmpl` skipped by the apply โ†’ applied: each rendered with `execute-template`; falsification added. +2. [high] no step creates the secret; first same-repo run would fail โ†’ applied: `gh secret set โ€ฆ < file` before the push; drift recorded. +3. [medium] secret placement โ†’ applied: `env:` + `printf` into `$RUNNER_TEMP`. +4. [medium] no `permissions:`, unpinned action โ†’ applied: `contents: read`, checkout pinned by SHA. +5. [low] `--tree` baseline and cold time โ†’ applied. +Would measure: a broken `run_once` template goes red; first same-repo run red then green after the secret; cold vs warm `make check`. + +### plan-review-backend, final binding โ€” approve-with-changes (37k, 49 s) + +1โ€“5 resolved. **Carried into Phase 2:** [high] `for f in $staged` splits `private_Library/private_Application Support/โ€ฆ/settings.json.tmpl` on the space and skips it silently โ€” `--tree`, the staged mode and `lint` read `git ls-files -z` / `git diff --cached -z` NUL-safely; a term planted only in that file โ†’ exit 1. [medium] `lint`'s list built from `-z` and its count compared to `git ls-files`. [low] under `REQUIRE_TERMS`, an empty pattern (comments-only terms file) is a failure; falsification with `# x`. [low] the `include`/`lookPath` claim holds today; no edit. + +### plan-review-platform, final binding โ€” approve-with-changes (32k, 30 s) + +1โ€“5 resolved. **Carried into Phase 2:** [medium] `execute-template < $f` makes chezmoi name `stdin`, so the Makefile prints `render: $f` and exits 1 itself. [medium] `--persistent-state "$dst/state.boltdb"` on both `execute-template` calls too, run before `dst` is removed. [low] the two `run_once` templates are at different depths โ€” list them with `git ls-files '*run_once_*.tmpl'`, print the list, fail under 2 entries. From ab57cc80f0efd39b11ac24309bd0d9918b6aeb18 Mon Sep 17 00:00:00 2001 From: fredericrous Date: Wed, 30 Sep 2026 00:50:59 +0200 Subject: [PATCH 2/8] fix(shell): quote the helper scripts What shellcheck 0.11.0 finds in the tracked shell scripts, ahead of make lint: literal backslash-quotes handed to git commit --author and -m (the quotes ended up in the commit), a -z test on a literal that was always false, ${2:REMOVED} that meant a default value, bashisms under a sh shebang, and unquoted expansions. Usage text says rather than $name. Co-Authored-By: Claude Fable 5.1 --- install_dependencies_debian.sh | 2 +- private_dot_config/git/bin/executable_git-author-prev | 4 ++-- .../git/bin/executable_git-author-rewrite | 4 ++-- private_dot_config/git/bin/executable_git-coauthor | 10 +++++----- private_dot_config/git/bin/executable_git-fixup | 8 +++----- .../git/bin/executable_git-replace-history | 4 ++-- private_dot_config/git/bin/executable_git-rm-history | 6 +++--- 7 files changed, 18 insertions(+), 20 deletions(-) diff --git a/install_dependencies_debian.sh b/install_dependencies_debian.sh index 0eefae6..e0b5da2 100644 --- a/install_dependencies_debian.sh +++ b/install_dependencies_debian.sh @@ -11,7 +11,7 @@ if [ "$EUID" -ne 0 ] fi # create user qaunix -useradd -g sysadmin -d /home/qaunix -m -p $(echo qaunix | openssl passwd -1 -stdin) -s /bin/bash qaunix +useradd -g sysadmin -d /home/qaunix -m -p "$(echo qaunix | openssl passwd -1 -stdin)" -s /bin/bash qaunix # set dns echo "search mydns.test" >> /etc/resolvconf/resolv.conf.d/base diff --git a/private_dot_config/git/bin/executable_git-author-prev b/private_dot_config/git/bin/executable_git-author-prev index 3a6bab4..83224ac 100644 --- a/private_dot_config/git/bin/executable_git-author-prev +++ b/private_dot_config/git/bin/executable_git-author-prev @@ -3,8 +3,8 @@ SCRIPT_NAME=$(basename "$0" | sed 's/git-//') if [ "$#" -ne 2 ]; then - echo -e "Usage:\n git $SCRIPT_NAME \$name \$email"; + printf 'Usage:\n git %s \n' "$SCRIPT_NAME" exit 1; fi -git commit --amend --author \"$1 <$2>\" -C HEAD \ No newline at end of file +git commit --amend --author "$1 <$2>" -C HEAD diff --git a/private_dot_config/git/bin/executable_git-author-rewrite b/private_dot_config/git/bin/executable_git-author-rewrite index 1769171..d220b50 100644 --- a/private_dot_config/git/bin/executable_git-author-rewrite +++ b/private_dot_config/git/bin/executable_git-author-rewrite @@ -3,8 +3,8 @@ SCRIPT_NAME=$(basename "$0" | sed 's/git-//') if [ "$#" -eq 0 ] || [ "$#" -lt 3 ]; then - echo -e "Usage:\n git $SCRIPT_NAME \$old_email \$name \$new_email"; + printf 'Usage:\n git %s \n' "$SCRIPT_NAME" exit 1; fi -git filter-repo --mailmap <(echo "$2 <$3> <$1>") ${@:4} +git filter-repo --mailmap <(echo "$2 <$3> <$1>") "${@:4}" diff --git a/private_dot_config/git/bin/executable_git-coauthor b/private_dot_config/git/bin/executable_git-coauthor index ef5c3cd..ec0c044 100644 --- a/private_dot_config/git/bin/executable_git-coauthor +++ b/private_dot_config/git/bin/executable_git-coauthor @@ -1,16 +1,16 @@ -#!/bin/sh +#!/bin/bash # coauthor Search author $name in previous commits and append the # trailer Co-authored-by to the last commit SCRIPT_NAME=$(basename "$0" | sed 's/git-//') if [ "$#" -ne 1 ]; then - printf "Usage:\n git $SCRIPT_NAME \$name\n"; + printf 'Usage:\n git %s \n' "$SCRIPT_NAME" exit 1; fi set -e -o pipefail OLD_MSG=$(git log --format=%B -1) -CO_AUTHOR=$(git log --format='%aN <%ae>' | sort -u | rg $1 --context=0) -if [ ! -z \"$CO_AUTHOR\" ]; then - git commit --amend -m\"$OLD_MSG\" -m\"Co-authored-by: $CO_AUTHOR\" +CO_AUTHOR=$(git log --format='%aN <%ae>' | sort -u | rg "$1" --context=0) +if [ -n "$CO_AUTHOR" ]; then + git commit --amend -m "$OLD_MSG" -m "Co-authored-by: $CO_AUTHOR" fi diff --git a/private_dot_config/git/bin/executable_git-fixup b/private_dot_config/git/bin/executable_git-fixup index 887e6c4..d3a1f71 100644 --- a/private_dot_config/git/bin/executable_git-fixup +++ b/private_dot_config/git/bin/executable_git-fixup @@ -3,13 +3,11 @@ SCRIPT_NAME=$(basename "$0" | sed 's/git-//') if [ "$#" -eq 0 ]; then - printf "Usage:\n git $SCRIPT_NAME \$sha\n"; + printf 'Usage:\n git %s \n' "$SCRIPT_NAME" exit 1; fi SHA=$(git rev-parse "$1") -git commit --fixup=$SHA ${@:2} -if [ $? -ne 0 ]; then - EDITOR=true - git rebase -i --autostash --autosquash $SHA^ +if ! git commit --fixup="$SHA" "${@:2}"; then + EDITOR=true git rebase -i --autostash --autosquash "$SHA^" fi diff --git a/private_dot_config/git/bin/executable_git-replace-history b/private_dot_config/git/bin/executable_git-replace-history index aa3911c..4bdba63 100644 --- a/private_dot_config/git/bin/executable_git-replace-history +++ b/private_dot_config/git/bin/executable_git-replace-history @@ -3,8 +3,8 @@ SCRIPT_NAME=$(basename "$0" | sed 's/git-//') if [ "$#" -eq 0 ] || [ "$#" -gt 2 ]; then - printf "Usage:\n git $SCRIPT_NAME \$old_text [\$new_text:REMOVED]\n"; + printf 'Usage:\n git %s [, default REMOVED]\n' "$SCRIPT_NAME" exit 1; fi -git filter-repo --force --replace-text <(echo "$1==>${2:REMOVED}") +git filter-repo --force --replace-text <(echo "$1==>${2:-REMOVED}") diff --git a/private_dot_config/git/bin/executable_git-rm-history b/private_dot_config/git/bin/executable_git-rm-history index 6b5e7b6..7db78c9 100644 --- a/private_dot_config/git/bin/executable_git-rm-history +++ b/private_dot_config/git/bin/executable_git-rm-history @@ -1,10 +1,10 @@ -#!/bin/sh +#!/bin/bash # rm-history Erase a $path from git history SCRIPT_NAME=$(basename "$0" | sed 's/git-//') if [ "$#" -eq 0 ]; then - printf "Usage:\n git $SCRIPT_NAME \$path [...args]\n"; + printf 'Usage:\n git %s [...args]\n' "$SCRIPT_NAME" exit 1; fi -git filter-repo --path "$1" --invert-paths ${@:2} +git filter-repo --path "$1" --invert-paths "${@:2}" From f16a0dda89d6a9739eadc2f73d83a23f4d036b52 Mon Sep 17 00:00:00 2001 From: fredericrous Date: Wed, 30 Sep 2026 00:52:43 +0200 Subject: [PATCH 3/8] feat(ci): make check with pinned tools One check target, what the workflow will run (ADR-0017, ci.mirrors-the-local-check): tools (chezmoi v2.72.1, shellcheck v0.11.0 as release assets verified against SHA-256s in tools.env, into .tools/, never PATH), render (every template against ci/chezmoi.toml placeholders into a temp home, run_once templates on their own, the config template), lint (shellcheck over every tracked shell script, NUL-safe), private-refs (--tree: every tracked plaintext file, names only, path withheld when it matches, PRIVATE_REFS_REQUIRE_TERMS for CI). Makefile, tools.env and ci/ are repository machinery and join .chezmoiignore. Co-Authored-By: Claude Fable 5.1 --- .chezmoiignore | 3 ++ .gitignore | 3 ++ Makefile | 30 +++++++++++ ci/chezmoi.toml | 15 ++++++ scripts/check-private-refs.sh | 91 ++++++++++++++++++++++++++------- scripts/lint.sh | 49 ++++++++++++++++++ scripts/render.sh | 69 +++++++++++++++++++++++++ scripts/tools.sh | 95 +++++++++++++++++++++++++++++++++++ tools.env | 19 +++++++ 9 files changed, 357 insertions(+), 17 deletions(-) create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 ci/chezmoi.toml create mode 100644 scripts/lint.sh create mode 100644 scripts/render.sh create mode 100644 scripts/tools.sh create mode 100644 tools.env diff --git a/.chezmoiignore b/.chezmoiignore index 887c5ae..1756f30 100644 --- a/.chezmoiignore +++ b/.chezmoiignore @@ -5,6 +5,9 @@ docs/ README.md install_* .gitattributes +Makefile +tools.env +ci/ {{ if ne .chezmoi.os "windows" }} AppData/ Documents/WindowsPowerShell/Profile.ps1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e5074f9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +# Pinned tools `make tools` downloads (tools.env). A dot-file at the source +# root is not a chezmoi target, so nothing here is deployed. +.tools/ diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..e9d26c2 --- /dev/null +++ b/Makefile @@ -0,0 +1,30 @@ +# The one check target (ADR-0017, ci.mirrors-the-local-check): what a +# workstation runs is what .github/workflows/ci.yaml runs, step for step. +# Each target is its own script under scripts/, so a red job names its step +# and the scripts are themselves under `make lint`. + +.POSIX: + +check: tools render lint private-refs + @echo " ok tools render lint private-refs" + +# Pinned tools into .tools/ (tools.env); never the machine's. +tools: + @sh scripts/tools.sh + +# Every template renders against placeholder data, hermetically. +render: tools + @sh scripts/render.sh + +# shellcheck over every tracked shell script. +lint: tools + @sh scripts/lint.sh + +# No private identifier in any tracked plaintext file. +private-refs: + @sh scripts/check-private-refs.sh --tree + +clean: + @rm -rf .tools + +.PHONY: check tools render lint private-refs clean diff --git a/ci/chezmoi.toml b/ci/chezmoi.toml new file mode 100644 index 0000000..7ad3c20 --- /dev/null +++ b/ci/chezmoi.toml @@ -0,0 +1,15 @@ +# The configuration `make render` runs chezmoi with: placeholder data for the +# five keys the templates read, none of them a real value. The real values +# live in ~/.config/chezmoi/chezmoi.toml on a workstation, generated by +# .chezmoi.toml.tmpl at `chezmoi init` and never committed. + +[data] + bwserver = "" + +[data.forge] + host = "forge.example.invalid" + slug = "example" + +[data.work] + dir = "" + config = "" diff --git a/scripts/check-private-refs.sh b/scripts/check-private-refs.sh index ad5e51f..53818b3 100755 --- a/scripts/check-private-refs.sh +++ b/scripts/check-private-refs.sh @@ -11,20 +11,42 @@ # script carried them inline and so re-introduced the forge host into the public # repository โ€” the guard leaking the thing it guards. # -# Scoped to what is STAGED, so a pre-existing reference in an untouched file is -# not this commit's problem โ€” the rule amont's own checks follow. +# Two modes: +# (default) the STAGED files โ€” amont's pre-commit gate. A pre-existing +# reference in an untouched file is not this commit's problem, +# the rule amont's own checks follow. On a hit the matching +# lines are shown: the terminal is the committer's own. +# --tree every tracked file โ€” `make check`, and therefore CI. On a hit +# only file names are printed, never the lines, because a CI +# log is public and a regex term defeats GitHub's secret +# masking; a path that itself matches is ``. +# +# The terms file is read from $PRIVATE_TERMS_FILE. Without one the check +# fails OPEN, loudly: failing closed would block every commit on a machine +# not set up yet, including the commit that sets it up. CI is different โ€” +# there the secret should exist โ€” so PRIVATE_REFS_REQUIRE_TERMS=1 makes a +# missing or empty terms file a failure (general.no-disabled-safety). # # Values, not mechanisms: `~/.netrc` and mTLS are standard and belong in # readable documentation. Ban what names a person or a machine. set -eu +mode=staged +case "${1:-}" in + --tree) mode=tree ;; + '') ;; + *) echo "usage: $0 [--tree]" >&2; exit 2 ;; +esac + TERMS_FILE="${PRIVATE_TERMS_FILE:-$HOME/.config/chezmoi/private-terms}" +require="${PRIVATE_REFS_REQUIRE_TERMS:-}" if [ ! -r "$TERMS_FILE" ]; then - # Fail OPEN, but loudly. Failing closed would block every commit on a machine - # not set up yet, including the commit that sets it up; silence would leave - # the repository unguarded with nothing to notice. + if [ -n "$require" ]; then + echo "private-refs: no term list at $TERMS_FILE and PRIVATE_REFS_REQUIRE_TERMS is set โ€” failing." >&2 + exit 1 + fi echo "private-refs: no term list at $TERMS_FILE โ€” NOT checking." >&2 echo " create it (one regex per line) or set PRIVATE_TERMS_FILE." >&2 exit 0 @@ -32,28 +54,63 @@ fi PATTERN=$(sed -e 's/#.*//' -e 's/[[:space:]]*$//' "$TERMS_FILE" \ | grep -v '^$' | paste -sd '|' -) -[ -n "$PATTERN" ] || exit 0 +if [ -z "$PATTERN" ]; then + if [ -n "$require" ]; then + echo "private-refs: the term list at $TERMS_FILE holds no term and PRIVATE_REFS_REQUIRE_TERMS is set โ€” failing." >&2 + exit 1 + fi + exit 0 +fi -staged=$(git diff --cached --name-only --diff-filter=ACM) -[ -n "$staged" ] || exit 0 +# The files to scan, one per line, spaces intact (no tracked path holds a +# newline). NUL from git, because a tracked path here contains a space and +# word-splitting used to skip it without a word. +files=$(mktemp "${TMPDIR:-/tmp}/private-refs.XXXXXX") +trap 'rm -f "$files"' EXIT INT TERM +if [ "$mode" = tree ]; then + git ls-files -z | tr '\0' '\n' > "$files" +else + git diff --cached --name-only -z --diff-filter=ACM | tr '\0' '\n' > "$files" +fi +[ -s "$files" ] || exit 0 found='' -for f in $staged; do +scanned=0 +while IFS= read -r f; do + [ -n "$f" ] || continue # ciphertext cannot match, and skipping it keeps the scan cheap case "$f" in *encrypted_*) continue ;; esac [ -f "$f" ] || continue - # the staged content, not the working tree: they can differ - if git show ":$f" 2>/dev/null | grep -qiE "$PATTERN"; then - found="$found $f" + scanned=$((scanned + 1)) + if [ "$mode" = tree ]; then + content() { cat "$f"; } + else + # the staged content, not the working tree: they can differ + content() { git show ":$f" 2>/dev/null; } fi -done + if content | grep -qiE "$PATTERN"; then + found="$found +$f" + fi +done < "$files" -[ -n "$found" ] || exit 0 +if [ -z "$found" ]; then + [ "$mode" = tree ] && echo " private-refs ok ($scanned plaintext files scanned)" + exit 0 +fi echo "private identifiers in plaintext, in a PUBLIC repository:" >&2 -for f in $found; do - echo " $f" >&2 - git show ":$f" | grep -inE "$PATTERN" | head -3 | sed 's/^/ /' >&2 +printf '%s\n' "$found" | sed '/^$/d' | while IFS= read -r f; do + if [ "$mode" = tree ]; then + if printf '%s' "$f" | grep -qiE "$PATTERN"; then + echo " " >&2 + else + echo " $f" >&2 + fi + else + echo " $f" >&2 + git show ":$f" | grep -inE "$PATTERN" | head -3 | sed 's/^/ /' >&2 + fi done cat >&2 <<'EOF' diff --git a/scripts/lint.sh b/scripts/lint.sh new file mode 100644 index 0000000..c77c6d2 --- /dev/null +++ b/scripts/lint.sh @@ -0,0 +1,49 @@ +#!/bin/sh +# `make lint`: shellcheck, pinned in .tools/, over every tracked shell +# script โ€” `*.sh`, plus any tracked file whose first line is a sh or bash +# shebang. fish and PowerShell are not shellcheck's business. The list is +# computed, printed and NUL-safe: a tracked path here contains a space. + +set -eu + +cd "$(dirname "$0")/.." +SHELLCHECK=.tools/shellcheck + +tmp=$(mktemp "${TMPDIR:-/tmp}/dotfiles-lint.XXXXXX") +trap 'rm -f "$tmp"' EXIT INT TERM + +# Tracked files, one per line, spaces intact (paths here hold no newline). +git ls-files -z | tr '\0' '\n' > "$tmp" +total=$(grep -c . "$tmp") + +n=0 +list="" +while IFS= read -r f; do + [ -f "$f" ] || continue + case "$f" in + *.sh) ;; + *) + # The first 64 bytes, NUL stripped: a tracked binary is not a script. + first=$(LC_ALL=C head -c 64 "$f" 2>/dev/null | tr -d '\0' | head -n 1 || true) + case "$first" in + '#!'*/sh|'#!'*/bash|'#!'*' sh'|'#!'*' bash'|'#!'*/sh' '*|'#!'*/bash' '*|'#!'*' sh '*|'#!'*' bash '*) ;; + *) continue ;; + esac ;; + esac + n=$((n + 1)) + echo " lint $f" + list="$list +$f" +done < "$tmp" + +if [ "$n" -eq 0 ]; then + echo "lint: no shell script found among $total tracked files" >&2 + exit 1 +fi + +# One shellcheck run over the list; a path with a space survives the loop. +printf '%s\n' "$list" | sed '/^$/d' | while IFS= read -r f; do + printf '%s\0' "$f" +done | xargs -0 "$SHELLCHECK" --external-sources + +echo " lint ok ($n scripts of $total tracked files)" diff --git a/scripts/render.sh b/scripts/render.sh new file mode 100644 index 0000000..04f5d96 --- /dev/null +++ b/scripts/render.sh @@ -0,0 +1,69 @@ +#!/bin/sh +# `make render`: every template renders against the placeholder data in +# ci/chezmoi.toml, into a temporary destination, hermetically โ€” no key +# (encrypted files excluded), no network (externals excluded), no script run +# (scripts excluded). The two run_once_* templates are the ones that call +# `include` and `lookPath`, so they are rendered on their own with +# execute-template. chezmoi's state file goes into the same temporary +# directory: it otherwise lands beside the config file and dirties the tree. + +set -eu + +cd "$(dirname "$0")/.." +CHEZMOI=.tools/chezmoi +CONFIG=ci/chezmoi.toml + +dst=$(mktemp -d "${TMPDIR:-/tmp}/dotfiles-render.XXXXXX") +trap 'rm -rf "$dst"' EXIT INT TERM +state="$dst/state.boltdb" + +run() { + "$CHEZMOI" --config "$CONFIG" --persistent-state "$state" --source . "$@" +} + +run --destination "$dst" apply --exclude encrypted,externals,scripts + +# What must have come out. +for f in .zshrc .claude/CLAUDE.md .claude/skills/worktree-task/SKILL.md; do + if [ ! -s "$dst/$f" ]; then + echo "render: $f is missing or empty in the rendered home" >&2 + exit 1 + fi +done +# What must never reach a home directory (see .chezmoiignore). +for f in Makefile tools.env ci amont.conf scripts docs README.md; do + if [ -e "$dst/$f" ]; then + echo "render: $f was deployed; it belongs to the repository, not a home โ€” add it to .chezmoiignore" >&2 + exit 1 + fi +done + +# The run_once_* templates, one by one. Listed with a git pathspec: `*` +# matches across `/`, and the two live at different depths. NUL-safe, since +# a tracked path in this repository contains a space. +n=0 +git ls-files -z -- '*run_once_*.tmpl' | tr '\0' '\n' > "$dst/run_once.list" +while IFS= read -r f; do + [ -n "$f" ] || continue + n=$((n + 1)) + echo " render $f" + if ! run execute-template < "$f" > /dev/null; then + echo "render: $f does not render" >&2 + exit 1 + fi +done < "$dst/run_once.list" +if [ "$n" -lt 2 ]; then + echo "render: expected at least 2 run_once_* templates, found $n" >&2 + exit 1 +fi + +# The config template itself, as `chezmoi init` would render it, answering +# the one prompt that has no default. With the ci config, promptStringOnce +# reads placeholders, never a workstation's real data. +if ! run execute-template --init --promptString "bitwarden server url=x" < .chezmoi.toml.tmpl \ + | grep -q 'encryption = "gpg"'; then + echo "render: .chezmoi.toml.tmpl does not render to a config that sets encryption" >&2 + exit 1 +fi + +echo " render ok ($n run_once templates, config template, home applied to a temp dir)" diff --git a/scripts/tools.sh b/scripts/tools.sh new file mode 100644 index 0000000..f27a2d7 --- /dev/null +++ b/scripts/tools.sh @@ -0,0 +1,95 @@ +#!/bin/sh +# `make tools`: the pinned tools into .tools/, from the release asset of the +# pinned tag, verified against the checksum in tools.env before extraction. +# Never PATH's copy: a workstation and a runner must run the same binary +# (toolchain.tools-pinned-with-it, toolchain.one-pin-read-by-both). +# +# Idempotent: a tool already present at its pinned version is kept, so a +# second run sends no request. + +set -eu + +cd "$(dirname "$0")/.." +# A plain KEY=value file; shellcheck cannot follow it without -x (SC1091). +# shellcheck disable=SC1091 +. ./tools.env + +TOOLS=.tools +DL="$TOOLS/dl" +mkdir -p "$DL" + +os=$(uname -s) +arch=$(uname -m) +case "$os/$arch" in + Linux/x86_64) chezmoi_asset=linux_amd64; shellcheck_asset=linux.x86_64 ;; + Darwin/x86_64) chezmoi_asset=darwin_amd64; shellcheck_asset=darwin.x86_64 ;; + Darwin/arm64) chezmoi_asset=darwin_arm64; shellcheck_asset=darwin.aarch64 ;; + *) + echo "tools: no pinned asset for $os/$arch (tools.env knows linux/x86_64, darwin/x86_64, darwin/arm64)" >&2 + exit 1 ;; +esac + +# The digest tool differs by platform; both print ` `. +if command -v shasum >/dev/null 2>&1; then + digest() { shasum -a 256 "$1" | cut -d' ' -f1; } +elif command -v sha256sum >/dev/null 2>&1; then + digest() { sha256sum "$1" | cut -d' ' -f1; } +else + echo "tools: neither shasum nor sha256sum is available" >&2 + exit 1 +fi + +# have : the tool is already there at the pinned version. +have() { + [ -x "$TOOLS/$1" ] && "$TOOLS/$1" --version 2>/dev/null | grep -qF "$2" +} + +# fetch +fetch() { + if ! curl -fsSL --retry 3 -o "$2" "$1"; then + echo "tools: could not download $1" >&2 + exit 1 + fi + got=$(digest "$2") + if [ "$got" != "$3" ]; then + echo "tools: checksum mismatch for $(basename "$2"): tools.env says $3, the download is $got" >&2 + rm -f "$2" + exit 1 + fi +} + +# expect : refuse a binary that is not the pin it claims. +expect() { + if ! "$TOOLS/$1" --version 2>/dev/null | grep -qF "$2"; then + echo "tools: $TOOLS/$1 does not report $2:" >&2 + "$TOOLS/$1" --version >&2 || true + exit 1 + fi + echo " tools $1 $2" +} + +# chezmoi: chezmoi___.tar.gz, binary at the archive root. +ver=${CHEZMOI#v} +if have chezmoi "$CHEZMOI"; then + echo " tools chezmoi $CHEZMOI (present)" +else + sum=$(eval "printf '%s' \"\${CHEZMOI_SHA256_$chezmoi_asset}\"") + tarball="$DL/chezmoi_${ver}_${chezmoi_asset}.tar.gz" + fetch "https://github.com/twpayne/chezmoi/releases/download/$CHEZMOI/chezmoi_${ver}_${chezmoi_asset}.tar.gz" "$tarball" "$sum" + tar -xzf "$tarball" -C "$TOOLS" chezmoi + expect chezmoi "$CHEZMOI" +fi + +# ShellCheck ships shellcheck-...tar.xz, binary under shellcheck-/. +if have shellcheck "${SHELLCHECK#v}"; then + echo " tools shellcheck $SHELLCHECK (present)" +else + key=$(printf '%s' "$shellcheck_asset" | tr . _) + sum=$(eval "printf '%s' \"\${SHELLCHECK_SHA256_$key}\"") + tarball="$DL/shellcheck-$SHELLCHECK.$shellcheck_asset.tar.xz" + fetch "https://github.com/koalaman/shellcheck/releases/download/$SHELLCHECK/shellcheck-$SHELLCHECK.$shellcheck_asset.tar.xz" "$tarball" "$sum" + tar -xJf "$tarball" -C "$DL" "shellcheck-$SHELLCHECK/shellcheck" + mv "$DL/shellcheck-$SHELLCHECK/shellcheck" "$TOOLS/shellcheck" + rmdir "$DL/shellcheck-$SHELLCHECK" + expect shellcheck "${SHELLCHECK#v}" +fi diff --git a/tools.env b/tools.env new file mode 100644 index 0000000..e8d8038 --- /dev/null +++ b/tools.env @@ -0,0 +1,19 @@ +# The only place a tool version is stated (toolchain.pinned-exactly-in-the-repository, +# toolchain.one-pin-read-by-both). `make tools` reads this file, downloads the +# release asset of the pinned tag into .tools/ and verifies it against the +# checksum beside it before extracting; nothing is ever taken from PATH. +# +# chezmoi's checksums come from its published chezmoi__checksums.txt. +# shellcheck publishes no checksums file, so its three were computed at pin +# time from the assets downloaded over HTTPS from the GitHub release: they pin +# what was reviewed, not what upstream attests. + +CHEZMOI=v2.72.1 +CHEZMOI_SHA256_linux_amd64=9f97d32caca166e5c92160ec3a9325519809c38963121cef38173142065c981f +CHEZMOI_SHA256_darwin_amd64=bf0f0e048291efe126cb8bc51cf566057b92755cd53ce82c45efa11d2f8f4898 +CHEZMOI_SHA256_darwin_arm64=938d422091cc001e68fe3fd7efea9b923a36facbf2b8db67063639abbaf72de2 + +SHELLCHECK=v0.11.0 +SHELLCHECK_SHA256_linux_x86_64=8c3be12b05d5c177a04c29e3c78ce89ac86f1595681cab149b65b97c4e227198 +SHELLCHECK_SHA256_darwin_x86_64=3c89db4edcab7cf1c27bff178882e0f6f27f7afdf54e859fa041fca10febe4c6 +SHELLCHECK_SHA256_darwin_aarch64=56affdd8de5527894dca6dc3d7e0a99a873b0f004d7aabc30ae407d3f48b0a79 From 50fc7e7f357d16f1bfbf2d7f03ad634b3b46cfc1 Mon Sep 17 00:00:00 2001 From: fredericrous Date: Wed, 30 Sep 2026 00:52:45 +0200 Subject: [PATCH 4/8] feat(ci): the workflow, the pre-push gate, the README .github/workflows/ci.yaml runs make check on ubuntu and macos for every push to main and every pull request: contents: read, checkout pinned by commit, the PRIVATE_TERMS secret through env: into $RUNNER_TEMP, required on a push or a same-repository pull request and left to the loud pass on a fork's. amont.conf runs make check at pre-push (warm 10 s on this Mac, under the 20 s ceiling). The README carries the badge and one paragraph. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/ci.yaml | 52 +++++++++++++++++++++++++++++++++++++++ README.md | 12 +++++++++ amont.conf | 2 ++ 3 files changed, 66 insertions(+) create mode 100644 .github/workflows/ci.yaml diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..00c5454 --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,52 @@ +# The same check a workstation runs (`make check`), on the two operating +# systems the templates branch on (ADR-0017, ci.mirrors-the-local-check). +# A red step here is a Makefile target to run at home. +name: CI + +on: + push: + branches: [main] + pull_request: + +# The default token in a public repository may carry write; nothing here +# needs more than a read. +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + check: + name: check + runs-on: ${{ matrix.os }} + timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + steps: + # Pinned by commit, the tag in the comment (toolchain.tools-pinned-with-it). + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v7.0.1 + + # The private terms, from the repository secret, into a file outside the + # workspace. Through `env:`, never in the script text. Required wherever + # the secret should exist โ€” a push, or a pull request from this + # repository; a fork's pull request has no secret and the check says + # so loudly and passes, as on a machine not set up yet. + - name: private terms + env: + PRIVATE_TERMS: ${{ secrets.PRIVATE_TERMS }} + FROM_THIS_REPOSITORY: ${{ github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository }} + run: | + if [ -n "$PRIVATE_TERMS" ]; then + printf '%s\n' "$PRIVATE_TERMS" > "$RUNNER_TEMP/private-terms" + echo "PRIVATE_TERMS_FILE=$RUNNER_TEMP/private-terms" >> "$GITHUB_ENV" + fi + if [ "$FROM_THIS_REPOSITORY" = "true" ]; then + echo "PRIVATE_REFS_REQUIRE_TERMS=1" >> "$GITHUB_ENV" + fi + + - name: make check + run: make check diff --git a/README.md b/README.md index 5304e08..70668fc 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,19 @@ # Dotfiles +[![CI](https://github.com/fredericrous/dotfiles/actions/workflows/ci.yaml/badge.svg)](https://github.com/fredericrous/dotfiles/actions/workflows/ci.yaml) + personal environment files +## Checks + +`make check` is what CI runs, with the same commands and the same pinned +tools (`tools.env`, installed into `.tools/`): every template renders +against placeholder data, the shell scripts pass shellcheck, and no tracked +plaintext file names private infrastructure. The private terms live outside +the tree (`~/.config/chezmoi/private-terms`) and, for CI, in the +`PRIVATE_TERMS` repository secret โ€” a copy: whoever edits the file re-sets +the secret with `gh secret set PRIVATE_TERMS < ~/.config/chezmoi/private-terms`. + ## Requirements import your gpg keys diff --git a/amont.conf b/amont.conf index 0824709..4a9c154 100644 --- a/amont.conf +++ b/amont.conf @@ -2,3 +2,5 @@ # # stage name scope severity command pre-commit private-refs * block scripts/check-private-refs.sh +# What CI runs, before the remote sees the push (ADR-0017). Warm: ~10 s. +pre-push check * block make check From 42a7f91bcf75591e4d0636c026d3139f34e42c42 Mon Sep 17 00:00:00 2001 From: fredericrous Date: Wed, 30 Sep 2026 00:53:38 +0200 Subject: [PATCH 5/8] fix(ci): shellcheck does not follow tools.env Co-Authored-By: Claude Fable 5.1 --- scripts/tools.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/tools.sh b/scripts/tools.sh index f27a2d7..ad79596 100644 --- a/scripts/tools.sh +++ b/scripts/tools.sh @@ -10,8 +10,8 @@ set -eu cd "$(dirname "$0")/.." -# A plain KEY=value file; shellcheck cannot follow it without -x (SC1091). -# shellcheck disable=SC1091 +# A plain KEY=value file, not a script: shellcheck is told not to follow it. +# shellcheck source=/dev/null . ./tools.env TOOLS=.tools From 42d0177fb893144f61e4d6f175b140c04b0a6f8c Mon Sep 17 00:00:00 2001 From: fredericrous Date: Wed, 30 Sep 2026 00:57:23 +0200 Subject: [PATCH 6/8] fix(ci): what the implementation review found An invalid regex in the terms file made grep exit 2, which the check read as no match: now a failure naming the file. The workflow runs the four targets as four steps, so a red step is the target to run at home. Status lines go to stderr; the helper scripts' usage goes there too and exits 2; lint no longer hides a read error behind || true. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/ci.yaml | 14 +++++++++++--- Makefile | 2 +- .../git/bin/executable_git-author-prev | 4 ++-- .../git/bin/executable_git-author-rewrite | 4 ++-- private_dot_config/git/bin/executable_git-coauthor | 4 ++-- private_dot_config/git/bin/executable_git-fixup | 4 ++-- .../git/bin/executable_git-replace-history | 4 ++-- .../git/bin/executable_git-rm-history | 4 ++-- scripts/check-private-refs.sh | 11 ++++++++++- scripts/lint.sh | 6 +++--- scripts/render.sh | 4 ++-- scripts/tools.sh | 6 +++--- 12 files changed, 42 insertions(+), 25 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 00c5454..944747b 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1,6 +1,6 @@ # The same check a workstation runs (`make check`), on the two operating # systems the templates branch on (ADR-0017, ci.mirrors-the-local-check). -# A red step here is a Makefile target to run at home. +# A red step here is the Makefile target to run at home. name: CI on: @@ -48,5 +48,13 @@ jobs: echo "PRIVATE_REFS_REQUIRE_TERMS=1" >> "$GITHUB_ENV" fi - - name: make check - run: make check + # The four targets `make check` runs, one step each, so a red step is + # the target to run at home. + - name: make tools + run: make tools + - name: make render + run: make render + - name: make lint + run: make lint + - name: make private-refs + run: make private-refs diff --git a/Makefile b/Makefile index e9d26c2..617755f 100644 --- a/Makefile +++ b/Makefile @@ -6,7 +6,7 @@ .POSIX: check: tools render lint private-refs - @echo " ok tools render lint private-refs" + @echo " ok tools render lint private-refs" >&2 # Pinned tools into .tools/ (tools.env); never the machine's. tools: diff --git a/private_dot_config/git/bin/executable_git-author-prev b/private_dot_config/git/bin/executable_git-author-prev index 83224ac..f4a98c8 100644 --- a/private_dot_config/git/bin/executable_git-author-prev +++ b/private_dot_config/git/bin/executable_git-author-prev @@ -3,8 +3,8 @@ SCRIPT_NAME=$(basename "$0" | sed 's/git-//') if [ "$#" -ne 2 ]; then - printf 'Usage:\n git %s \n' "$SCRIPT_NAME" - exit 1; + printf 'Usage:\n git %s \n' "$SCRIPT_NAME" >&2 + exit 2 fi git commit --amend --author "$1 <$2>" -C HEAD diff --git a/private_dot_config/git/bin/executable_git-author-rewrite b/private_dot_config/git/bin/executable_git-author-rewrite index d220b50..ee1b43e 100644 --- a/private_dot_config/git/bin/executable_git-author-rewrite +++ b/private_dot_config/git/bin/executable_git-author-rewrite @@ -3,8 +3,8 @@ SCRIPT_NAME=$(basename "$0" | sed 's/git-//') if [ "$#" -eq 0 ] || [ "$#" -lt 3 ]; then - printf 'Usage:\n git %s \n' "$SCRIPT_NAME" - exit 1; + printf 'Usage:\n git %s \n' "$SCRIPT_NAME" >&2 + exit 2 fi git filter-repo --mailmap <(echo "$2 <$3> <$1>") "${@:4}" diff --git a/private_dot_config/git/bin/executable_git-coauthor b/private_dot_config/git/bin/executable_git-coauthor index ec0c044..3c0a233 100644 --- a/private_dot_config/git/bin/executable_git-coauthor +++ b/private_dot_config/git/bin/executable_git-coauthor @@ -4,8 +4,8 @@ SCRIPT_NAME=$(basename "$0" | sed 's/git-//') if [ "$#" -ne 1 ]; then - printf 'Usage:\n git %s \n' "$SCRIPT_NAME" - exit 1; + printf 'Usage:\n git %s \n' "$SCRIPT_NAME" >&2 + exit 2 fi set -e -o pipefail diff --git a/private_dot_config/git/bin/executable_git-fixup b/private_dot_config/git/bin/executable_git-fixup index d3a1f71..d273c48 100644 --- a/private_dot_config/git/bin/executable_git-fixup +++ b/private_dot_config/git/bin/executable_git-fixup @@ -3,8 +3,8 @@ SCRIPT_NAME=$(basename "$0" | sed 's/git-//') if [ "$#" -eq 0 ]; then - printf 'Usage:\n git %s \n' "$SCRIPT_NAME" - exit 1; + printf 'Usage:\n git %s \n' "$SCRIPT_NAME" >&2 + exit 2 fi SHA=$(git rev-parse "$1") diff --git a/private_dot_config/git/bin/executable_git-replace-history b/private_dot_config/git/bin/executable_git-replace-history index 4bdba63..ab44596 100644 --- a/private_dot_config/git/bin/executable_git-replace-history +++ b/private_dot_config/git/bin/executable_git-replace-history @@ -3,8 +3,8 @@ SCRIPT_NAME=$(basename "$0" | sed 's/git-//') if [ "$#" -eq 0 ] || [ "$#" -gt 2 ]; then - printf 'Usage:\n git %s [, default REMOVED]\n' "$SCRIPT_NAME" - exit 1; + printf 'Usage:\n git %s [, default REMOVED]\n' "$SCRIPT_NAME" >&2 + exit 2 fi git filter-repo --force --replace-text <(echo "$1==>${2:-REMOVED}") diff --git a/private_dot_config/git/bin/executable_git-rm-history b/private_dot_config/git/bin/executable_git-rm-history index 7db78c9..88244f9 100644 --- a/private_dot_config/git/bin/executable_git-rm-history +++ b/private_dot_config/git/bin/executable_git-rm-history @@ -3,8 +3,8 @@ SCRIPT_NAME=$(basename "$0" | sed 's/git-//') if [ "$#" -eq 0 ]; then - printf 'Usage:\n git %s [...args]\n' "$SCRIPT_NAME" - exit 1; + printf 'Usage:\n git %s [...args]\n' "$SCRIPT_NAME" >&2 + exit 2 fi git filter-repo --path "$1" --invert-paths "${@:2}" diff --git a/scripts/check-private-refs.sh b/scripts/check-private-refs.sh index 53818b3..983f578 100755 --- a/scripts/check-private-refs.sh +++ b/scripts/check-private-refs.sh @@ -61,6 +61,15 @@ if [ -z "$PATTERN" ]; then fi exit 0 fi +# A term list that is not a valid extended regex would make every grep below +# exit 2, which `if` reads as "no match": a silent pass. Refuse it instead, +# naming the file and never the terms. +if printf '' | grep -qiE "$PATTERN"; then :; else + if [ $? -gt 1 ]; then + echo "private-refs: the term list at $TERMS_FILE is not a valid extended regex โ€” failing." >&2 + exit 1 + fi +fi # The files to scan, one per line, spaces intact (no tracked path holds a # newline). NUL from git, because a tracked path here contains a space and @@ -95,7 +104,7 @@ $f" done < "$files" if [ -z "$found" ]; then - [ "$mode" = tree ] && echo " private-refs ok ($scanned plaintext files scanned)" + [ "$mode" = tree ] && echo " private-refs ok ($scanned plaintext files scanned)" >&2 exit 0 fi diff --git a/scripts/lint.sh b/scripts/lint.sh index c77c6d2..ba1223e 100644 --- a/scripts/lint.sh +++ b/scripts/lint.sh @@ -24,14 +24,14 @@ while IFS= read -r f; do *.sh) ;; *) # The first 64 bytes, NUL stripped: a tracked binary is not a script. - first=$(LC_ALL=C head -c 64 "$f" 2>/dev/null | tr -d '\0' | head -n 1 || true) + first=$(LC_ALL=C head -c 64 "$f" | tr -d '\0' | head -n 1) case "$first" in '#!'*/sh|'#!'*/bash|'#!'*' sh'|'#!'*' bash'|'#!'*/sh' '*|'#!'*/bash' '*|'#!'*' sh '*|'#!'*' bash '*) ;; *) continue ;; esac ;; esac n=$((n + 1)) - echo " lint $f" + echo " lint $f" >&2 list="$list $f" done < "$tmp" @@ -46,4 +46,4 @@ printf '%s\n' "$list" | sed '/^$/d' | while IFS= read -r f; do printf '%s\0' "$f" done | xargs -0 "$SHELLCHECK" --external-sources -echo " lint ok ($n scripts of $total tracked files)" +echo " lint ok ($n scripts of $total tracked files)" >&2 diff --git a/scripts/render.sh b/scripts/render.sh index 04f5d96..88fe00d 100644 --- a/scripts/render.sh +++ b/scripts/render.sh @@ -46,7 +46,7 @@ git ls-files -z -- '*run_once_*.tmpl' | tr '\0' '\n' > "$dst/run_once.list" while IFS= read -r f; do [ -n "$f" ] || continue n=$((n + 1)) - echo " render $f" + echo " render $f" >&2 if ! run execute-template < "$f" > /dev/null; then echo "render: $f does not render" >&2 exit 1 @@ -66,4 +66,4 @@ if ! run execute-template --init --promptString "bitwarden server url=x" < .chez exit 1 fi -echo " render ok ($n run_once templates, config template, home applied to a temp dir)" +echo " render ok ($n run_once templates, config template, home applied to a temp dir)" >&2 diff --git a/scripts/tools.sh b/scripts/tools.sh index ad79596..813c3e7 100644 --- a/scripts/tools.sh +++ b/scripts/tools.sh @@ -65,13 +65,13 @@ expect() { "$TOOLS/$1" --version >&2 || true exit 1 fi - echo " tools $1 $2" + echo " tools $1 $2" >&2 } # chezmoi: chezmoi___.tar.gz, binary at the archive root. ver=${CHEZMOI#v} if have chezmoi "$CHEZMOI"; then - echo " tools chezmoi $CHEZMOI (present)" + echo " tools chezmoi $CHEZMOI (present)" >&2 else sum=$(eval "printf '%s' \"\${CHEZMOI_SHA256_$chezmoi_asset}\"") tarball="$DL/chezmoi_${ver}_${chezmoi_asset}.tar.gz" @@ -82,7 +82,7 @@ fi # ShellCheck ships shellcheck-...tar.xz, binary under shellcheck-/. if have shellcheck "${SHELLCHECK#v}"; then - echo " tools shellcheck $SHELLCHECK (present)" + echo " tools shellcheck $SHELLCHECK (present)" >&2 else key=$(printf '%s' "$shellcheck_asset" | tr . _) sum=$(eval "printf '%s' \"\${SHELLCHECK_SHA256_$key}\"") From 477d247138f90b1e03cc27b9d64b65bd325ee5f0 Mon Sep 17 00:00:00 2001 From: fredericrous Date: Wed, 30 Sep 2026 00:59:18 +0200 Subject: [PATCH 7/8] docs(plan): implementation review Co-Authored-By: Claude Fable 5.1 --- docs/plans/2026-09-30-dotfiles-ci.md | 36 ++++++++++++++++++++++++---- 1 file changed, 32 insertions(+), 4 deletions(-) diff --git a/docs/plans/2026-09-30-dotfiles-ci.md b/docs/plans/2026-09-30-dotfiles-ci.md index 0731b79..7762d37 100644 --- a/docs/plans/2026-09-30-dotfiles-ci.md +++ b/docs/plans/2026-09-30-dotfiles-ci.md @@ -16,6 +16,13 @@ adrs: [ADR-0017] ๐Ÿ“„ Full reviews: [2026-09-30-dotfiles-ci.reviews.md](2026-09-30-dotfiles-ci.reviews.md) +## Implementation review + +Round 1 `approve-with-changes` (52k tokens, 78 s), Delta `approve-with-changes` (31k, 30 s), tree `19bd0a5e4c1d`. +Fixed: an invalid regex in the terms file passed silently (grep exit 2 read as no match) โ€” real, would have shipped; four workflow steps to match the targets; status lines and usage to stderr; usage exits 2; no `|| true` in lint's shebang read. +Deliberate: lint's unreadable-file guard (`[ -r "$f" ]`) is the next change's, not a third pass; the checksum wording in Behaviour now matches the code (`digest` + compare, not `shasum -c`). +Record-only items (phase ticks, this section) live under `docs/plans/`, outside the canonical tree, so the reviewed tree is the pushed tree. + ## Context `fredericrous/dotfiles` is a public GitHub repository with an amont @@ -78,8 +85,8 @@ repository has no language of its own. publishes no checksums file, so its three are computed at pin time from the asset downloaded over HTTPS from the GitHub release, and pin what was reviewed rather than what upstream attests. The Makefile - verifies every download with `shasum -a 256 -c` before extracting; a - mismatch fails naming the asset. An unknown host is a failure that + verifies every download โ€” `shasum -a 256` (or `sha256sum`) compared to + the pinned digest โ€” before extracting; a mismatch fails naming the asset. An unknown host is a failure that names the host, not a fallback to PATH. - Afterwards `.tools/chezmoi --version` and `.tools/shellcheck --version` must contain their pins, or the target fails. @@ -171,9 +178,9 @@ repository has no language of its own. ## Phases -- [ ] Phase 1 โ€” this plan, `docs/plans/2026-09-30-dotfiles-ci.md`, the +- [x] Phase 1 โ€” this plan, `docs/plans/2026-09-30-dotfiles-ci.md`, the first commit. -- [ ] Phase 2 โ€” `tools.env` (versions and asset checksums), `Makefile` +- [x] Phase 2 โ€” `tools.env` (versions and asset checksums), `Makefile` (`tools`, `render`, `lint`, `private-refs`, `check`), `ci/chezmoi.toml`, `.gitignore`, `.chezmoiignore` entries, and the `--tree` mode of `scripts/check-private-refs.sh` (names only, `PRIVATE_REFS_REQUIRE_TERMS`). @@ -237,6 +244,27 @@ repository has no language of its own. - `make render` with `run_once_fisher.fish.tmpl` broken on purpose (an unclosed `{{`) โ†’ fails naming that file (falsification), then restored. - `make check` โ†’ all four, green, wall time recorded. + - Phase 2 actuals (2026-09-30, this Mac, Intel): cold `make check` 15 s, + warm 9โ€“10 s (pre-push keeps the full target, under 20 s); `make tools` + twice โ†’ second run "(present)" for both, no request; wrong version โ†’ + "tools: could not download โ€ฆ/v0.11.99/โ€ฆ"; one checksum digit โ†’ "tools: + checksum mismatch for shellcheck-v0.11.0.darwin.x86_64.tar.xz"; + `git status --porcelain` after `make check` โ†’ only the intended files; + `chezmoi managed` โ†’ none of `Makefile`, `tools.env`, `ci`; broken + `run_once_fisher.fish.tmpl` โ†’ "render: โ€ฆ does not render"; broken + `dot_claude/CLAUDE.md.tmpl` โ†’ chezmoi names it ("unclosed action โ€ฆ + CLAUDE.md.tmpl:336"); an unquoted-loop script โ†’ 2 ร— SC2086, exit 1; a + README word as a term โ†’ exit 1 naming `README.md`, line not shown; the + real terms โ†’ 0 hits over 96 files (= tracked minus `encrypted_*`); no + terms file โ†’ "NOT checking", exit 0; `PRIVATE_REFS_REQUIRE_TERMS=1` + with no file, with a comments-only file, and with `[unclosed` โ†’ exit 1 + each; a term only in the include line of the `Application Support` + settings template โ†’ that path named (the NUL-safe loop reaches it); + the term `code` โ†’ `` ร—4, the path never printed. + shellcheck baseline on the tracked scripts before the fixes: 1 finding + in the three scripts the plan named, 20 more in the six git helpers + the shebang rule pulled in, all fixed (two were real bugs: literal + backslash-quotes to `git commit`, `${2:REMOVED}` for a default). - GitHub Actions: the PR's `check (ubuntu-latest)` and `check (macos-latest)` โ†’ both green; the run's step names are the target names; the downloaded run log contains no `NOT checking` line and no term from From ad7063b6381e7f2974b4543f76a5e0bfb0229fec Mon Sep 17 00:00:00 2001 From: fredericrous Date: Wed, 30 Sep 2026 01:02:48 +0200 Subject: [PATCH 8/8] docs(plan): phase 3 verified Co-Authored-By: Claude Fable 5.1 --- docs/plans/2026-09-30-dotfiles-ci.md | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/docs/plans/2026-09-30-dotfiles-ci.md b/docs/plans/2026-09-30-dotfiles-ci.md index 7762d37..d70d517 100644 --- a/docs/plans/2026-09-30-dotfiles-ci.md +++ b/docs/plans/2026-09-30-dotfiles-ci.md @@ -190,7 +190,7 @@ repository has no language of its own. `--tree` hit count (file names) against the real terms file โ€” a hit already in the tree is fixed in this phase, since `--tree` fails on it where the staged mode never did. -- [ ] Phase 3 โ€” `.github/workflows/ci.yaml`, the `amont.conf` pre-push +- [x] Phase 3 โ€” `.github/workflows/ci.yaml`, the `amont.conf` pre-push entry, the README line. **Before the push**: `gh secret set PRIVATE_TERMS --repo fredericrous/dotfiles < ~/.config/chezmoi/private-terms` (stdin, never argv), otherwise the first same-repository run fails by @@ -198,7 +198,9 @@ repository has no language of its own. runners green, merge. - [ ] Phase 4 โ€” record the F4b cycle (tokens, seconds, verdict, whether each finding was real) in the implementation-review plan's Verification, - in decisions, as its Phase 4; set that plan's Phase 4 ticked. + in decisions, as its Phase 4; set that plan's Phase 4 ticked. (Opened + right after this PR merges; this plan's tick rides the next dotfiles + change.) ## Decision log @@ -269,6 +271,12 @@ repository has no language of its own. (macos-latest)` โ†’ both green; the run's step names are the target names; the downloaded run log contains no `NOT checking` line and no term from the secret. + - dotfiles#14, run 36642805700 (2026-09-30): ubuntu 6 s, macos 12 s, both + `success`; steps `make tools`, `make render`, `make lint`, `make + private-refs`; the log holds 0 `NOT checking` lines, 0 matches of any + term, `private-refs ok (96 plaintext files scanned)` on both runners, + both tools reporting their pins on both. The `PRIVATE_TERMS` secret was + set before the push with `gh secret set โ€ฆ < file`. - A deliberate hit on a throwaway branch (a placeholder term added to the secret's file locally, `--tree` run with it) โ†’ exit 1 naming the file and NOT printing the term. @@ -279,6 +287,13 @@ repository has no language of its own. fixed or `deliberate:`; the push โ†’ silent, journal `unconfirmed reviewed` (the hook's live `reviewed` case, still unverified). Tokens and seconds recorded here and in the implementation-review plan. + - 2026-09-30: block `repo=chezmoi sha=19bd0a5eโ€ฆ`; round 1 + approve-with-changes (52k, 78 s, 6 findings, 1 real bug: the invalid + regex); Delta approve-with-changes (31k, 30 s); the push of this branch + โ†’ the hook silent, one journal line `implementation-review unconfirmed + reviewed`, pass file `by-tree/chezmoi/19bd0a5eโ€ฆ.json` written by the + hook. Found on the hook itself: its Bash guard refused a read-only `ls` + of the pass files (amont-agent#61). ## Outcome