Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 8 additions & 7 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down
4 changes: 3 additions & 1 deletion app/test/test_helper.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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])
15 changes: 10 additions & 5 deletions documents/quality-gates-decision.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
58 changes: 50 additions & 8 deletions lib/mix/tasks/ci.ex
Original file line number Diff line number Diff line change
@@ -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
49 changes: 20 additions & 29 deletions lib/mix/tasks/precommit.ex
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down
19 changes: 12 additions & 7 deletions test/mix/tasks/ci_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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 ->
Expand All @@ -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
Expand Down
26 changes: 19 additions & 7 deletions test/mix/tasks/precommit_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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 ->
Expand All @@ -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)
Expand Down
Loading