Skip to content
Merged
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
30 changes: 20 additions & 10 deletions app/test/linear_cli/cli/issue_commands_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down
7 changes: 4 additions & 3 deletions app/usage-rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
3 changes: 2 additions & 1 deletion documents/quality-gates-decision.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -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.
10 changes: 7 additions & 3 deletions git-hooks/pre-push
Original file line number Diff line number Diff line change
@@ -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"
4 changes: 2 additions & 2 deletions lib/mix/tasks/git_hooks.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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.
"""
Expand Down
5 changes: 3 additions & 2 deletions lib/mix/tasks/precommit.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
52 changes: 48 additions & 4 deletions test/git_hooks_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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")
Expand All @@ -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

Expand Down Expand Up @@ -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
Expand Down