Current section

Files

Jump to
workspace lib mix tasks workspace.run.ex
Raw

lib/mix/tasks/workspace.run.ex

defmodule Mix.Tasks.Workspace.Run do
opts = [
task: [
type: :string,
short: "t",
doc: "The task to execute",
required: true
],
only_roots: [
type: :boolean,
doc: "If set, the task will be executed only on graph's root nodes.",
doc_section: :status
],
dry_run: [
type: :boolean,
doc: "If set it will not execute the command, useful for testing and debugging.",
default: false
],
env_vars: [
type: :string,
doc: """
Optional environment variables to be set before command execution. They are
expected to be in the form `ENV_VAR_NAME=value`. You can use this multiple times
for setting multiple variables.\
""",
long: "env-var",
multiple: true
],
allow_failure: [
type: :atom,
doc: """
Allow the task for this specific project to fail. Can be set more than once.
""",
multiple: true,
separator: ","
],
early_stop: [
type: :boolean,
doc: """
If set the execution will stop if the execution of any project failed
""",
default: false
],
partitions: [
type: :integer,
doc: """
Sets the number of partitions to split executions in. It must be a number
greater than zero. If set to `1` it acts as a no-op. If more than one you
must also set the the `WORKSPACE_RUN_PARTITION` environment variable with
the partition to use in the current execution. See the "Run partitioning"
section for more details
"""
],
export: [
type: :string,
doc: """
A `json` path to export the run results. If set a `json` file will be
generated with the run results for each project. This may be useful for
your CI/CD automation pipelines, in case you want to post-process the
run results.
""",
doc_section: :export
],
order: [
type: :string,
doc: """
The execution order based on the workspace graph. Can be one of the
following:
* `alphabetical` - The projects are sorted alphabetically
* `postorder` - Performs a depth-first search on the project graph and returns the
projects in post-order. In this order, outer leaves (projects without dependencies)
are returned first, followed by their parent projects, respecting the dependency
relationships between them.
""",
default: "alphabetical",
allowed: ["alphabetical", "postorder"]
]
]
@options_schema Workspace.Cli.options(
[
:workspace_path,
:config_path,
:project,
:exclude,
:tags,
:excluded_tags,
:affected,
:modified,
:base,
:head,
:verbose,
:show_status,
:paths,
:dependency,
:dependent
],
opts
)
@shortdoc "Run a mix command to all projects"
@moduledoc """
Run a mix task on one or more workspace projects.
You need to specify the task to be executed with the `--task, -t` option:
$ mix workspace.run -t format
This will run `mix format` on all projects of the workspace. You can also select
the projects to run, or exclude specific projects:
# Run format only on foo and bar
$ mix workspace.run -t format -p foo -p bar
# Run format on all projects except foo
$ mix workspace.run -t format -e foo
### Passing options to tasks
You can pass task specific options after the return separator (`--`). For example:
$ mix workspace.run -t format -p foo -p bar -- --check-formatted
## Command-line Options
#{CliOptions.docs(@options_schema, sort: true, sections: Workspace.CliOptions.doc_sections())}
## Filtering runs
Monorepos can have hundreds of projects so being able to run a task against all
or a subset of them is a key feature of Workspace.
One of the key features of `workspace.run` is the flexibility regarding the projects
on which the task will be executed.
### The project graph
The core concept of `Workspace` is the project graph. This is a directed acyclic
graphs depicting the internal project dependencies. This is used by all tasks
internally in order to specify the status of the projects.
Assume you have the following workspace:
```
package_a
├── package_b ✚
│ └── package_g
├── package_c
│ ├── package_e
│ └── package_f
│ └── package_g
└── package_d
package_h ✚
└── package_d
```
It consists of various packages, and two of them (`_b` and `_h`) are modified. Using
the graph we can find the dependencies between packages and **limit the execution of
a task** only to the subset that makes sense.
**Using the proper execution strategy wisely significantly improves the CI execution
time**.
> #### Where to run a task? {: .tip}
>
> This clearly depends on the type of the task. Let's see some examples:
>
> - `mix format` makes sense to be executed only on the modified packages.
> - `mix test` should be executed on the modified and the parents of them since a change
> on a package may affect a dependent one.
> - `mix deps.get` makes sense to be executed only on the root packages if
> you have adopted a common deps path, since internal dependencies will be
> inherited from dependent packages.
> - In the main branch it makes sense to run the full suite on the
> complete workspace.
In order to visualize the graph of the current workspace check the `mix workspace.graph`
task.
### Filtering examples
Let's see some examples:
# Run test on all workspace projects
$ mix workspace.run -t test
# Run test only on foo and bar
$ mix workspace.run -t test -p foo -p bar
# Run test on all projects excluding foo and bar
$ mix workspace.run -t test --exclude foo --exclude bar
# Run test on all affected projects
$ mix workspace.run -t test --affected
# Run test on all modified projects
$ mix workspace.run -t test --modified
# Run test only on top level projects
$ mix workspace.run -t test --only-roots
## Execution order
By default the given task will run on all projects (matching any provided filter) alphabetically
with respect to the project app name. You can also pass the `--order postorder` option
which applies a topologic sort. This will perform a depth first search on the project graph
and return the project in post-order, e.g. outer leaves are returned first respecting
your workspace graph.
$ mix workspace.run -t compile --order postorder
The `preorder` option ensures that dependencies are executed first, which can reduce
execution time by avoiding unnecessary task invocations on dependencies (only if the
task requires dependencies being executed first, e.g. `mix compile` with a common
build path).
## Run partitioning
In big workspaces some CI steps may take a lot time. You can split the execution of
a task in multiple partitions in order to speed up this process. This is done by
setting the `--partitions` option and setting the `WORKSPACE_RUN_PARTITION` environment
variable to control the current partition.
For example to split the execution of `mix test` on all workspace projects into
4 partitions, you would use the following:
$ WORKSPACE_RUN_PARTITION=1 mix workspace.run -t run --partitions 4
$ WORKSPACE_RUN_PARTITION=2 mix workspace.run -t run --partitions 4
$ WORKSPACE_RUN_PARTITION=3 mix workspace.run -t run --partitions 4
$ WORKSPACE_RUN_PARTITION=4 mix workspace.run -t run --partitions 4
The matching projects are sorted upfront and assigned to each partition in a round-robin
fashion.
## The `--env-var` option
Some tasks depend on environment variables. You can use the `--env-var` option to
set an environment variable only during a task's execution. This is particularly useful
in CI environments.
$ mix workspace.run -t compile --env-var MIX_ENV=compile
You can set multiple environment variables by setting the `--env-var` multiple times.
## Early stopping
You can set the `--early-stop` flag in order to immediately terminate the command if
the execution in any of the projects has failed.
## Allowing failures on some projects
On big codebases not all projects are of the same quality standards and some linter
tasks may fail on them. In such cases you may want to still run the linting tasks on
these projects but ignore their output status from the overall run task's status. In
such cases you can use the `--allow-failure` option:
# Run lint on all projects but ignore the output status of project foo
$ mix workspace.run -t lint --allow-failure foo
## Dry running a task
You can set the `--dry-run` option in order to dry run the task, e.g. to check the
sequence of mix tasks that will be invoked.
## Exporting run results
You can set the `--export` option in order to save the run results per workspace
project in a `json` file. This may be useful in case you want to post-process run
results in your CI pipelines, for example you could use it to generate a summary
of the execution and post it as a comment to the corresponding PR/MR.
# Exporting execution results to test.json
$ mix workspace.run -t test --export test.json
For each project the following will be included:
* The project info
* The executed task
* The run's status (one of `"ok", error", "skip"`)
* Execution time info like the `triggered_at`, `completed_at` timestamps and the `duration`
of the task in milliseconds.
* The task's status code
* The task's output with ANSI sequences removed
Notice that all workspace projects will be included in the generated `json` including
the skipped projects.
"""
use Mix.Task
@recursive true
import Workspace.Cli
@impl Mix.Task
def run(args) do
Mix.Task.reenable("workspace.run")
{opts, _args, extra} = CliOptions.parse!(args, @options_schema)
env = Enum.map(opts[:env_vars] || [], &parse_environment_variable/1)
opts =
opts
|> Keyword.put(:env, env)
|> Keyword.put(:argv, extra)
|> Keyword.put_new(:allow_failure, [])
filtered_projects =
opts
|> Mix.WorkspaceUtils.load_and_filter_workspace()
|> Workspace.projects(order: String.to_existing_atom(opts[:order]))
|> filter_by_partition(opts[:partitions])
case Enum.count(filtered_projects, &(not &1.skip)) do
0 ->
log([:bright, :yellow, "No matching projects for the given options", :reset])
valid ->
log([
"Running task in ",
:bright,
:blue,
"#{valid} workspace projects",
:reset
])
end
results =
filtered_projects
|> Enum.map(fn project ->
triggered_at = System.os_time(:millisecond)
result = run_task(project, opts)
completed_at = System.os_time(:millisecond)
execution_result =
%{
project: project,
task: opts[:task],
argv: opts[:argv],
status: execution_status(result, allowed_to_fail?(project.app, opts[:allow_failure])),
status_code: status_code(result),
output: cmd_output(result),
triggered_at: triggered_at,
completed_at: completed_at,
duration: completed_at - triggered_at
}
log_task_execution_result(execution_result)
if opts[:early_stop] do
maybe_early_stop(execution_result)
end
execution_result
end)
if opts[:export], do: export_execution_results(results, opts[:export])
grouped_results = Enum.group_by(results, fn result -> result.status end)
maybe_log_warnings(grouped_results[:warn] || [])
raise_if_errors(grouped_results[:error] || [])
end
defp parse_environment_variable(var) do
case String.split(var, "=") do
[name, value] when value != "" ->
{String.upcase(name) |> String.to_charlist(), String.to_charlist(value)}
other ->
Mix.raise(
"invalid environment variable definition, it should be of the form " <>
"ENV_VAR_NAME=value, got: #{other}"
)
end
end
defp filter_by_partition(projects, partitions) when partitions in [nil, 1], do: projects
defp filter_by_partition(projects, partitions) when partitions > 1 do
partition = System.get_env("WORKSPACE_RUN_PARTITION")
case partition && Integer.parse(partition) do
{partition, ""} when partition in 1..partitions//1 ->
partition = partition - 1
for {project, index} <- Enum.with_index(Enum.sort_by(projects, & &1.app)),
rem(index, partitions) == partition,
do: project
_other ->
Mix.raise(
"The WORKSPACE_RUN_PARTITION environment variable must be set to an integer between " <>
"1..#{partitions} when the --partitions option is set, got: #{inspect(partition)}"
)
end
end
# if the project is skipped we only print a message if --verbose is set
defp run_task(%{skip: true} = project, options) do
if options[:verbose] do
log_with_title(
project_name(project, show_status: options[:show_status]),
highlight("skipped", [:bright, :yellow]),
prefix: :header
)
end
:skip
end
defp run_task(project, options) do
log_with_title(
project_name(project, show_status: options[:show_status]),
highlight(mix_task_to_string(options[:task], options[:argv]), :bright),
prefix: :header
)
case options[:dry_run] do
true -> :skip
false -> cmd(options[:task], options[:argv], project, options[:env])
end
end
defp mix_task_to_string(task, argv), do: ~s'mix #{Enum.join([task | argv], " ")}'
defp cmd(task, argv, project, env) do
[command | args] = enable_ansi(["mix", task | argv])
command = System.find_executable(command)
{status_code, output} =
Port.open({:spawn_executable, command}, [
:stream,
:hide,
:use_stdio,
:stderr_to_stdout,
:binary,
:exit_status,
args: args,
cd: project.path,
env: env
])
|> stream_output([])
case status_code do
0 -> {:ok, output}
status_code -> {:error, status_code, output}
end
end
# elixir tasks are not run in a TTY and will by default not print ANSI
# characters, We explicitely enable ANSI
# kudos to `ex_check`: https://github.com/karolsluszniak/ex_check
defp enable_ansi(["mix" | args]) do
erl_config = Application.app_dir(:workspace, ~w[priv enable_ansi.config])
["elixir", "--erl-config", erl_config, "-S", "mix" | args]
end
defp stream_output(port, acc) do
receive do
{^port, {:data, data}} ->
clean_data = Workspace.ANSI.unescape(data)
IO.write(data)
stream_output(port, [clean_data | acc])
{^port, {:exit_status, status}} ->
{status, Enum.reverse(acc) |> Enum.join()}
end
end
defp allowed_to_fail?(project, allowed_to_fail), do: project in allowed_to_fail
defp execution_status({:error, _status, _output}, true), do: :warn
defp execution_status({:error, _status, _output}, false), do: :error
defp execution_status({:ok, _output}, _allowed_to_fail), do: :ok
defp execution_status(:skip, _allowed_to_fail), do: :skip
defp status_code(:skip), do: nil
defp status_code({:ok, _output}), do: 0
defp status_code({:error, status, _output}), do: status
defp cmd_output(:skip), do: nil
defp cmd_output({:ok, output}), do: output
defp cmd_output({:error, _code, output}), do: output
defp maybe_early_stop(result) do
case result[:status] do
:error ->
Mix.raise("--early-stop is set - terminating workspace.run")
_other ->
result
end
end
defp log_task_execution_result(%{status: status} = result) when status not in [:skip] do
result_message =
case status do
:ok -> " succeeded"
_other -> [" failed with ", highlight("#{result.status_code}", [:bright, :light_red])]
end
duration = Workspace.Utils.format_duration(result.duration)
log([
highlight(inspect(result.project.app), [:bright, status_color(status)]),
" ",
highlight(mix_task_to_string(result.task, result.argv), :bright),
result_message,
" [#{duration}]"
])
end
defp log_task_execution_result(_result), do: :ok
defp maybe_log_warnings(projects) do
if length(projects) > 0 do
names = Enum.map(projects, & &1.project.app)
Workspace.Cli.log([
:yellow,
"WARNING ",
:reset,
"task failed in #{length(projects)} projects but the ",
:light_cyan,
"--alow-failure",
:reset,
" flag is set"
])
Workspace.Cli.log([" failed projects - ", :yellow, inspect(names)])
end
end
defp raise_if_errors(projects) do
names = Enum.map(projects, & &1.project.app)
if length(projects) > 0 do
Mix.raise("""
mix workspace.run failed - errors detected in #{length(names)} executions
failed projects - #{inspect(names)}
""")
end
end
defp export_execution_results(results, output_path) do
json_data = Workspace.Export.run_results_to_json(results)
File.write!(output_path, json_data)
Workspace.Cli.log(
[:green, "exported ", :reset, "execution results to ", :yellow, output_path, :reset],
prefix: "* "
)
end
end