diff --git a/lib/mix/tasks/appdeps.update.ex b/lib/mix/tasks/appdeps.update.ex index c3d920f..cf68f43 100644 --- a/lib/mix/tasks/appdeps.update.ex +++ b/lib/mix/tasks/appdeps.update.ex @@ -6,7 +6,8 @@ defmodule Mix.Tasks.Appdeps.Update do mix appdeps.update DEP [DEP ...] - Runs `mix deps.update` for the named dependencies from the `app/` project. + Checks that CI uses mise's pinned toolchain, runs `mix deps.update` for the + named dependencies from the `app/` project, then runs the full `mix ci` gate. """ use Mix.Task @@ -24,7 +25,9 @@ defmodule Mix.Tasks.Appdeps.Update do end def run(dependencies, shell) do + Mix.Tasks.Toolchain.Check.run([]) shell.("mix", ["deps.update" | dependencies], cd: "app") + Mix.Tasks.Ci.run([], shell) :ok end end diff --git a/lib/mix/tasks/ci.ex b/lib/mix/tasks/ci.ex index 75f0cf8..4129d7e 100644 --- a/lib/mix/tasks/ci.ex +++ b/lib/mix/tasks/ci.ex @@ -54,6 +54,7 @@ defmodule Mix.Tasks.Ci do @doc false def run([], shell) do + Mix.Tasks.Toolchain.Check.run([]) shell.("mix", ["deps.get"], cd: "app") shell.("./ci/validate_pull_request_title.sh", [], []) shell.("./ci/validate_commit_range.sh", [], []) diff --git a/lib/mix/tasks/toolchain.check.ex b/lib/mix/tasks/toolchain.check.ex new file mode 100644 index 0000000..f5b6fee --- /dev/null +++ b/lib/mix/tasks/toolchain.check.ex @@ -0,0 +1,70 @@ +defmodule Mix.Tasks.Toolchain.Check do + @shortdoc "Checks that CI and mise use the same OTP and Elixir versions" + + @moduledoc """ + #{@shortdoc}. + + mix toolchain.check + + `mise.toml` is the local toolchain source of truth. This task rejects a + workflow that configures erlef/setup-beam with different OTP or Elixir pins. + """ + + use Mix.Task + + @impl Mix.Task + def run([]) do + workflows = + ".github/workflows/*.yaml" + |> Path.wildcard() + |> Enum.map(&{&1, File.read!(&1)}) + + case validate(File.read!("mise.toml"), workflows) do + :ok -> :ok + {:error, message} -> Mix.raise(message) + end + end + + def run(_argv), do: Mix.raise("Usage: mix toolchain.check") + + @doc false + def validate(mise_toml, workflows) do + expected = %{otp: pin!(mise_toml, "erlang"), elixir: pin!(mise_toml, "elixir")} + + mismatches = + Enum.flat_map(workflows, fn {path, workflow} -> + Enum.flat_map(setup_beam_steps(workflow), fn step -> + Enum.flat_map(expected, fn {name, expected_pin} -> + case Map.get(workflow_pins(step), name) do + ^expected_pin -> [] + nil -> ["#{path}: #{name} missing (expected #{expected_pin})"] + pin -> ["#{path}: #{name} #{pin} (expected #{expected_pin})"] + end + end) + end) + end) + + case mismatches do + [] -> :ok + _ -> {:error, "Toolchain pins differ from mise.toml:\n #{Enum.join(mismatches, "\n ")}"} + end + end + + defp pin!(contents, name) do + case Regex.run(~r/^#{name}\s*=\s*"([^"]+)"$/m, contents) do + [_, pin] -> pin + nil -> Mix.raise("mise.toml does not pin #{name}") + end + end + + defp workflow_pins(workflow) do + Regex.scan(~r/^\s*(otp|elixir)-version:\s*"([^"]+)"$/m, workflow) + |> Map.new(fn [_, name, pin] -> {if(name == "otp", do: :otp, else: :elixir), pin} end) + end + + defp setup_beam_steps(workflow) do + workflow + |> String.split(~r/\n(?=\s*-\s*$)/m) + |> Enum.filter(&String.contains?(&1, "uses: erlef/setup-beam@v1")) + end +end diff --git a/mise.toml b/mise.toml index b29c54e..5dedb63 100644 --- a/mise.toml +++ b/mise.toml @@ -18,6 +18,14 @@ python = "3.14.1" #CLAUDE_CODE_ATTRIBUTION_HEADER = 0 #CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = 1 +[tasks.setup] +description = "Set up the repository" +run = "mix setup" + +[tasks.deps] +description = "Fetch CLI dependencies" +run = "cd app && mix deps.get" + [hooks] # Keep a freshly cloned checkout protected without rerunning setup after it is installed. enter = ''' diff --git a/test/mix/tasks/appdeps.update_test.exs b/test/mix/tasks/appdeps.update_test.exs index 217f29b..bccf008 100644 --- a/test/mix/tasks/appdeps.update_test.exs +++ b/test/mix/tasks/appdeps.update_test.exs @@ -3,7 +3,7 @@ defmodule Mix.Tasks.Appdeps.UpdateTest do alias Mix.Tasks.Appdeps.Update - test "updates the named dependencies from app" do + test "updates the named dependencies from app, then runs the CI gate" do caller = self() shell = fn cmd, args, opts -> @@ -13,6 +13,7 @@ defmodule Mix.Tasks.Appdeps.UpdateTest do assert :ok = Update.run(["ash", "oban"], shell) assert_received {:run, "mix", ["deps.update", "ash", "oban"], [cd: "app"]} + assert_received {:run, "mix", ["deps.audit"], [cd: "app"]} end test "requires at least one dependency" do diff --git a/test/mix/tasks/toolchain.check_test.exs b/test/mix/tasks/toolchain.check_test.exs new file mode 100644 index 0000000..02e7c94 --- /dev/null +++ b/test/mix/tasks/toolchain.check_test.exs @@ -0,0 +1,45 @@ +defmodule Mix.Tasks.Toolchain.CheckTest do + use ExUnit.Case, async: true + + alias Mix.Tasks.Toolchain.Check + + @mise """ + [tools] + erlang = "29.1" + elixir = "1.20.4" + """ + + test "accepts matching workflow pins" do + assert :ok = + Check.validate(@mise, [ + {".github/workflows/ci.yaml", workflow("29.1", "1.20.4")} + ]) + end + + test "reports every workflow pin that differs from mise" do + assert {:error, message} = + Check.validate(@mise, [ + {".github/workflows/ci.yaml", workflow("29.0.3", "1.20.3")} + ]) + + assert message =~ "ci.yaml: otp 29.0.3 (expected 29.1)" + assert message =~ "ci.yaml: elixir 1.20.3 (expected 1.20.4)" + end + + test "rejects a setup-beam step without both pins" do + assert {:error, message} = + Check.validate(@mise, [ + {".github/workflows/ci.yaml", "uses: erlef/setup-beam@v1\notp-version: \"29.1\""} + ]) + + assert message =~ "ci.yaml: elixir missing (expected 1.20.4)" + end + + defp workflow(otp, elixir) do + """ + uses: erlef/setup-beam@v1 + otp-version: "#{otp}" + elixir-version: "#{elixir}" + """ + end +end diff --git a/vendor/stokowski b/vendor/stokowski index 2a43887..79fa1bb 160000 --- a/vendor/stokowski +++ b/vendor/stokowski @@ -1 +1 @@ -Subproject commit 2a43887511e8b3d16559e84c6e8626cef43646de +Subproject commit 79fa1bb032fdb4517a28a2f6dcf074ff4571829e diff --git a/workflow.opus.yaml b/workflow.opus.yaml index dcefed7..1139ddf 100644 --- a/workflow.opus.yaml +++ b/workflow.opus.yaml @@ -116,6 +116,7 @@ hooks: after_create: | git clone --depth 1 --recursive git@github.com:rubyists/linear-cli . mix setup + cd app && mix deps.get before_run: | set -eu git fetch origin main