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
3 changes: 2 additions & 1 deletion app/config/config.exs
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,8 @@ config :ash,
bulk_actions_default_to_errors?: true,
transaction_rollback_on_error?: true,
redact_sensitive_values_in_errors?: true,
many_to_many_destroy_destination_on_match?: true
many_to_many_destroy_destination_on_match?: true,
default_string_length_count: :codepoints

config :spark,
formatter: [
Expand Down
4 changes: 2 additions & 2 deletions app/mix.lock
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
%{
"ash": {:hex, :ash, "3.32.3", "a6390b9f6497458f4575220cd507b6b5aae52c43bba0cc057aa3fa96e44ef416", [:mix], [{:crux, ">= 0.1.2 and < 1.0.0-0", [hex: :crux, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.14", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8", [hex: :ets, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:plug, ">= 0.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:reactor, "~> 1.0", [hex: :reactor, repo: "hexpm", optional: false]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:spark, ">= 2.6.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.3", [hex: :splode, repo: "hexpm", optional: false]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "4eebbd3fa7dab05d0aab9c7552155c07e746b18c8dc1cf5ed2f62195cea87f3d"},
"ash": {:hex, :ash, "3.33.0", "ffbf9c14d00135cd2ae53199a5d00a1a3c456b69c0b74fc53bd2aba771eb879f", [:mix], [{:crux, ">= 0.1.2 and < 1.0.0-0", [hex: :crux, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.14", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8", [hex: :ets, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:plug, ">= 0.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:reactor, "~> 1.0", [hex: :reactor, repo: "hexpm", optional: false]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:spark, ">= 2.6.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.3", [hex: :splode, repo: "hexpm", optional: false]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a1b313eefd0e04aa626d4e54313aadec17f3d82422f75d45e66c2736b807bf00"},
"bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"},
"burrito": {:hex, :burrito, "1.6.0", "7af0a75f11680e8a6e9c01370c9af51cb9d0e15b3226eddf4f438dbc68570520", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:req, ">= 0.5.0", [hex: :req, repo: "hexpm", optional: false]}, {:typed_struct, "~> 0.2.0 or ~> 0.3.0", [hex: :typed_struct, repo: "hexpm", optional: false]}], "hexpm", "e636a00b032c45a69ff755d9fc53fa5fdc9e1d21bdbd229075fe4a15b05355fe"},
"castore": {:hex, :castore, "1.0.21", "0a0e8330dc267a40a3b7ad86d39302764bb71758172904e6a59d5ad6443ce307", [:mix], [], "hexpm", "e42e22723e25dbd46876d056a03f685513d6e98f6b5e555dc551321decd76c5c"},
Expand Down Expand Up @@ -51,7 +51,7 @@
"sbom": {:hex, :sbom, "0.10.0", "b99be5407bc196d0ad71b8061126a67aae46dc3bfaa852b4c1c04645dd1ad984", [:mix], [{:hex_core, "~> 0.15.0", [hex: :hex_core, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:optimus, "~> 0.6.1", [hex: :optimus, repo: "hexpm", optional: false]}, {:protobuf, "~> 0.16.0", [hex: :protobuf, repo: "hexpm", optional: false]}, {:purl, "~> 0.3.0", [hex: :purl, repo: "hexpm", optional: false]}], "hexpm", "a8116ef965c1ebd103e223545794bd0a6691edd3ec678ec07972d473e2badc95"},
"sourceror": {:hex, :sourceror, "1.12.2", "85bfd48159f020c0cbfc72f289f11456fdc05dc43719b6f2589fb969faefa113", [:mix], [], "hexpm", "da37d3da09c5b890528802c7056a8f585a061973820d7656b6e3649c14f0e9cb"},
"spark": {:hex, :spark, "2.7.2", "36becc6ff03b40908cc821d403d7f06d893498e293d2f718afc6ca097fcb9d93", [:mix], [{:igniter, ">= 0.3.64 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:sourceror, "~> 1.2", [hex: :sourceror, repo: "hexpm", optional: true]}], "hexpm", "adb323ddbf9dbbe326f9e5def54ac96c47911e852b2c270bb19a5147c56f1b45"},
"spitfire": {:hex, :spitfire, "0.4.0", "6d98c10cf585434b9439ba0c6dd3cc7aeff0e06ab73bfe5488f42e7c0f883d9b", [:mix], [], "hexpm", "7e5c6d1523c111b59f332f9dc49edc0377111d0c17167a29830f0e98233f5472"},
"spitfire": {:hex, :spitfire, "0.4.1", "69e90335d00ca328295e1e1e77cac5d7575aa6d34274e3467ebfc654b8858be3", [:mix], [], "hexpm", "27d86f67681179682b15c6758d64ac2eb2b3637ed8340800c8b885c69754cdcd"},
"splode": {:hex, :splode, "0.3.2", "7716b6b2260a98a6f018c65cc0393da2cdf17314202eb351a0956e8762190dff", [:mix], [], "hexpm", "08fd658f80da7f1cd254b149164dcff8acd44b22f032001d5416b97223d32bc9"},
"stream_data": {:hex, :stream_data, "1.4.0", "026f929db613aabea6208012ae9b8970d3fd5f88b3bdf26831bc536f98c42036", [:mix], [], "hexpm", "2b0ee3a340dcce1c8cf6302a763ee757d1e01c54d6e16d9069062509d68b1dc9"},
"telemetry": {:hex, :telemetry, "1.4.2", "a0cb522801dffb1c49fe6e30561badffc7b6d0e180db1300df759faa22062855", [:rebar3], [], "hexpm", "928f6495066506077862c0d1646609eed891a4326bee3126ba54b60af61febb1"},
Expand Down
15 changes: 15 additions & 0 deletions ci/validate_commit_branch.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
#!/usr/bin/env bash
# Rejects ordinary commits made directly on main. Called by git-hooks/pre-commit
# so the failure occurs before Git creates the commit object.

if ! branch=$(git branch --show-current)
then
printf 'ERROR: unable to determine the current Git branch\n' >&2
exit 1
fi

if [ "$branch" = "main" ]
then
printf 'ERROR: committing directly to main is not allowed; create a feature branch instead\n' >&2
exit 1
fi
4 changes: 0 additions & 4 deletions documents/phase-15-plan.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -184,10 +184,6 @@ preserve Phase 14's required protection behavior.
* `mix precommit` needs no network, credentials, Git fetch, container runtime,
externally managed database, or other service after dependencies are present.
* `mix ci` runs every `mix precommit` check plus documented CI-only checks.
* Tests enforce both command sequences: `precommit` uses
`mix test --exclude ci_only`; `ci` uses unfiltered `mix test`; neither uses
`--only`; and no global `ci_only` exclusion is configured. Future plans may
add real tagged tests to this established boundary.
* GitHub Actions calls `mix ci`; Conventional Commit and PR-title validation
remain required CI behavior.
* Developer documentation describes the two commands without calling the local
Expand Down
5 changes: 5 additions & 0 deletions documents/quality-gates-decision.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -90,3 +90,8 @@ audits and usage-rule drift), followed by the unfiltered app test suite. That
final test run includes both ordinary tests and any future tests tagged
`ci_only`; the tag is excluded only by `mix precommit`, never globally. GitHub
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.
9 changes: 9 additions & 0 deletions git-hooks/pre-commit
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
#!/bin/sh
# Rejects direct commits to main, then runs the fast local quality gate. See
# app/usage-rules.md. Activate with: mix git_hooks

repo_top=$(git rev-parse --show-toplevel) || exit 1
"$repo_top/ci/validate_commit_branch.sh" || exit $?

cd "$repo_top" || exit 1
exec mix precommit
11 changes: 6 additions & 5 deletions lib/mix/tasks/git_hooks.ex
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
defmodule Mix.Tasks.GitHooks do
@shortdoc "Installs this repo's commit-msg and pre-push quality hooks"
@shortdoc "Installs this repo's pre-commit, commit-msg, and pre-push hooks"

@moduledoc """
#{@shortdoc}.

mix git_hooks

Sets `core.hooksPath` to `git-hooks/`: 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.
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.
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
2 changes: 1 addition & 1 deletion lib/mix/tasks/precommit.ex
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ defmodule Mix.Tasks.Precommit do
mix precommit

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
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.

It requires no network access, credentials, containers, or external services.
Expand Down
58 changes: 55 additions & 3 deletions test/git_hooks_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ defmodule GitHooksTest do
use ExUnit.Case, async: true

@subject_guard Path.expand("../ci/validate_conventional_subject.sh", __DIR__)
@branch_guard Path.expand("../ci/validate_commit_branch.sh", __DIR__)
@title_guard Path.expand("../ci/validate_pull_request_title.sh", __DIR__)
@range_guard Path.expand("../ci/validate_commit_range.sh", __DIR__)
@push_refs_guard Path.expand("../ci/validate_push_refs.sh", __DIR__)
Expand Down Expand Up @@ -246,10 +247,42 @@ defmodule GitHooksTest do
assert {"", 0} = run_with_stdin(@push_refs_guard, stdin, cd: worktree)
end

test "git-hooks/ directory contains only the commit-msg and pre-push adapters" do
test "git-hooks/ directory contains the pre-commit, commit-msg, and pre-push adapters" do
hooks_dir = Path.expand("../git-hooks", __DIR__)
entries = File.ls!(hooks_dir) |> Enum.sort()
assert entries == ["commit-msg", "pre-push"]
assert entries == ["commit-msg", "pre-commit", "pre-push"]
end

test "the pre-commit adapter rejects main before running the quality gate" do
{worktree, ci_dir} = setup_ci_worktree!()
marker = Path.join(worktree, "precommit-ran")
hook = install_pre_commit_hook!(worktree, ci_dir)
fake_bin = install_fake_mix!(worktree)

assert {output, 1} =
run(hook, [],
cd: worktree,
env: [{"MIX_MARKER", marker}, {"PATH", fake_bin <> ":" <> System.get_env("PATH")}]
)

assert output =~ "committing directly to main is not allowed"
refute File.exists?(marker)
end

test "the pre-commit adapter runs mix precommit on a feature branch" do
{worktree, ci_dir} = setup_ci_worktree!()
marker = Path.join(worktree, "precommit-ran")
hook = install_pre_commit_hook!(worktree, ci_dir)
fake_bin = install_fake_mix!(worktree)
git!(worktree, ["checkout", "-b", "feature"])

assert {"", 0} =
run(hook, [],
cd: worktree,
env: [{"MIX_MARKER", marker}, {"PATH", fake_bin <> ":" <> System.get_env("PATH")}]
)

assert File.read!(marker) == "precommit\n"
end

test "the pre-push adapter validates refs before running the Hex audit" do
Expand Down Expand Up @@ -343,7 +376,7 @@ defmodule GitHooksTest do
ci_dir = Path.join(worktree, "ci")
File.mkdir_p!(ci_dir)

for guard <- [@subject_guard, @range_guard, @push_refs_guard] do
for guard <- [@subject_guard, @branch_guard, @range_guard, @push_refs_guard] do
destination = Path.join(ci_dir, Path.basename(guard))
File.cp!(guard, destination)
File.chmod!(destination, 0o755)
Expand All @@ -352,6 +385,25 @@ defmodule GitHooksTest do
{worktree, ci_dir}
end

defp install_pre_commit_hook!(worktree, _ci_dir) do
hooks_dir = Path.join(worktree, "git-hooks")
File.mkdir_p!(hooks_dir)

hook = Path.join(hooks_dir, "pre-commit")
File.cp!(Path.expand("../git-hooks/pre-commit", __DIR__), hook)
File.chmod!(hook, 0o755)
hook
end

defp install_fake_mix!(worktree) 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.chmod!(fake_mix, 0o755)
fake_bin
end

defp install_pre_push_hook!(worktree, ci_dir) do
hooks_dir = Path.join(worktree, "git-hooks")
File.mkdir_p!(hooks_dir)
Expand Down
Loading