From 69abe3f1bc3b8647451b0051960d978f344562aa Mon Sep 17 00:00:00 2001 From: bougyman's bot Date: Fri, 4 Sep 2026 22:23:57 -0400 Subject: [PATCH 01/11] feat(precommit): complete 11-step gate; convert mix ci to alias - Insert root `mix format --check-formatted` and `mix test` (steps 3-4) between the metadata guards and the app checks in mix precommit. These cover the root project's own sources and validator tests. - Update the moduledoc to list all 11 steps with correct numbering. - Convert mix ci into a thin alias that delegates entirely to Mix.Tasks.Precommit (both run/1 and the testable run/2 passthrough). - Update precommit_test to assert all 11 steps in order. - Replace ci_test's independent step list with delegation assertions. Co-Authored-By: Claude Sonnet 4.6 --- lib/mix/tasks/ci.ex | 36 +++++++------------------------ lib/mix/tasks/precommit.ex | 20 ++++++++++------- test/mix/tasks/ci_test.exs | 24 +++++++++++++++------ test/mix/tasks/precommit_test.exs | 2 ++ 4 files changed, 40 insertions(+), 42 deletions(-) diff --git a/lib/mix/tasks/ci.ex b/lib/mix/tasks/ci.ex index 3b6dccc..3ddc75f 100644 --- a/lib/mix/tasks/ci.ex +++ b/lib/mix/tasks/ci.ex @@ -1,47 +1,27 @@ defmodule Mix.Tasks.Ci do - @shortdoc "Runs the complete quality gate against app/" + @shortdoc "Compatibility alias for mix precommit" @moduledoc """ #{@shortdoc}. mix ci - Runs every check `.github/workflows/ci.yaml`'s `test` job runs on a pull - request, in the same order, so a green `mix ci` locally predicts a green - CI run — and CI itself calls this task, so there's one place to fix if - either ever breaks: + Delegates to `Mix.Tasks.Precommit`, which is the canonical full-repository + quality gate. Kept for backwards compatibility with scripts and CI + configurations that call `mix ci` directly. - 1. `mix deps.get` — ensure deps are present - 2. `mix hex.audit` — reject retired or vulnerable Hex packages - 3. `mix deps.audit` — scan dependencies for known security advisories - 4. `mix format --check-formatted` — code is formatted - 5. `mix credo --strict` — static analysis (style, complexity, common bugs) - 6. `mix usage_rules.sync --check` — usage rules are in sync with deps - (catches drift introduced by a dep bump without re-running the sync; - see #79) - 7. `mix test` — all tests pass - - All steps run inside `app/`. + See `mix help precommit` for the complete step list. """ use Mix.Task - alias RepoTasks.Shell - @impl Mix.Task def run(argv) do - run(argv, &Shell.run!/3) + Mix.Tasks.Precommit.run(argv) end @doc false - def run(_argv, shell) do - shell.("mix", ["deps.get"], cd: "app") - shell.("mix", ["hex.audit"], cd: "app") - shell.("mix", ["deps.audit"], cd: "app") - shell.("mix", ["format", "--check-formatted"], cd: "app") - shell.("mix", ["credo", "--strict"], cd: "app") - shell.("mix", ["usage_rules.sync", "--check"], cd: "app") - shell.("mix", ["test"], cd: "app") - :ok + def run(argv, shell) do + Mix.Tasks.Precommit.run(argv, shell) end end diff --git a/lib/mix/tasks/precommit.ex b/lib/mix/tasks/precommit.ex index 520d0ba..c871301 100644 --- a/lib/mix/tasks/precommit.ex +++ b/lib/mix/tasks/precommit.ex @@ -14,13 +14,15 @@ defmodule Mix.Tasks.Precommit do pull request title when `PULL_REQUEST_TITLE_REQUIRED=true` 2. `ci/validate_commit_range.sh` — validate every commit since the branch diverged from its base - 3. `mix deps.get` — ensure app dependencies are present - 4. `mix hex.audit` — reject retired or vulnerable Hex packages - 5. `mix deps.audit` — scan dependencies for known security advisories - 6. `mix format --check-formatted` — check formatting - 7. `mix credo --strict` — run static analysis - 8. `mix usage_rules.sync --check` — catch usage-rule drift after dep bumps - 9. `mix test` — run the app test suite + 3. `mix format --check-formatted` — check root project formatting + 4. `mix test` — run the root project test suite (validator and task tests) + 5. `mix deps.get` — ensure app dependencies are present + 6. `mix hex.audit` — reject retired or vulnerable Hex packages + 7. `mix deps.audit` — scan dependencies for known security advisories + 8. `mix format --check-formatted` — check app formatting + 9. `mix credo --strict` — run static analysis + 10. `mix usage_rules.sync --check` — catch usage-rule drift after dep bumps + 11. `mix test` — run the app test suite Pull request metadata does not exist before a pull request is opened, so local runs skip only the title guard. GitHub Actions sets both @@ -28,7 +30,7 @@ defmodule Mix.Tasks.Precommit do a missing, empty, or non-conventional title then fails this task. Commit subjects are always validated. - All Mix quality steps run inside `app/`. + Steps 1-4 run from the repo root; steps 5-11 run inside `app/`. """ use Mix.Task @@ -44,6 +46,8 @@ defmodule Mix.Tasks.Precommit do def run([], shell) do shell.("./ci/validate_pull_request_title.sh", [], []) shell.("./ci/validate_commit_range.sh", [], []) + shell.("mix", ["format", "--check-formatted"], []) + shell.("mix", ["test"], []) shell.("mix", ["deps.get"], cd: "app") shell.("mix", ["hex.audit"], cd: "app") shell.("mix", ["deps.audit"], cd: "app") diff --git a/test/mix/tasks/ci_test.exs b/test/mix/tasks/ci_test.exs index 82e6090..78f689f 100644 --- a/test/mix/tasks/ci_test.exs +++ b/test/mix/tasks/ci_test.exs @@ -3,7 +3,7 @@ defmodule Mix.Tasks.CiTest do alias Mix.Tasks.Ci - test "runs all quality gate steps in order" do + test "delegates to mix precommit" do caller = self() shell = fn cmd, args, opts -> @@ -13,10 +13,22 @@ defmodule Mix.Tasks.CiTest do assert :ok = Ci.run([], shell) - assert_receive {:run, "mix", ["deps.get"], [cd: "app"]} - assert_receive {:run, "mix", ["hex.audit"], [cd: "app"]} - assert_receive {:run, "mix", ["format", "--check-formatted"], [cd: "app"]} - assert_receive {:run, "mix", ["usage_rules.sync", "--check"], [cd: "app"]} - assert_receive {:run, "mix", ["test"], [cd: "app"]} + assert_received {:run, "./ci/validate_pull_request_title.sh", [], []} + assert_received {:run, "./ci/validate_commit_range.sh", [], []} + assert_received {:run, "mix", ["format", "--check-formatted"], []} + assert_received {:run, "mix", ["test"], []} + assert_received {:run, "mix", ["deps.get"], [cd: "app"]} + assert_received {:run, "mix", ["hex.audit"], [cd: "app"]} + assert_received {:run, "mix", ["deps.audit"], [cd: "app"]} + assert_received {:run, "mix", ["format", "--check-formatted"], [cd: "app"]} + assert_received {:run, "mix", ["credo", "--strict"], [cd: "app"]} + assert_received {:run, "mix", ["usage_rules.sync", "--check"], [cd: "app"]} + assert_received {:run, "mix", ["test"], [cd: "app"]} + end + + test "rejects arguments via precommit" do + assert_raise Mix.Error, "Usage: mix precommit", fn -> + Ci.run(["unexpected"], fn _, _, _ -> :ok end) + end end end diff --git a/test/mix/tasks/precommit_test.exs b/test/mix/tasks/precommit_test.exs index c55bd49..8142be5 100644 --- a/test/mix/tasks/precommit_test.exs +++ b/test/mix/tasks/precommit_test.exs @@ -15,6 +15,8 @@ defmodule Mix.Tasks.PrecommitTest do assert_receive {:run, "./ci/validate_pull_request_title.sh", [], []} assert_receive {:run, "./ci/validate_commit_range.sh", [], []} + assert_receive {:run, "mix", ["format", "--check-formatted"], []} + assert_receive {:run, "mix", ["test"], []} assert_receive {:run, "mix", ["deps.get"], [cd: "app"]} assert_receive {:run, "mix", ["hex.audit"], [cd: "app"]} assert_receive {:run, "mix", ["deps.audit"], [cd: "app"]} From d0fe8b4a66ebbcf31577c37f8e10940091a08b0c Mon Sep 17 00:00:00 2001 From: bougyman's bot Date: Fri, 4 Sep 2026 22:24:02 -0400 Subject: [PATCH 02/11] docs: point quality command to mix precommit - .ai/prompts/implement.md: replace `mix ci` with `mix precommit` in the quality-suite step. - Readme.adoc: present `mix precommit` as the canonical full-repository quality command; retain focused single-project commands below it. Co-Authored-By: Claude Sonnet 4.6 --- .ai/prompts/implement.md | 2 +- Readme.adoc | 9 ++++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/.ai/prompts/implement.md b/.ai/prompts/implement.md index 827699c..c0f499f 100644 --- a/.ai/prompts/implement.md +++ b/.ai/prompts/implement.md @@ -36,7 +36,7 @@ necessary for release-please to pick up our squash merge commits to main. ``` 4. Implement the changes with clean, logical commits. 5. Run the full quality suite: - - mix ci + - mix precommit 6. Fix any failures before proceeding. 7. Push the branch and create a PR: ``` diff --git a/Readme.adoc b/Readme.adoc index ebbb59e..22e907c 100644 --- a/Readme.adoc +++ b/Readme.adoc @@ -476,7 +476,14 @@ $ mix lc whoami $ mix lc issue list --output json ---- -The project uses ExUnit and `mix format`. Run tests with: +The project uses ExUnit and `mix format`. Run the full quality gate with: + +[source,sh] +---- +$ mix precommit +---- + +To run only the app test suite or format check directly: [source,sh] ---- From 7ff6d97630194cb46ed5ad3e3fe588d902545b51 Mon Sep 17 00:00:00 2001 From: bougyman's bot Date: Fri, 4 Sep 2026 22:41:56 -0400 Subject: [PATCH 03/11] fix(ci): resolve precommit comparison base --- .github/workflows/ci.yaml | 7 ++++-- ci/validate_commit_range.sh | 47 ++++++++++++++++++++++++++++--------- test/git_hooks_test.exs | 16 +++++++++++++ 3 files changed, 57 insertions(+), 13 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 3b0f552..286a40a 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -28,6 +28,11 @@ jobs: steps: - uses: actions/checkout@v7 + with: + # The repository-level precommit task validates every commit since + # the merge base. Retain the full range so its shell guard can + # resolve that base without a second network fetch. + fetch-depth: 0 - uses: erlef/setup-beam@v1 with: @@ -157,6 +162,4 @@ jobs: # workflow_call), where github.event.pull_request is unset. ref: ${{ github.event.pull_request.head.sha || github.sha }} - - env: - FETCH_BASE_REF: "true" run: ./ci/validate_commit_range.sh diff --git a/ci/validate_commit_range.sh b/ci/validate_commit_range.sh index 523ee3e..298d532 100755 --- a/ci/validate_commit_range.sh +++ b/ci/validate_commit_range.sh @@ -8,8 +8,7 @@ usage() { $0 Environment: - BASE_REF Base branch or ref. Defaults to GITHUB_BASE_REF, then origin/main. - FETCH_BASE_REF When "true", fetch BASE_REF from origin before validating. + BASE_REF Base branch or ref. Defaults to GITHUB_BASE_REF, then main. EOT } @@ -37,9 +36,7 @@ validator="$repo_top/ci/validate_conventional_subject.sh" [ -x "$validator" ] || die "validator is not executable: $validator" -base_input=${BASE_REF:-${GITHUB_BASE_REF:-}} -base_name=${base_input#refs/heads/} -base_name=${base_name#origin/} +base_input=${BASE_REF:-${GITHUB_BASE_REF:-main}} base_ref= @@ -49,20 +46,22 @@ then # already contains it, and using the immutable SHA ensures the newly # pushed main commit is validated instead of comparing main to itself. base_ref_candidates="$base_input" -elif [ -n "$base_input" ] -then - if [ "${FETCH_BASE_REF:-}" = "true" ] + + if ! git rev-parse --verify --quiet "$base_input" >/dev/null then - git_or_die fetch --no-tags origin "$base_name:refs/remotes/origin/$base_name" >/dev/null + # A shallow GitHub checkout may contain only HEAD. Fetch the precise + # pre-push SHA here rather than requiring workflow-specific setup. + git_or_die fetch --no-tags origin "$base_input" >/dev/null fi +else + base_name=${base_input#refs/heads/} + base_name=${base_name#origin/} # Prefer the remote-tracking ref. In particular, a developer pushing # directly from local main must compare against origin/main, not against # local main (HEAD), or the range would be empty and a bypassed commit-msg # hook could slip through pre-push validation. base_ref_candidates="origin/$base_name $base_input $base_name" -else - base_ref_candidates="origin/main main" fi for candidate in $base_ref_candidates @@ -74,6 +73,32 @@ do fi done +[ -n "$base_ref" ] || { + if [[ "$base_input" =~ ^[0-9a-fA-F]{40}$ ]] + then + # `git fetch origin ` normally makes the object directly + # addressable. Keep FETCH_HEAD as a fallback for Git servers that do + # not install an anonymous remote-tracking ref for a SHA request. + base_ref_candidates="$base_input FETCH_HEAD" + else + # Local clones normally already have origin/main (or their configured + # BASE_REF), so this branch is not taken locally. GitHub Actions' + # default shallow checkout does not; fetch the missing base from here + # so callers never need a workflow-level FETCH_BASE_REF switch. + git_or_die fetch --no-tags origin "$base_name:refs/remotes/origin/$base_name" >/dev/null + base_ref_candidates="origin/$base_name $base_input $base_name" + fi + + for candidate in $base_ref_candidates + do + if git rev-parse --verify --quiet "$candidate" >/dev/null + then + base_ref=$candidate + break + fi + done +} + [ -n "$base_ref" ] || die "unable to resolve commit comparison base" base_sha=$(git_or_die merge-base HEAD "$base_ref") diff --git a/test/git_hooks_test.exs b/test/git_hooks_test.exs index f00a45f..9655a69 100644 --- a/test/git_hooks_test.exs +++ b/test/git_hooks_test.exs @@ -115,6 +115,22 @@ defmodule GitHooksTest do assert {"", 0} = run(@range_guard, [], cd: worktree) end + test "the range guard fetches a missing local default base from origin" do + {worktree, _} = setup_ci_worktree!() + + # Mimic a shallow CI checkout: the remote has main, while neither a local + # main branch nor origin/main is available to resolve without a fetch. + git!(worktree, ["branch", "-m", "main", "feature"]) + git!(worktree, ["update-ref", "-d", "refs/remotes/origin/main"]) + + File.write!(Path.join(worktree, "README"), "bad commit\n", [:append]) + git!(worktree, ["add", "README"]) + git!(worktree, ["commit", "-m", "this is not conventional"]) + + assert {output, 1} = run(@range_guard, [], cd: worktree) + assert output =~ "this is not conventional" + end + test "the range guard reports all invalid subjects in a mixed commit range" do {worktree, _} = setup_ci_worktree!() From 1c55ef6f542645ff7728f1e7cc821d4138aad03d Mon Sep 17 00:00:00 2001 From: bougyman's bot Date: Fri, 4 Sep 2026 22:44:39 -0400 Subject: [PATCH 04/11] fix(ci): skip GitHub test merge commits --- ci/validate_commit_range.sh | 9 ++++++--- test/git_hooks_test.exs | 11 +++++++++++ 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/ci/validate_commit_range.sh b/ci/validate_commit_range.sh index 298d532..cf86c3f 100755 --- a/ci/validate_commit_range.sh +++ b/ci/validate_commit_range.sh @@ -108,10 +108,12 @@ validation_status=0 # A commit is exempt from subject validation only when ALL three conditions hold: # 1. It has exactly two parents (is a merge commit). # 2. Its committer is GitHub (the trusted bot identity). -# 3. Its subject matches the canonical "Update branch" pattern. +# 3. Its subject matches one of GitHub's two generated merge patterns: +# an "Update branch" merge, or a pull-request test merge of two SHAs. # Ordinary contributor-created merge commits (different committer, or a # subject that doesn't match the pattern) still go through subject validation. -github_merge_pattern="^Merge branch '[^']+' into .+" +github_update_branch_merge_pattern="^Merge branch '[^']+' into .+" +github_pull_request_merge_pattern="^Merge [0-9a-fA-F]{40} into [0-9a-fA-F]{40}$" while IFS= read -r -d '' entry do @@ -128,7 +130,8 @@ do if [ "$parent_count" -eq 2 ] \ && [ "$committer_name" = "GitHub" ] \ && [ "$committer_email" = "noreply@github.com" ] \ - && [[ "$subject" =~ $github_merge_pattern ]] + && { [[ "$subject" =~ $github_update_branch_merge_pattern ]] \ + || [[ "$subject" =~ $github_pull_request_merge_pattern ]]; } then continue fi diff --git a/test/git_hooks_test.exs b/test/git_hooks_test.exs index 9655a69..56c0d9c 100644 --- a/test/git_hooks_test.exs +++ b/test/git_hooks_test.exs @@ -159,6 +159,17 @@ defmodule GitHooksTest do assert {"", 0} = run(@range_guard, [], cd: worktree) end + test "the range guard skips a GitHub pull-request test merge commit" do + {worktree, _} = setup_ci_worktree!() + + subject = + "Merge #{String.duplicate("a", 40)} into #{String.duplicate("b", 40)}" + + add_github_merge!(worktree, subject: subject) + + assert {"", 0} = run(@range_guard, [], cd: worktree) + end + test "the range guard validates when the committer name is not GitHub" do {worktree, _} = setup_ci_worktree!() add_github_merge!(worktree, committer_name: "Not GitHub") From c1642613f2ce5ca82339f7cf95cfaca27b906a16 Mon Sep 17 00:00:00 2001 From: bougyman's bot Date: Fri, 4 Sep 2026 22:46:21 -0400 Subject: [PATCH 05/11] fix(ci): validate actual pull request commits --- .github/workflows/ci.yaml | 5 +++++ ci/validate_commit_range.sh | 19 ------------------- test/git_hooks_test.exs | 20 +++++--------------- 3 files changed, 10 insertions(+), 34 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 286a40a..7f63640 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -33,6 +33,11 @@ jobs: # the merge base. Retain the full range so its shell guard can # resolve that base without a second network fetch. fetch-depth: 0 + # pull_request otherwise checks out GitHub's synthetic test-merge + # commit. Validate the contributor's actual branch tip instead; + # the shell guard must never exempt a commit based on forgeable + # subject or committer metadata. + ref: ${{ github.event.pull_request.head.sha || github.sha }} - uses: erlef/setup-beam@v1 with: diff --git a/ci/validate_commit_range.sh b/ci/validate_commit_range.sh index cf86c3f..4b9a48f 100755 --- a/ci/validate_commit_range.sh +++ b/ci/validate_commit_range.sh @@ -105,16 +105,6 @@ base_sha=$(git_or_die merge-base HEAD "$base_ref") validation_status=0 -# A commit is exempt from subject validation only when ALL three conditions hold: -# 1. It has exactly two parents (is a merge commit). -# 2. Its committer is GitHub (the trusted bot identity). -# 3. Its subject matches one of GitHub's two generated merge patterns: -# an "Update branch" merge, or a pull-request test merge of two SHAs. -# Ordinary contributor-created merge commits (different committer, or a -# subject that doesn't match the pattern) still go through subject validation. -github_update_branch_merge_pattern="^Merge branch '[^']+' into .+" -github_pull_request_merge_pattern="^Merge [0-9a-fA-F]{40} into [0-9a-fA-F]{40}$" - while IFS= read -r -d '' entry do IFS=$'\x01' read -r parents committer_name committer_email subject <<< "$entry" @@ -127,15 +117,6 @@ do parent_count=0 fi - if [ "$parent_count" -eq 2 ] \ - && [ "$committer_name" = "GitHub" ] \ - && [ "$committer_email" = "noreply@github.com" ] \ - && { [[ "$subject" =~ $github_update_branch_merge_pattern ]] \ - || [[ "$subject" =~ $github_pull_request_merge_pattern ]]; } - then - continue - fi - "$validator" --subject "$subject" status=$? diff --git a/test/git_hooks_test.exs b/test/git_hooks_test.exs index 56c0d9c..2e6f01d 100644 --- a/test/git_hooks_test.exs +++ b/test/git_hooks_test.exs @@ -152,22 +152,12 @@ defmodule GitHooksTest do refute output =~ "feat: valid commit" end - test "the range guard skips a GitHub Update-branch merge commit matching all three predicates" do + test "the range guard validates a GitHub Update-branch merge commit" do {worktree, _} = setup_ci_worktree!() add_github_merge!(worktree) - assert {"", 0} = run(@range_guard, [], cd: worktree) - end - - test "the range guard skips a GitHub pull-request test merge commit" do - {worktree, _} = setup_ci_worktree!() - - subject = - "Merge #{String.duplicate("a", 40)} into #{String.duplicate("b", 40)}" - - add_github_merge!(worktree, subject: subject) - - assert {"", 0} = run(@range_guard, [], cd: worktree) + assert {output, 1} = run(@range_guard, [], cd: worktree) + assert output =~ "Merge branch 'main' into feature" end test "the range guard validates when the committer name is not GitHub" do @@ -271,8 +261,8 @@ defmodule GitHooksTest do end # Creates a no-fast-forward merge commit on `main` from a throwaway `feature` - # branch. Defaults simulate GitHub's "Update branch" committer identity and - # subject so the predicate in validate_commit_range.sh matches. + # branch. Defaults simulate GitHub's "Update branch" identity and subject; + # these remain subject to validation because commit metadata is forgeable. defp add_github_merge!(worktree, opts \\ []) do committer_name = Keyword.get(opts, :committer_name, "GitHub") committer_email = Keyword.get(opts, :committer_email, "noreply@github.com") From 91b8f6fe854af213e43b848922e307221aa181e1 Mon Sep 17 00:00:00 2001 From: bougyman's bot Date: Fri, 4 Sep 2026 22:53:50 -0400 Subject: [PATCH 06/11] test: remove stokowski from root suite --- test/git_hooks_test.exs | 7 ------ test/mix/tasks/stokowski_test.exs | 40 ------------------------------- 2 files changed, 47 deletions(-) delete mode 100644 test/mix/tasks/stokowski_test.exs diff --git a/test/git_hooks_test.exs b/test/git_hooks_test.exs index 2e6f01d..4a0985c 100644 --- a/test/git_hooks_test.exs +++ b/test/git_hooks_test.exs @@ -12,13 +12,6 @@ defmodule GitHooksTest do assert {"", 0} = run(@subject_guard, ["--subject", "feat(api): add title validation"]) end - test "the shared subject guard rejects the squash title from pull request 197" do - title = "Stokowski tooling: fix Claude→Qwen routing, add lc issue comment (#197)" - - assert {output, 1} = run(@subject_guard, ["--subject", title]) - assert output =~ "Commit subject must use Conventional Commits format" - end - test "the shared subject guard rejects the squash title from pull request 196" do title = "EXT-19: isolate Burrito musl loader per user (#196)" diff --git a/test/mix/tasks/stokowski_test.exs b/test/mix/tasks/stokowski_test.exs deleted file mode 100644 index dafea6f..0000000 --- a/test/mix/tasks/stokowski_test.exs +++ /dev/null @@ -1,40 +0,0 @@ -defmodule Mix.Tasks.StokowskiTest do - use ExUnit.Case, async: true - - test "raises when no workflow.yaml is found" do - in_tmp_dir(fn -> - assert_raise Mix.Error, ~r/No workflow\.yaml at/, fn -> - Mix.Tasks.Stokowski.run([]) - end - end) - end - - test "raises when tracker.api_key is a bare literal" do - in_tmp_dir(fn -> - File.write!("workflow.yaml", """ - tracker: - api_key: "lin_api_totally_real" - """) - - assert_raise Mix.Error, ~r/is a bare literal key/, fn -> - Mix.Tasks.Stokowski.run([]) - end - end) - end - - # Each test gets its own directory rather than sharing System.tmp_dir!() - # directly - both tests run async and would otherwise race on the same - # workflow.yaml. A cryptographic nonce avoids collisions across BEAM VM - # restarts (unlike System.unique_integer/1 which resets each run). - defp in_tmp_dir(fun) do - nonce = :crypto.strong_rand_bytes(16) |> Base.url_encode64(padding: false) - dir = Path.join(System.tmp_dir!(), "stokowski_test_#{nonce}") - File.mkdir!(dir) - - try do - File.cd!(dir, fun) - after - File.rm_rf!(dir) - end - end -end From b25ed93b42e8baa6c1cfd4f20a2d939ab7651be4 Mon Sep 17 00:00:00 2001 From: bougyman's bot Date: Fri, 4 Sep 2026 23:01:28 -0400 Subject: [PATCH 07/11] ci: wire PR title and commit-range validation into GitHub Actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ci.yaml: add 'edited' to pull_request trigger types so title-only changes retrigger the required Test check. - ci.yaml: replace skip_commit_validation boolean workflow_call input with base_ref, pull_request_title_required, and pull_request_title so each caller can supply the exact data its event provides. - ci.yaml: export BASE_REF, PULL_REQUEST_TITLE_REQUIRED, and PULL_REQUEST_TITLE as env vars (never via ${{ }} in run:) so PR titles containing quotes, backticks, dollar signs, or Unicode are inert data. - ci.yaml: run mix precommit instead of mix ci in the Test job, giving the Test required check the full 11-step gate including commit-range and PR-title validation. - ci.yaml: exclude 'edited' from burrito_changes guard — a title edit never produces a file diff. - ci.yaml: update conventional_commits compatibility job condition to use pull_request_title_required instead of skip_commit_validation; keep emitting the Validate Commit Subjects check for PRs until EXT-33 migrates branch protection. - main.yaml: replace skip_commit_validation: true with granular inputs — base_ref carries github.event.before for push events and pull_request.base.sha for closed-PR events; pull_request_title_required is always false (no PR title to enforce post-merge). --- .github/workflows/ci.yaml | 93 ++++++++++++++++++++++++++++++------- .github/workflows/main.yaml | 18 ++++++- 2 files changed, 93 insertions(+), 18 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 7f63640..b464a54 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -5,14 +5,35 @@ on: # yamllint disable-line rule:truthy workflow_dispatch: workflow_call: inputs: - skip_commit_validation: + base_ref: description: >- - Skip the commit-subject check - it only makes sense pre-merge - (should this PR be mergeable), not as a post-merge gate. + Commit comparison base: an exact 40-character SHA (for push events + and closed-PR validation), a branch name (for workflow_dispatch with + an explicit base), or empty (falls back to origin/main). The Test job + exports this as BASE_REF so ci/validate_commit_range.sh uses the + correct range instead of re-resolving it from scratch. + required: false + type: string + default: "" + pull_request_title_required: + description: >- + Set to true only for pull_request events. ci/validate_pull_request_title.sh + treats any other value as "skip" and exits 0. Pass "false" for push, + closed-PR, and workflow_dispatch events where there is no PR title to enforce. required: false type: boolean default: false + pull_request_title: + description: >- + The pull request title to validate. Only meaningful when + pull_request_title_required is true. Passed as an environment variable + so the title is never interpolated as shell code — quotes, backticks, + dollar signs, and Unicode are all safe. + required: false + type: string + default: "" pull_request: + types: [opened, synchronize, reopened, edited] permissions: contents: read @@ -29,14 +50,15 @@ jobs: - uses: actions/checkout@v7 with: - # The repository-level precommit task validates every commit since - # the merge base. Retain the full range so its shell guard can - # resolve that base without a second network fetch. + # mix precommit calls ci/validate_commit_range.sh, which validates + # every commit since the merge base. A full-history checkout lets the + # script resolve that base locally without a second network fetch. fetch-depth: 0 # pull_request otherwise checks out GitHub's synthetic test-merge - # commit. Validate the contributor's actual branch tip instead; - # the shell guard must never exempt a commit based on forgeable - # subject or committer metadata. + # commit (subject "Merge into "). Validate the + # contributor's actual branch tip instead so that commit never + # appears in the validated range. Falls back to github.sha for + # non-PR triggers (workflow_dispatch, workflow_call from push/closed). ref: ${{ github.event.pull_request.head.sha || github.sha }} - uses: erlef/setup-beam@v1 @@ -58,19 +80,44 @@ jobs: app/_build key: ${{ runner.os }}-mix-${{ hashFiles('app/mix.lock') }} - - # mix ci covers: deps.get, hex.audit, deps.audit (Elixir security - # advisories), format --check-formatted, credo (static analysis), - # usage_rules.sync --check (catches dep-bump drift, see #79), - # and mix test. Runs from the repo root; cd: app/ is handled - # inside the task itself. - run: mix ci + # mix precommit runs the full 11-step gate: + # 1. ci/validate_pull_request_title.sh — PR title (when required) + # 2. ci/validate_commit_range.sh — all commit subjects in the range + # 3-4. Root format + test (the repo-management tooling itself) + # 5-11. App deps.get, hex.audit, deps.audit, format, credo, + # usage_rules.sync, and test. + # BASE_REF carries the exact comparison base SHA so the commit-range + # validator never has to guess. For pull_request events it is the exact + # base SHA; for push events it is github.event.before; for + # workflow_dispatch and unverified closes it is empty, and the validator + # falls back to origin/main. + # PULL_REQUEST_TITLE is set via env (never ${{ }} in run:) so PR titles + # containing quotes, backticks, dollar signs, or Unicode are treated as + # inert data, not executable shell code. + name: Run full quality gate + env: + BASE_REF: >- + ${{ inputs.base_ref != '' && inputs.base_ref + || github.event.pull_request.base.sha }} + PULL_REQUEST_TITLE_REQUIRED: >- + ${{ inputs.pull_request_title_required + || github.event_name == 'pull_request' }} + PULL_REQUEST_TITLE: >- + ${{ inputs.pull_request_title != '' && inputs.pull_request_title + || github.event.pull_request.title }} + run: mix precommit working-directory: . burrito_changes: # Building Burrito is deliberately reserved for changes that affect its # dependency graph or packaging path. The release workflow still builds # every target before publishing. - if: github.event_name == 'pull_request' && github.event.action != 'closed' + # Exclude 'edited' events — a title-only change never alters file content, + # so there are no Burrito-impacting diffs to check. + if: >- + github.event_name == 'pull_request' + && github.event.action != 'closed' + && github.event.action != 'edited' name: Detect Burrito-impacting changes runs-on: ubuntu-latest outputs: @@ -152,7 +199,15 @@ jobs: run: ../ci/test_burrito_shared_loader.sh ./burrito_out/lc_linux_x86_64 conventional_commits: - if: inputs.skip_commit_validation != true + # Temporary compatibility job retained while branch protection still lists + # "Validate Commit Subjects" as a required check. The Test job above runs + # the same ci/validate_commit_range.sh via mix precommit and is the + # authoritative gate; this job will be removed in EXT-33 after the required- + # check set is migrated to "Test" only. Condition mirrors the Test job: + # skip only when main.yaml explicitly passes pull_request_title_required=false + # for a non-PR event (not when inputs is absent, which is the direct + # pull_request trigger). + if: inputs.pull_request_title_required != false name: Validate Commit Subjects runs-on: ubuntu-latest steps: @@ -167,4 +222,8 @@ jobs: # workflow_call), where github.event.pull_request is unset. ref: ${{ github.event.pull_request.head.sha || github.sha }} - + env: + BASE_REF: >- + ${{ inputs.base_ref != '' && inputs.base_ref + || github.event.pull_request.base.sha }} run: ./ci/validate_commit_range.sh diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index 03dcf3e..0e6e326 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -19,7 +19,23 @@ jobs: name: Validations uses: ./.github/workflows/ci.yaml with: - skip_commit_validation: true + # push: validate every commit introduced after github.event.before. + # The exact SHA lets ci/validate_commit_range.sh check the squash/merge + # integration commit itself rather than comparing main to main (empty). + # pull_request closed: validate the merged result when a PR was actually + # merged; the base SHA gives the correct start of the new-commits range. + # For unmerged closes the range would be empty, but passing the base SHA + # is harmless — git log will find no commits to validate. + # workflow_dispatch: no PR title and no exact base; the validator falls + # back to origin/main as the comparison base. + # PR title validation only applies to direct pull_request events + # (handled by ci.yaml's own trigger), not to any workflow_call here. + base_ref: >- + ${{ github.event_name == 'push' && github.event.before + || github.event_name == 'pull_request' && github.event.pull_request.base.sha + || '' }} + pull_request_title_required: false + pull_request_title: "" manage-release-pr: needs: [validate] From 80d691480ecfb80198995373dbaf209400357114 Mon Sep 17 00:00:00 2001 From: bougyman's bot Date: Fri, 4 Sep 2026 23:02:40 -0400 Subject: [PATCH 08/11] fix(ci): run Validate Commit Subjects for direct PR triggers inputs.pull_request_title_required defaults to false in GitHub Actions even for non-workflow_call triggers, so the previous condition `inputs.pull_request_title_required != false` skipped the compatibility job on direct pull_request events. Replace with `github.event_name != 'workflow_call' || inputs.pull_request_title_required` so the job runs for all direct triggers (pull_request, workflow_dispatch) and skips only when main.yaml calls the workflow with pull_request_title_required: false for a post-merge event. --- .github/workflows/ci.yaml | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index b464a54..fee9975 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -203,11 +203,12 @@ jobs: # "Validate Commit Subjects" as a required check. The Test job above runs # the same ci/validate_commit_range.sh via mix precommit and is the # authoritative gate; this job will be removed in EXT-33 after the required- - # check set is migrated to "Test" only. Condition mirrors the Test job: - # skip only when main.yaml explicitly passes pull_request_title_required=false - # for a non-PR event (not when inputs is absent, which is the direct - # pull_request trigger). - if: inputs.pull_request_title_required != false + # check set is migrated to "Test" only. + # Run for all direct triggers (pull_request, workflow_dispatch); skip only + # when main.yaml calls this workflow for a non-PR event and explicitly sets + # pull_request_title_required to false — those are post-merge runs where + # there is no open PR to gate on Validate Commit Subjects. + if: github.event_name != 'workflow_call' || inputs.pull_request_title_required name: Validate Commit Subjects runs-on: ubuntu-latest steps: From 9fab0f4b0a747206c2f7f704c9509bf70213fe19 Mon Sep 17 00:00:00 2001 From: bougyman's bot Date: Fri, 4 Sep 2026 23:18:56 -0400 Subject: [PATCH 09/11] fix(test): clear CI env vars from subprocesses in GitHooksTest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BASE_REF and PULL_REQUEST_TITLE* are set in the GitHub Actions job-level env: block so mix precommit can validate commits and PR titles. But System.cmd :env only adds/overrides the listed keys — any unlisted CI var flows through from the inherited process environment and corrupts test subprocess calls. For range guard tests that don't specify an explicit BASE_REF, the CI SHA (e.g. fd2dbb8d...) was used as the base; git merge-base then failed because the test's temp repo has no reachable path to that commit. For the title guard "rejects missing required title" test, PULL_REQUEST_TITLE from CI leaked through (only PULL_REQUEST_TITLE_REQUIRED was listed in the test env), so the script found the title set and validated it rather than reporting the expected missing-title error. Fix: in run/3, build an explicit ci_clear list that sets each known CI var to false (unset) unless the test has already provided a value for that key. Co-Authored-By: Claude Sonnet 4.6 --- test/git_hooks_test.exs | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/test/git_hooks_test.exs b/test/git_hooks_test.exs index 4a0985c..8846edf 100644 --- a/test/git_hooks_test.exs +++ b/test/git_hooks_test.exs @@ -344,7 +344,22 @@ defmodule GitHooksTest do end defp run(command, args, opts \\ []) do - opts = Keyword.put(opts, :stderr_to_stdout, true) + test_env = Keyword.get(opts, :env, []) + test_env_keys = MapSet.new(test_env, fn {k, _} -> k end) + + # GitHub Actions sets BASE_REF and PULL_REQUEST_TITLE* in the job-level env + # block; System.cmd :env only affects listed keys, so any unlisted CI var + # flows through unchanged and corrupts test subprocess calls. + ci_clear = + ~w[BASE_REF GITHUB_BASE_REF PULL_REQUEST_TITLE PULL_REQUEST_TITLE_REQUIRED] + |> Enum.reject(&MapSet.member?(test_env_keys, &1)) + |> Enum.map(&{&1, false}) + + opts = + opts + |> Keyword.put(:stderr_to_stdout, true) + |> Keyword.put(:env, ci_clear ++ test_env) + System.cmd(command, args, opts) end From d5e077bf687858def52b281db1c2fbff8f83b808 Mon Sep 17 00:00:00 2001 From: bougyman's bot Date: Fri, 4 Sep 2026 23:22:44 -0400 Subject: [PATCH 10/11] fix(test): use env(1) -u to unset CI vars instead of {key, false} MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Elixir 1.20's System.cmd does not support {key, false} for unsetting env vars — it calls String.to_charlist/1 on the value, which has no clause for false. Replace the previous approach with /usr/bin/env -u KEY flags, which properly remove each CI-level variable from the subprocess environment even when the variable exists in the inherited process env. Co-Authored-By: Claude Sonnet 4.6 --- test/git_hooks_test.exs | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/test/git_hooks_test.exs b/test/git_hooks_test.exs index 8846edf..54a4065 100644 --- a/test/git_hooks_test.exs +++ b/test/git_hooks_test.exs @@ -347,20 +347,26 @@ defmodule GitHooksTest do test_env = Keyword.get(opts, :env, []) test_env_keys = MapSet.new(test_env, fn {k, _} -> k end) - # GitHub Actions sets BASE_REF and PULL_REQUEST_TITLE* in the job-level env - # block; System.cmd :env only affects listed keys, so any unlisted CI var - # flows through unchanged and corrupts test subprocess calls. - ci_clear = + # Elixir 1.20's System.cmd :env only adds/overrides listed keys — any CI var + # not listed flows through unchanged. Elixir 1.20 also does not support + # {key, false} for unsetting. Use env(1) -u flags instead to clear CI-level + # variables (BASE_REF, PULL_REQUEST_TITLE*) that the GitHub Actions job + # environment sets and that would otherwise corrupt these test subprocess calls. + unset_flags = ~w[BASE_REF GITHUB_BASE_REF PULL_REQUEST_TITLE PULL_REQUEST_TITLE_REQUIRED] |> Enum.reject(&MapSet.member?(test_env_keys, &1)) - |> Enum.map(&{&1, false}) + |> Enum.flat_map(&["-u", &1]) + + set_args = Enum.map(test_env, fn {k, v} -> "#{k}=#{v}" end) + + env_args = unset_flags ++ set_args ++ [command | args] opts = opts + |> Keyword.delete(:env) |> Keyword.put(:stderr_to_stdout, true) - |> Keyword.put(:env, ci_clear ++ test_env) - System.cmd(command, args, opts) + System.cmd("/usr/bin/env", env_args, opts) end defp git!(directory, args) do From e53fa054095a3d3ef007cc432eda36dd3a521ff7 Mon Sep 17 00:00:00 2001 From: bougyman's bot Date: Fri, 4 Sep 2026 23:52:34 -0400 Subject: [PATCH 11/11] refactor: clean up code review findings - ci/validate_commit_range.sh: simplify git log format from '%P%x01%cn%x01%ce%x01%s' to '%s'; remove the parents/committer_name/ committer_email/parent_count parsing that was left behind when the merge-commit exemption was removed. Only the subject is passed to the validator, so fetching three extra fields per commit was dead code. - test/mix/tasks/precommit_test.exs: use assert_received instead of assert_receive for consistency with ci_test.exs. Messages are sent synchronously within the same process, so assert_received (which does not wait for a message to arrive) is more appropriate. - test/git_hooks_test.exs: remove unused default argument from run_with_stdin/3; every caller passes opts explicitly, so the default was never triggered (and generated a compiler warning). Co-Authored-By: Claude Sonnet 4.6 --- ci/validate_commit_range.sh | 14 ++------------ test/git_hooks_test.exs | 2 +- test/mix/tasks/precommit_test.exs | 22 +++++++++++----------- 3 files changed, 14 insertions(+), 24 deletions(-) diff --git a/ci/validate_commit_range.sh b/ci/validate_commit_range.sh index 4b9a48f..9c862c5 100755 --- a/ci/validate_commit_range.sh +++ b/ci/validate_commit_range.sh @@ -105,18 +105,8 @@ base_sha=$(git_or_die merge-base HEAD "$base_ref") validation_status=0 -while IFS= read -r -d '' entry +while IFS= read -r -d '' subject do - IFS=$'\x01' read -r parents committer_name committer_email subject <<< "$entry" - - if [ -n "$parents" ] - then - IFS=' ' read -ra parents_array <<< "$parents" - parent_count=${#parents_array[@]} - else - parent_count=0 - fi - "$validator" --subject "$subject" status=$? @@ -124,6 +114,6 @@ do then validation_status=$status fi -done < <(git log -z --format='%P%x01%cn%x01%ce%x01%s' "$base_sha..HEAD") +done < <(git log -z --format='%s' "$base_sha..HEAD") exit "$validation_status" diff --git a/test/git_hooks_test.exs b/test/git_hooks_test.exs index 54a4065..801c318 100644 --- a/test/git_hooks_test.exs +++ b/test/git_hooks_test.exs @@ -318,7 +318,7 @@ defmodule GitHooksTest do {worktree, ci_dir} end - defp run_with_stdin(command, stdin_content, opts \\ []) do + defp run_with_stdin(command, stdin_content, opts) do nonce = :crypto.strong_rand_bytes(8) |> Base.url_encode64(padding: false) stdin_file = Path.join(System.tmp_dir!(), "linear_cli_stdin_#{nonce}") File.write!(stdin_file, stdin_content) diff --git a/test/mix/tasks/precommit_test.exs b/test/mix/tasks/precommit_test.exs index 8142be5..3af847d 100644 --- a/test/mix/tasks/precommit_test.exs +++ b/test/mix/tasks/precommit_test.exs @@ -13,17 +13,17 @@ defmodule Mix.Tasks.PrecommitTest do assert :ok = Precommit.run([], shell) - assert_receive {:run, "./ci/validate_pull_request_title.sh", [], []} - assert_receive {:run, "./ci/validate_commit_range.sh", [], []} - assert_receive {:run, "mix", ["format", "--check-formatted"], []} - assert_receive {:run, "mix", ["test"], []} - assert_receive {:run, "mix", ["deps.get"], [cd: "app"]} - assert_receive {:run, "mix", ["hex.audit"], [cd: "app"]} - assert_receive {:run, "mix", ["deps.audit"], [cd: "app"]} - assert_receive {:run, "mix", ["format", "--check-formatted"], [cd: "app"]} - assert_receive {:run, "mix", ["credo", "--strict"], [cd: "app"]} - assert_receive {:run, "mix", ["usage_rules.sync", "--check"], [cd: "app"]} - assert_receive {:run, "mix", ["test"], [cd: "app"]} + assert_received {:run, "./ci/validate_pull_request_title.sh", [], []} + assert_received {:run, "./ci/validate_commit_range.sh", [], []} + assert_received {:run, "mix", ["format", "--check-formatted"], []} + assert_received {:run, "mix", ["test"], []} + assert_received {:run, "mix", ["deps.get"], [cd: "app"]} + assert_received {:run, "mix", ["hex.audit"], [cd: "app"]} + assert_received {:run, "mix", ["deps.audit"], [cd: "app"]} + assert_received {:run, "mix", ["format", "--check-formatted"], [cd: "app"]} + assert_received {:run, "mix", ["credo", "--strict"], [cd: "app"]} + assert_received {:run, "mix", ["usage_rules.sync", "--check"], [cd: "app"]} + assert_received {:run, "mix", ["test"], [cd: "app"]} end test "rejects arguments" do