diff --git a/app/test/linear_cli/cli/issue_commands_test.exs b/app/test/linear_cli/cli/issue_commands_test.exs index 15017e8..efad05b 100644 --- a/app/test/linear_cli/cli/issue_commands_test.exs +++ b/app/test/linear_cli/cli/issue_commands_test.exs @@ -4318,13 +4318,14 @@ defmodule LinearCli.CLI.IssueCommandsTest do Req.Test.json(conn, create_error_response("Unauthorized")) end) - {result, _output} = - with_io(fn -> - Commands.issue_relation_add(add_parse_result("EXT-1", ["EXT-2"], "blocks")) + stderr = + capture_io(:stderr, fn -> + result = Commands.issue_relation_add(add_parse_result("EXT-1", ["EXT-2"], "blocks")) + assert {:error, {:smells_bad, msg}} = result + assert msg =~ "failed" end) - assert {:error, {:smells_bad, msg}} = result - assert msg =~ "failed" + assert stderr =~ "EXT-2: Linear API error: Unauthorized" end test "partial failure: succeeds for valid targets, errors for failed targets" do @@ -4342,15 +4343,24 @@ defmodule LinearCli.CLI.IssueCommandsTest do end end) - output = - capture_io(fn -> - result = - Commands.issue_relation_add(add_parse_result("EXT-1", ["EXT-2", "EXT-bad"], "blocks")) + stderr = + capture_io(:stderr, fn -> + output = + capture_io(fn -> + result = + Commands.issue_relation_add( + add_parse_result("EXT-1", ["EXT-2", "EXT-bad"], "blocks") + ) - assert {:error, {:smells_bad, _}} = result + assert {:error, {:smells_bad, _}} = result + end) + + send(self(), {:relation_add_output, output}) end) + assert_received {:relation_add_output, output} assert output =~ "EXT-1 now blocks EXT-2" + assert stderr =~ "EXT-bad: Linear API error: Unauthorized" assert :counters.get(call_count, 1) == 2 end diff --git a/app/usage-rules.md b/app/usage-rules.md index 248426c..5cb5cb9 100644 --- a/app/usage-rules.md +++ b/app/usage-rules.md @@ -14,9 +14,10 @@ - Enforced locally by the `commit-msg` hook at `git-hooks/commit-msg` (each commit's own subject, via `ci/validate_conventional_subject.sh`) and the `pre-push` hook at `git-hooks/pre-push` (every non-deletion ref update, - via `ci/validate_push_refs.sh`, followed by `ci/hex-audit.sh`) — run - `mix setup` once per clone to activate both. The Hex audit needs network - access and prevents a push when it finds a vulnerable or retired package. + via `ci/validate_push_refs.sh`, followed by `mix precommit` and then + `ci/hex-audit.sh`) — run `mix setup` once per clone to activate both. The + Hex audit needs network access and prevents a push when it finds a vulnerable + or retired package. - Enforced in CI across a whole PR's commit range by `ci/validate_commit_range.sh` (skips GitHub's own auto-generated update-branch merge commits). diff --git a/documents/quality-gates-decision.adoc b/documents/quality-gates-decision.adoc index 7663b68..7ff8d07 100644 --- a/documents/quality-gates-decision.adoc +++ b/documents/quality-gates-decision.adoc @@ -94,4 +94,5 @@ Actions calls `mix ci`, not `mix precommit`. The repository's `pre-commit` hook refuses commits directly on `main` and then invokes `mix precommit`. The subsequent `commit-msg` hook validates the commit message after Git writes it; the `pre-push` hook remains the final local guard -for every pushed commit subject and the Hex audit. +by validating every pushed commit subject, rerunning `mix precommit`, and then +performing the Hex audit. diff --git a/git-hooks/pre-push b/git-hooks/pre-push index 2dcaf4e..3220e75 100755 --- a/git-hooks/pre-push +++ b/git-hooks/pre-push @@ -1,8 +1,12 @@ #!/bin/sh -# Validates every commit subject introduced by this push, then rejects known -# vulnerable or retired Hex dependencies. See app/usage-rules.md. Activate with: -# mix git_hooks +# Validates every commit subject introduced by this push, reruns the fast local +# quality gate, then rejects known vulnerable or retired Hex dependencies. See +# app/usage-rules.md. Activate with: mix git_hooks repo_top=$(git rev-parse --show-toplevel) || exit 1 "$repo_top/ci/validate_push_refs.sh" "$@" || exit $? + +cd "$repo_top" || exit 1 +mix precommit || exit $? + exec "$repo_top/ci/hex-audit.sh" diff --git a/lib/mix/tasks/git_hooks.ex b/lib/mix/tasks/git_hooks.ex index 8a1e3d8..e1eb6a5 100644 --- a/lib/mix/tasks/git_hooks.ex +++ b/lib/mix/tasks/git_hooks.ex @@ -9,8 +9,8 @@ defmodule Mix.Tasks.GitHooks do Sets `core.hooksPath` to `git-hooks/`: its `pre-commit` hook prevents direct commits to `main` and runs `mix precommit`; its `commit-msg` hook enforces Conventional Commits on each commit subject; and its `pre-push` hook validates - all commit subjects introduced by the push before running Hex's dependency - security audit. + all commit subjects introduced by the push, reruns `mix precommit`, and then + runs Hex's dependency security audit. This is idempotent and safe to run repeatedly: setting the same Git config value twice is a no-op. Wired into `mix setup` - see that task. """ diff --git a/lib/mix/tasks/precommit.ex b/lib/mix/tasks/precommit.ex index d71d19a..3dea858 100644 --- a/lib/mix/tasks/precommit.ex +++ b/lib/mix/tasks/precommit.ex @@ -7,8 +7,9 @@ defmodule Mix.Tasks.Precommit do mix precommit A fast, self-contained command designed for frequent developer use: between - edits and before committing. The repository's pre-commit hook invokes it. On a warm checkout - with dependencies already installed, it completes in under five seconds. + edits and before committing. The repository's pre-commit and pre-push hooks + invoke it. On a warm checkout with dependencies already installed, it + completes in under five seconds. It requires no network access, credentials, containers, or external services. Run `mix deps.get` inside `app/` once after cloning or after updating diff --git a/test/git_hooks_test.exs b/test/git_hooks_test.exs index fe33519..b5e2afc 100644 --- a/test/git_hooks_test.exs +++ b/test/git_hooks_test.exs @@ -285,10 +285,12 @@ defmodule GitHooksTest do assert File.read!(marker) == "precommit\n" end - test "the pre-push adapter validates refs before running the Hex audit" do + test "the pre-push adapter validates refs before running the quality and Hex audits" do {worktree, ci_dir} = setup_ci_worktree!() audit_marker = Path.join(worktree, "hex-audit-ran") + precommit_marker = Path.join(worktree, "precommit-ran") hook = install_pre_push_hook!(worktree, ci_dir) + fake_bin = install_fake_mix!(worktree) {base_sha, 0} = System.cmd("git", ["rev-parse", "HEAD"], cd: worktree) base_sha = String.trim(base_sha) @@ -297,10 +299,16 @@ defmodule GitHooksTest do assert {"", 0} = run_with_stdin(hook, valid_stdin, cd: worktree, - env: [{"HEX_AUDIT_MARKER", audit_marker}] + env: [ + {"HEX_AUDIT_MARKER", audit_marker}, + {"MIX_MARKER", precommit_marker}, + {"PATH", fake_bin <> ":" <> System.get_env("PATH")} + ] ) + assert File.read!(precommit_marker) == "precommit\n" assert File.read!(audit_marker) == "audited\n" + File.rm!(precommit_marker) File.rm!(audit_marker) File.write!(Path.join(worktree, "invalid"), "commit\n") @@ -313,10 +321,41 @@ defmodule GitHooksTest do assert {output, 1} = run_with_stdin(hook, invalid_stdin, cd: worktree, - env: [{"HEX_AUDIT_MARKER", audit_marker}] + env: [ + {"HEX_AUDIT_MARKER", audit_marker}, + {"MIX_MARKER", precommit_marker}, + {"PATH", fake_bin <> ":" <> System.get_env("PATH")} + ] ) assert output =~ "not conventional" + refute File.exists?(precommit_marker) + refute File.exists?(audit_marker) + end + + test "the pre-push adapter stops before the Hex audit when the quality gate fails" do + {worktree, ci_dir} = setup_ci_worktree!() + audit_marker = Path.join(worktree, "hex-audit-ran") + precommit_marker = Path.join(worktree, "precommit-ran") + hook = install_pre_push_hook!(worktree, ci_dir) + fake_bin = install_fake_mix!(worktree) + + {base_sha, 0} = System.cmd("git", ["rev-parse", "HEAD"], cd: worktree) + base_sha = String.trim(base_sha) + stdin = "refs/heads/main #{base_sha} refs/heads/main #{base_sha}\n" + + assert {"", 1} = + run_with_stdin(hook, stdin, + cd: worktree, + env: [ + {"HEX_AUDIT_MARKER", audit_marker}, + {"MIX_MARKER", precommit_marker}, + {"MIX_EXIT_STATUS", "1"}, + {"PATH", fake_bin <> ":" <> System.get_env("PATH")} + ] + ) + + assert File.read!(precommit_marker) == "precommit\n" refute File.exists?(audit_marker) end @@ -399,7 +438,12 @@ defmodule GitHooksTest do fake_bin = Path.join(worktree, "fake-bin") File.mkdir!(fake_bin) fake_mix = Path.join(fake_bin, "mix") - File.write!(fake_mix, "#!/bin/sh\nprintf 'precommit\\n' > \"$MIX_MARKER\"\n") + + File.write!( + fake_mix, + "#!/bin/sh\nprintf 'precommit\\n' > \"$MIX_MARKER\"\nexit \"${MIX_EXIT_STATUS:-0}\"\n" + ) + File.chmod!(fake_mix, 0o755) fake_bin end