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
5 changes: 4 additions & 1 deletion lib/mix/tasks/appdeps.update.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
1 change: 1 addition & 0 deletions lib/mix/tasks/ci.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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", [], [])
Expand Down
70 changes: 70 additions & 0 deletions lib/mix/tasks/toolchain.check.ex
Original file line number Diff line number Diff line change
@@ -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
8 changes: 8 additions & 0 deletions mise.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '''
Expand Down
3 changes: 2 additions & 1 deletion test/mix/tasks/appdeps.update_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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 ->
Expand All @@ -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
Expand Down
45 changes: 45 additions & 0 deletions test/mix/tasks/toolchain.check_test.exs
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion vendor/stokowski
Submodule stokowski updated 59 files
+17 −2 .claude/commands/release.md
+40 −4 .github/workflows/release.yml
+39 −0 .github/workflows/test.yml
+14 −0 .gitignore
+118 −1 CHANGELOG.md
+288 −23 CLAUDE.md
+39 −15 README.md
+12 −0 package.json
+61 −0 prompts/diagnose.example.md
+71 −0 prompts/explore.example.md
+49 −0 prompts/global-bug-fix.example.md
+44 −0 prompts/global-exploration.example.md
+119 −29 prompts/global.example.md
+101 −0 prompts/ground-check.example.md
+20 −9 prompts/implement.example.md
+26 −9 prompts/investigate.example.md
+4 −2 prompts/merge.example.md
+74 −0 prompts/reproduce.example.md
+47 −10 prompts/review.example.md
+2 −2 pyproject.toml
+100 −0 scripts/stokowski.sh
+15 −0 scripts/test.sh
+157 −0 stokowski/artifacts.py
+425 −14 stokowski/config.py
+364 −0 stokowski/continuity.py
+349 −0 stokowski/events.py
+240 −0 stokowski/ledger.py
+215 −22 stokowski/linear.py
+108 −3 stokowski/main.py
+48 −0 stokowski/model_catalogue.py
+79 −0 stokowski/models.py
+600 −90 stokowski/orchestrator.py
+207 −26 stokowski/prompt.py
+436 −0 stokowski/report.py
+317 −125 stokowski/runner.py
+483 −0 stokowski/studio.py
+24 −5 stokowski/tracking.py
+651 −3 stokowski/web.py
+18 −1 stokowski/workspace.py
+19 −0 tests/fixtures/real_turn.ndjson
+130 −0 tests/test_artifacts.py
+271 −0 tests/test_assignee_filter.py
+401 −0 tests/test_codex_runner.py
+272 −0 tests/test_continuity.py
+58 −0 tests/test_entry_state.py
+252 −0 tests/test_events.py
+394 −0 tests/test_example_prompts.py
+210 −0 tests/test_ledger.py
+367 −0 tests/test_report.py
+355 −0 tests/test_routing.py
+290 −0 tests/test_runner.py
+105 −0 tests/test_state_comments.py
+413 −0 tests/test_studio.py
+68 −0 tests/test_tracking_order.py
+43 −0 tests/test_workspace.py
+128 −23 workflow.example.yaml
+109 −0 workflows/bug-fix.example.yaml
+60 −0 workflows/exploration.example.yaml
+96 −0 workflows/feature.example.yaml
1 change: 1 addition & 0 deletions workflow.opus.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading