diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 74a23a4..48afcb3 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -80,12 +80,13 @@ jobs: app/_build key: ${{ runner.os }}-mix-${{ hashFiles('app/mix.lock') }} - - # 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. + # mix ci is the complete integration gate: + # 1. App deps.get (bootstrap) + # 2. ci/validate_pull_request_title.sh — PR title (when required) + # 3. ci/validate_commit_range.sh — all commit subjects in the range + # 4. mix precommit — root format + test, app format + credo + test + # 5-7. App hex.audit, deps.audit, usage_rules.sync + # 8. App mix test --only ci_only (CI-classified integration tests) # 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 @@ -105,7 +106,7 @@ jobs: PULL_REQUEST_TITLE: >- ${{ inputs.pull_request_title != '' && inputs.pull_request_title || github.event.pull_request.title }} - run: mix precommit + run: mix ci working-directory: . burrito_changes: diff --git a/app/test/test_helper.exs b/app/test/test_helper.exs index dff1a1e..895168a 100644 --- a/app/test/test_helper.exs +++ b/app/test/test_helper.exs @@ -26,4 +26,6 @@ Application.put_env(:elixir, :ansi_enabled, true) # creates a profile. Application.fetch_env!(:linear_cli, :profiles_db_path) |> File.rm() -ExUnit.start() +# Tests tagged @moduletag :ci_only are excluded from the local gate (mix +# precommit). They run only through mix ci's --only ci_only step. +ExUnit.start(exclude: [:ci_only]) diff --git a/documents/quality-gates-decision.adoc b/documents/quality-gates-decision.adoc index 4ad464c..6d2ac3e 100644 --- a/documents/quality-gates-decision.adoc +++ b/documents/quality-gates-decision.adoc @@ -78,8 +78,13 @@ than relying on an undocumented alias. == Current implementation -At the time of this decision, `mix ci` delegates directly to `mix precommit`, -and `mix precommit` performs dependency retrieval, dependency audits, and the -full application suite. That arrangement does not satisfy this decision. -Implementation work must split the tasks so `mix precommit` has the local -contract above and `mix ci` composes it with the CI-only work. +`mix precommit` runs only source-local checks (root and app formatting, +static analysis, and unit tests). It requires no network access and no +installed services beyond the language toolchain and already-fetched +dependencies. + +`mix ci` is an independent orchestrator. It installs dependencies, runs fast +metadata guards (PR-title and commit-range validation), invokes `mix precommit` +in full, and then performs the CI-only checks (Hex and dependency security +audits, usage-rule drift, and CI-classified integration tests). GitHub Actions +calls `mix ci`, not `mix precommit`. diff --git a/lib/mix/tasks/ci.ex b/lib/mix/tasks/ci.ex index 3ddc75f..b1eb979 100644 --- a/lib/mix/tasks/ci.ex +++ b/lib/mix/tasks/ci.ex @@ -1,27 +1,69 @@ defmodule Mix.Tasks.Ci do - @shortdoc "Compatibility alias for mix precommit" + @shortdoc "Complete integration gate: local checks plus CI-only audits and validation" @moduledoc """ #{@shortdoc}. mix ci - 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. + The canonical pull-request and merge gate, and the command GitHub Actions + invokes. It bootstraps dependencies, runs fast metadata guards, runs the full + local gate, then performs CI-only checks that may use the network, PR + metadata, or advisory services. - See `mix help precommit` for the complete step list. + Steps: + + 1. `mix deps.get` — ensure app dependencies are installed + 2. `ci/validate_pull_request_title.sh` — require a Conventional Commits PR + title when `PULL_REQUEST_TITLE_REQUIRED=true` + 3. `ci/validate_commit_range.sh` — validate every commit since the branch + diverged from its base + 4. `mix precommit` — the fast local gate (format, static analysis, unit tests) + 5. `mix hex.audit` — reject retired or vulnerable Hex packages + 6. `mix deps.audit` — scan dependencies for known security advisories + 7. `mix usage_rules.sync --check` — catch usage-rule drift after dep bumps + 8. `mix test --only ci_only` — run CI-classified integration tests + + Step 1 and steps 5-8 run inside `app/`. Steps 2-3 run from the repo root. + Step 4 expands to all of `mix precommit`'s steps in place. + + Every check in `mix precommit` also runs through `mix ci`. The relationship + is: + + mix precommit ⊂ mix ci + + Pull request metadata does not exist before a pull request is opened, so + local runs of `mix ci` skip only the title guard. GitHub Actions sets both + `PULL_REQUEST_TITLE_REQUIRED=true` and `PULL_REQUEST_TITLE` from the event; + a missing, empty, or non-conventional title then fails step 2. Commit subjects + are always validated in step 3. + + For the fast local gate only (no network, no CI metadata), use `mix precommit`. """ use Mix.Task + alias RepoTasks.Shell + @impl Mix.Task def run(argv) do - Mix.Tasks.Precommit.run(argv) + run(argv, &Shell.run!/3) end @doc false - def run(argv, shell) do - Mix.Tasks.Precommit.run(argv, shell) + def run([], shell) do + shell.("mix", ["deps.get"], cd: "app") + shell.("./ci/validate_pull_request_title.sh", [], []) + shell.("./ci/validate_commit_range.sh", [], []) + Mix.Tasks.Precommit.run([], shell) + shell.("mix", ["hex.audit"], cd: "app") + shell.("mix", ["deps.audit"], cd: "app") + shell.("mix", ["usage_rules.sync", "--check"], cd: "app") + shell.("mix", ["test", "--only", "ci_only"], cd: "app") + :ok + end + + def run(_argv, _shell) do + Mix.raise("Usage: mix ci") end end diff --git a/lib/mix/tasks/precommit.ex b/lib/mix/tasks/precommit.ex index c871301..c2a2104 100644 --- a/lib/mix/tasks/precommit.ex +++ b/lib/mix/tasks/precommit.ex @@ -1,36 +1,33 @@ defmodule Mix.Tasks.Precommit do - @shortdoc "Runs every local and CI validation for this repository" + @shortdoc "Fast local quality gate (format, static analysis, unit tests)" @moduledoc """ #{@shortdoc}. mix precommit - This is the single validation entrypoint for developers, Git hooks, and - GitHub Actions. Cheap metadata guards run first so an invalid pull request - title or commit subject fails before dependency setup and the test suite: + A fast, self-contained command designed for frequent developer use: between + edits, before committing, and as the pre-push hook target. On a warm checkout + with dependencies already installed, it completes in under five seconds. - 1. `ci/validate_pull_request_title.sh` — require a Conventional Commits - 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 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 + It requires no network access, credentials, containers, or external services. + Run `mix deps.get` inside `app/` once after cloning or after updating + `app/mix.lock`, then run `mix precommit` as often as you like. - Pull request metadata does not exist before a pull request is opened, so - local runs skip only the title guard. GitHub Actions sets both - `PULL_REQUEST_TITLE_REQUIRED=true` and `PULL_REQUEST_TITLE` from the event; - a missing, empty, or non-conventional title then fails this task. Commit - subjects are always validated. + Steps: - Steps 1-4 run from the repo root; steps 5-11 run inside `app/`. + 1. `mix format --check-formatted` — check root project formatting + 2. `mix test` — run the root project test suite (validator and task tests) + 3. `mix format --check-formatted` — check app formatting + 4. `mix credo --strict` — run static analysis on the app + 5. `mix test` — run the app unit test suite + + Steps 1-2 run from the repo root; steps 3-5 run inside `app/`. + App tests tagged `@moduletag :ci_only` are excluded by default; they run + only through `mix ci`. + + For the complete integration gate — dependency bootstrap and audits, + PR-title and commit-range validation, and CI-classified tests — use `mix ci`. """ use Mix.Task @@ -44,16 +41,10 @@ defmodule Mix.Tasks.Precommit do @doc false 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") 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 end diff --git a/test/mix/tasks/ci_test.exs b/test/mix/tasks/ci_test.exs index 78f689f..a20e613 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 "delegates to mix precommit" do + test "bootstraps deps, validates, runs precommit checks, then CI-only checks" do caller = self() shell = fn cmd, args, opts -> @@ -13,21 +13,26 @@ defmodule Mix.Tasks.CiTest do assert :ok = Ci.run([], shell) + # Bootstrap + assert_received {:run, "mix", ["deps.get"], [cd: "app"]} + # Metadata guards (fast failures before the expensive steps) assert_received {:run, "./ci/validate_pull_request_title.sh", [], []} assert_received {:run, "./ci/validate_commit_range.sh", [], []} + # All precommit local checks are present (mix precommit ⊂ mix ci) 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"]} + # CI-only checks + assert_received {:run, "mix", ["hex.audit"], [cd: "app"]} + assert_received {:run, "mix", ["deps.audit"], [cd: "app"]} + assert_received {:run, "mix", ["usage_rules.sync", "--check"], [cd: "app"]} + assert_received {:run, "mix", ["test", "--only", "ci_only"], [cd: "app"]} end - test "rejects arguments via precommit" do - assert_raise Mix.Error, "Usage: mix precommit", fn -> + test "rejects arguments" do + assert_raise Mix.Error, "Usage: mix ci", fn -> Ci.run(["unexpected"], fn _, _, _ -> :ok end) end end diff --git a/test/mix/tasks/precommit_test.exs b/test/mix/tasks/precommit_test.exs index 3af847d..f4e45c0 100644 --- a/test/mix/tasks/precommit_test.exs +++ b/test/mix/tasks/precommit_test.exs @@ -3,7 +3,7 @@ defmodule Mix.Tasks.PrecommitTest do alias Mix.Tasks.Precommit - test "runs metadata guards before all quality gate steps" do + test "runs only local checks in order" do caller = self() shell = fn cmd, args, opts -> @@ -13,19 +13,31 @@ defmodule Mix.Tasks.PrecommitTest do assert :ok = Precommit.run([], shell) - 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 "does not run CI-only steps" do + caller = self() + + shell = fn cmd, args, opts -> + send(caller, {:run, cmd, args, opts}) + :ok + end + + Precommit.run([], shell) + + refute_received {:run, "./ci/validate_pull_request_title.sh", _, _} + refute_received {:run, "./ci/validate_commit_range.sh", _, _} + refute_received {:run, "mix", ["deps.get"], _} + refute_received {:run, "mix", ["hex.audit"], _} + refute_received {:run, "mix", ["deps.audit"], _} + refute_received {:run, "mix", ["usage_rules.sync", "--check"], _} + end + test "rejects arguments" do assert_raise Mix.Error, "Usage: mix precommit", fn -> Precommit.run(["unexpected"], fn _, _, _ -> :ok end)