From d808d56ddab34f5891ccd7d13d6a005d5cf5484c Mon Sep 17 00:00:00 2001 From: "Tj (bougyman) Vanderpoel" Date: Wed, 12 Aug 2026 13:53:47 -0400 Subject: [PATCH] feat: add a root-level repo-management Mix project (mix container.build/publish) New root-level mix.exs (app: :repo_tasks), sibling to app/'s own - tooling that operates on the repo as a whole, not the CLI itself. Dependency-free: every task shells out (mix release inside app/, ci/build_image.sh, ci/publish.sh) rather than running in-process, since app/ and this project are separate Mix projects with no in-process Mix.Task.run/2 path between them anyway. First tasks: mix container.build TAG [--push] (Burrito binary -> image build, chains into publish with --push) and mix container.publish TAG (standalone ci/publish.sh wrapper) - mirrors the build/publish split already made at the shell level. Verified end-to-end for real: built and pushed v1.1.1, confirmed live in GHCR, fixed the package's repository connection (GHCR packages don't follow a git repo rename). Found and fixed three real bugs along the way: missing --overwrite on mix release, a stale compiled burrito dependency .beam with the pre-rename absolute path baked into its debug-info via __ENV__.file, and System.cmd/3 not resolving ./-relative script paths the way a shell does. Also cleans up mix new .'s leftover boilerplate (a README.md that would've competed with the real Readme.adoc, a test file for the deleted default module). Closes #83 partially - still need to wire main.yaml's container job to use these tasks instead of its current ad-hoc steps. --- .formatter.exs | 4 ++ .gitignore | 5 ++ lib/mix/tasks/container.build.ex | 57 +++++++++++++++++++++++ lib/mix/tasks/container.publish.ex | 32 +++++++++++++ lib/repo_tasks/shell.ex | 46 ++++++++++++++++++ mix.exs | 31 ++++++++++++ test/mix/tasks/container.build_test.exs | 15 ++++++ test/mix/tasks/container.publish_test.exs | 15 ++++++ test/test_helper.exs | 1 + 9 files changed, 206 insertions(+) create mode 100644 .formatter.exs create mode 100644 lib/mix/tasks/container.build.ex create mode 100644 lib/mix/tasks/container.publish.ex create mode 100644 lib/repo_tasks/shell.ex create mode 100644 mix.exs create mode 100644 test/mix/tasks/container.build_test.exs create mode 100644 test/mix/tasks/container.publish_test.exs create mode 100644 test/test_helper.exs diff --git a/.formatter.exs b/.formatter.exs new file mode 100644 index 0000000..d2cda26 --- /dev/null +++ b/.formatter.exs @@ -0,0 +1,4 @@ +# Used by "mix format" +[ + inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] +] diff --git a/.gitignore b/.gitignore index b7f8b9d..db0ca80 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,8 @@ *.swp *.swo *~ + +# Root-level repo-management Mix project (mix.exs/lib/ at repo root - +# see RepoTasks.MixProject) - app/'s own equivalents are in app/.gitignore. +/_build/ +/deps/ diff --git a/lib/mix/tasks/container.build.ex b/lib/mix/tasks/container.build.ex new file mode 100644 index 0000000..8fa05c9 --- /dev/null +++ b/lib/mix/tasks/container.build.ex @@ -0,0 +1,57 @@ +defmodule Mix.Tasks.Container.Build do + @shortdoc "Builds the linux_x86_64 Burrito binary, then the container image" + + @moduledoc """ + #{@shortdoc}. + + mix container.build TAG [--push] + + Builds `app/`'s `linux_x86_64` Burrito binary (the container's payload - + `MIX_ENV=prod BURRITO_TARGET=linux_x86_64 mix release lc --overwrite` - + `--overwrite` so re-running this locally for a version already built + doesn't block on `mix release`'s own interactive prompt), then the + container image itself via `ci/build_image.sh`. With `--push` (`-p`), + chains straight into `mix container.publish TAG` afterward. + + This is the exact flow `.github/workflows/main.yaml`'s `container` job + runs - CI and a local `mix container.build v1.2.3 --push` do the + identical thing, so there's one place to fix if either ever breaks. + """ + + use Mix.Task + + alias RepoTasks.Shell + + @impl Mix.Task + def run(argv) do + {opts, args} = + OptionParser.parse!(argv, strict: [push: :boolean], aliases: [p: :push]) + + tag = + case args do + [tag] -> tag + _ -> Mix.raise("Usage: mix container.build TAG [--push]") + end + + Mix.shell().info("==> Fetching app/ deps") + Shell.run!("mix", ["deps.get"], cd: "app") + + Mix.shell().info("==> Building the linux_x86_64 Burrito binary") + + Shell.run!( + "mix", + ["release", "lc", "--overwrite"], + cd: "app", + env: [{"MIX_ENV", "prod"}, {"BURRITO_TARGET", "linux_x86_64"}] + ) + + Mix.shell().info("==> Building the container image (#{tag})") + Shell.run!("./ci/build_image.sh", [tag], env: [{"APP_VERSION", tag}]) + + if opts[:push] do + Mix.Task.run("container.publish", [tag]) + end + + :ok + end +end diff --git a/lib/mix/tasks/container.publish.ex b/lib/mix/tasks/container.publish.ex new file mode 100644 index 0000000..2ba41da --- /dev/null +++ b/lib/mix/tasks/container.publish.ex @@ -0,0 +1,32 @@ +defmodule Mix.Tasks.Container.Publish do + @shortdoc "Publishes an already-built local container image to the registry" + + @moduledoc """ + #{@shortdoc}. + + mix container.publish TAG + + Thin wrapper around `ci/publish.sh` - the image must already be built + locally under the same TAG (`mix container.build TAG`, or + `ci/build_image.sh` directly). Needs `REGISTRY_TOKEN` or `GITHUB_TOKEN` + set to log in to the registry - `ci/publish.sh`'s own requirement, + unchanged here. + """ + + use Mix.Task + + alias RepoTasks.Shell + + @impl Mix.Task + def run(argv) do + case argv do + [tag] -> + Mix.shell().info("==> Publishing #{tag}") + Shell.run!("./ci/publish.sh", [tag]) + :ok + + _ -> + Mix.raise("Usage: mix container.publish TAG") + end + end +end diff --git a/lib/repo_tasks/shell.ex b/lib/repo_tasks/shell.ex new file mode 100644 index 0000000..343c90c --- /dev/null +++ b/lib/repo_tasks/shell.ex @@ -0,0 +1,46 @@ +defmodule RepoTasks.Shell do + @moduledoc """ + Runs an external command (a `mix release` inside `app/`, or one of the + `ci/*.sh` scripts) with its output streamed live to stdout as it's + produced, raising via `Mix.raise/1` on a non-zero exit - a clean error + message and exit code, not a stacktrace, matching how every other Mix + task failure in this codebase behaves. + + Every repo-management task built on top of this shells out rather than + running anything in-process - see `RepoTasks.MixProject`'s own + moduledoc for why. + """ + + @doc """ + Runs `cmd` with `args`, streaming combined stdout/stderr live. + + `opts` forwards to `System.cmd/3` (e.g. `cd:`, `env:`) - `:into` and + `:stderr_to_stdout` are already set here and can't be overridden, since + live streaming is this function's whole point. + """ + @spec run!(String.t(), [String.t()], keyword()) :: :ok + def run!(cmd, args, opts \\ []) do + # System.cmd/3 only resolves a bare name (e.g. "mix") via PATH lookup - + # a relative script path like "./ci/build_image.sh" isn't in PATH nor + # absolute, so it raises :enoent (verified directly - this isn't a + # shell, "./" isn't special to it the way it is to bash). Expand any + # path-shaped cmd (contains "/") to absolute, relative to this + # process's cwd; bare command names are left alone for PATH lookup. + cmd = if String.contains?(cmd, "/"), do: Path.expand(cmd), else: cmd + + Mix.shell().info("+ #{cmd} #{Enum.join(args, " ")}") + + cmd_opts = + opts + |> Keyword.put(:into, IO.stream(:stdio, :line)) + |> Keyword.put(:stderr_to_stdout, true) + + {_io, status} = System.cmd(cmd, args, cmd_opts) + + if status != 0 do + Mix.raise("#{cmd} #{Enum.join(args, " ")} exited with status #{status}") + end + + :ok + end +end diff --git a/mix.exs b/mix.exs new file mode 100644 index 0000000..33c17e8 --- /dev/null +++ b/mix.exs @@ -0,0 +1,31 @@ +defmodule RepoTasks.MixProject do + use Mix.Project + + # Repo-management tasks (mix container.build/publish, and more to come) - + # deliberately its own Mix project, sibling to app/, not nested inside it. + # app/ is the CLI itself; this is tooling that operates ON the repo as a + # whole (this file's own directory, plus ci/, oci/, .github/workflows/ - + # things app/'s own mix.exs has no business knowing about). Kept + # dependency-free on purpose: every task here just orchestrates other + # already-existing tools (mix release inside app/, the ci/*.sh scripts) + # via System.cmd/3, never runs anything in-process. + def project do + [ + app: :repo_tasks, + version: "0.1.0", + elixir: "~> 1.20", + start_permanent: Mix.env() == :prod, + deps: deps() + ] + end + + def application do + [ + extra_applications: [:logger] + ] + end + + defp deps do + [] + end +end diff --git a/test/mix/tasks/container.build_test.exs b/test/mix/tasks/container.build_test.exs new file mode 100644 index 0000000..e408da2 --- /dev/null +++ b/test/mix/tasks/container.build_test.exs @@ -0,0 +1,15 @@ +defmodule Mix.Tasks.Container.BuildTest do + use ExUnit.Case, async: true + + test "requires a TAG argument" do + assert_raise Mix.Error, "Usage: mix container.build TAG [--push]", fn -> + Mix.Tasks.Container.Build.run([]) + end + end + + test "rejects more than one positional argument" do + assert_raise Mix.Error, "Usage: mix container.build TAG [--push]", fn -> + Mix.Tasks.Container.Build.run(["v1.0.0", "extra"]) + end + end +end diff --git a/test/mix/tasks/container.publish_test.exs b/test/mix/tasks/container.publish_test.exs new file mode 100644 index 0000000..ab8cfd0 --- /dev/null +++ b/test/mix/tasks/container.publish_test.exs @@ -0,0 +1,15 @@ +defmodule Mix.Tasks.Container.PublishTest do + use ExUnit.Case, async: true + + test "requires a TAG argument" do + assert_raise Mix.Error, "Usage: mix container.publish TAG", fn -> + Mix.Tasks.Container.Publish.run([]) + end + end + + test "rejects more than one positional argument" do + assert_raise Mix.Error, "Usage: mix container.publish TAG", fn -> + Mix.Tasks.Container.Publish.run(["v1.0.0", "extra"]) + end + end +end diff --git a/test/test_helper.exs b/test/test_helper.exs new file mode 100644 index 0000000..869559e --- /dev/null +++ b/test/test_helper.exs @@ -0,0 +1 @@ +ExUnit.start()