diff --git a/docs/docs/app-configuration.md b/docs/docs/app-configuration.md
index fe9ff2dad..4036bafa5 100644
--- a/docs/docs/app-configuration.md
+++ b/docs/docs/app-configuration.md
@@ -56,7 +56,7 @@ miren deploy
### Detection
-For each supported stack (Python, Node.js, Bun, Go, Ruby, Rust), `miren init`:
+For each supported stack (Python, Node.js, Bun, Go, Ruby, Rust, Elixir), `miren init`:
- Reads your manifest (`Gemfile`, `package.json`, `pyproject.toml`, `Cargo.toml`, `go.mod`) to map known libraries to the env vars they typically expect — `pg` → `DATABASE_URL`, `@sentry/node` → `SENTRY_DSN`, and so on.
- Greps your source code for direct env reads (`ENV['X']`, `process.env.X`, `os.Getenv("X")`, `std::env::var("X")`, `Bun.env.X`) and notes whether each one has a fallback.
diff --git a/docs/docs/getting-started.md b/docs/docs/getting-started.md
index e443edf68..629cd3040 100644
--- a/docs/docs/getting-started.md
+++ b/docs/docs/getting-started.md
@@ -166,7 +166,7 @@ That's it! You have an app running on Miren.
Now that you've got something deployed, here's where to go depending on what you need.
-**Deploy your own app.** Miren auto-detects Python, Node, Bun, Go, Ruby, and Rust projects. Run `miren init` in your project to create a [`.miren/app.toml`](./app-configuration.md) config, then `miren deploy`. If your app already has a runnable container image, [configure it directly](./deployment.md#deploying-an-existing-image). Use a `Dockerfile` when the deployment still needs to add source, packages, or build steps. For a step-by-step source-build walkthrough, see the [Language Guides](./guides/index.md).
+**Deploy your own app.** Miren auto-detects Python, Node, Bun, Go, Ruby, Rust, and Elixir projects. Run `miren init` in your project to create a [`.miren/app.toml`](./app-configuration.md) config, then `miren deploy`. If your app already has a runnable container image, [configure it directly](./deployment.md#deploying-an-existing-image). Use a `Dockerfile` when the deployment still needs to add source, packages, or build steps. For a step-by-step source-build walkthrough, see the [Language Guides](./guides/index.md).
:::tip[Set up your own app with an agent]
Install the [Miren agent skills](./agent-skills.md) and ask your AI coding agent to
diff --git a/docs/docs/guides/bash.md b/docs/docs/guides/bash.md
index cca6a5e54..d3a6cab1a 100644
--- a/docs/docs/guides/bash.md
+++ b/docs/docs/guides/bash.md
@@ -24,7 +24,7 @@ Yes. Add a `Dockerfile.miren` to your project root. Miren builds from it instead
guessing the stack — see [Using Dockerfile.miren](./index.md#using-dockerfilemiren).
:::tip[Want native support?]
-Miren auto-detects and builds common stacks (Python, Node, Bun, Go, Ruby, Rust)
+Miren auto-detects and builds [common stacks](./index.md#auto-detected-vs-dockerfile)
without a Dockerfile. This language isn't one of them yet — if you'd like first-class
support, [request it](https://linear.miren.garden/suggest).
:::
diff --git a/docs/docs/guides/c.md b/docs/docs/guides/c.md
index cc0566feb..a3deeed03 100644
--- a/docs/docs/guides/c.md
+++ b/docs/docs/guides/c.md
@@ -27,7 +27,7 @@ Miren builds from it instead of guessing the stack — see
[Using Dockerfile.miren](./index.md#using-dockerfilemiren).
:::tip[Want native support?]
-Miren auto-detects and builds common stacks (Python, Node, Bun, Go, Ruby, Rust)
+Miren auto-detects and builds [common stacks](./index.md#auto-detected-vs-dockerfile)
without a Dockerfile. This language isn't one of them yet — if you'd like first-class
support, [request it](https://linear.miren.garden/suggest).
:::
diff --git a/docs/docs/guides/clojure.md b/docs/docs/guides/clojure.md
index 975e21f18..826c64689 100644
--- a/docs/docs/guides/clojure.md
+++ b/docs/docs/guides/clojure.md
@@ -25,7 +25,7 @@ Miren builds from it instead of guessing the stack — see
[Using Dockerfile.miren](./index.md#using-dockerfilemiren).
:::tip[Want native support?]
-Miren auto-detects and builds common stacks (Python, Node, Bun, Go, Ruby, Rust)
+Miren auto-detects and builds [common stacks](./index.md#auto-detected-vs-dockerfile)
without a Dockerfile. This language isn't one of them yet — if you'd like first-class
support, [request it](https://linear.miren.garden/suggest).
:::
diff --git a/docs/docs/guides/cobol.md b/docs/docs/guides/cobol.md
index 927b884cb..af7cf6de0 100644
--- a/docs/docs/guides/cobol.md
+++ b/docs/docs/guides/cobol.md
@@ -25,7 +25,7 @@ Yes. Add a `Dockerfile.miren` to your project root. Miren builds from it instead
guessing the stack — see [Using Dockerfile.miren](./index.md#using-dockerfilemiren).
:::tip[Want native support?]
-Miren auto-detects and builds common stacks (Python, Node, Bun, Go, Ruby, Rust)
+Miren auto-detects and builds [common stacks](./index.md#auto-detected-vs-dockerfile)
without a Dockerfile. This language isn't one of them yet — if you'd like first-class
support, [request it](https://linear.miren.garden/suggest).
:::
diff --git a/docs/docs/guides/commonlisp.md b/docs/docs/guides/commonlisp.md
index 1c704a61f..d1c227376 100644
--- a/docs/docs/guides/commonlisp.md
+++ b/docs/docs/guides/commonlisp.md
@@ -24,7 +24,7 @@ Yes. Add a `Dockerfile.miren` to your project root. Miren builds from it instead
guessing the stack — see [Using Dockerfile.miren](./index.md#using-dockerfilemiren).
:::tip[Want native support?]
-Miren auto-detects and builds common stacks (Python, Node, Bun, Go, Ruby, Rust)
+Miren auto-detects and builds [common stacks](./index.md#auto-detected-vs-dockerfile)
without a Dockerfile. This language isn't one of them yet — if you'd like first-class
support, [request it](https://linear.miren.garden/suggest).
:::
diff --git a/docs/docs/guides/cpp.md b/docs/docs/guides/cpp.md
index 0d48d07f7..b0c8cf9bf 100644
--- a/docs/docs/guides/cpp.md
+++ b/docs/docs/guides/cpp.md
@@ -25,7 +25,7 @@ Miren builds from it instead of guessing the stack — see
[Using Dockerfile.miren](./index.md#using-dockerfilemiren).
:::tip[Want native support?]
-Miren auto-detects and builds common stacks (Python, Node, Bun, Go, Ruby, Rust)
+Miren auto-detects and builds [common stacks](./index.md#auto-detected-vs-dockerfile)
without a Dockerfile. This language isn't one of them yet — if you'd like first-class
support, [request it](https://linear.miren.garden/suggest).
:::
diff --git a/docs/docs/guides/crystal.md b/docs/docs/guides/crystal.md
index f98325d78..f9f0b0bc9 100644
--- a/docs/docs/guides/crystal.md
+++ b/docs/docs/guides/crystal.md
@@ -26,7 +26,7 @@ root. Miren builds from it instead of guessing the stack — see
[Using Dockerfile.miren](./index.md#using-dockerfilemiren).
:::tip[Want native support?]
-Miren auto-detects and builds common stacks (Python, Node, Bun, Go, Ruby, Rust)
+Miren auto-detects and builds [common stacks](./index.md#auto-detected-vs-dockerfile)
without a Dockerfile. This language isn't one of them yet — if you'd like first-class
support, [request it](https://linear.miren.garden/suggest).
:::
diff --git a/docs/docs/guides/dart.md b/docs/docs/guides/dart.md
index 7f1bb5e1a..cee0322db 100644
--- a/docs/docs/guides/dart.md
+++ b/docs/docs/guides/dart.md
@@ -25,7 +25,7 @@ Miren builds from it instead of guessing the stack — see
[Using Dockerfile.miren](./index.md#using-dockerfilemiren).
:::tip[Want native support?]
-Miren auto-detects and builds common stacks (Python, Node, Bun, Go, Ruby, Rust)
+Miren auto-detects and builds [common stacks](./index.md#auto-detected-vs-dockerfile)
without a Dockerfile. This language isn't one of them yet — if you'd like first-class
support, [request it](https://linear.miren.garden/suggest).
:::
diff --git a/docs/docs/guides/deno.md b/docs/docs/guides/deno.md
index 4eb8d88e3..b0cb56cab 100644
--- a/docs/docs/guides/deno.md
+++ b/docs/docs/guides/deno.md
@@ -27,7 +27,7 @@ from it instead of guessing the stack — see
[Using Dockerfile.miren](./index.md#using-dockerfilemiren).
:::tip[Want native support?]
-Miren auto-detects and builds common stacks (Python, Node, Bun, Go, Ruby, Rust)
+Miren auto-detects and builds [common stacks](./index.md#auto-detected-vs-dockerfile)
without a Dockerfile. This language isn't one of them yet — if you'd like first-class
support, [request it](https://linear.miren.garden/suggest).
:::
diff --git a/docs/docs/guides/dotnet.md b/docs/docs/guides/dotnet.md
index ecb3c6bf5..cbb37bef7 100644
--- a/docs/docs/guides/dotnet.md
+++ b/docs/docs/guides/dotnet.md
@@ -26,7 +26,7 @@ Miren builds from it instead of guessing the stack — see
[Using Dockerfile.miren](./index.md#using-dockerfilemiren).
:::tip[Want native support?]
-Miren auto-detects and builds common stacks (Python, Node, Bun, Go, Ruby, Rust)
+Miren auto-detects and builds [common stacks](./index.md#auto-detected-vs-dockerfile)
without a Dockerfile. This language isn't one of them yet — if you'd like first-class
support, [request it](https://linear.miren.garden/suggest).
:::
diff --git a/docs/docs/guides/elixir.md b/docs/docs/guides/elixir.md
index 9d3778c44..1341b4a25 100644
--- a/docs/docs/guides/elixir.md
+++ b/docs/docs/guides/elixir.md
@@ -1,193 +1,125 @@
---
title: Elixir on Miren
-description: Deploy Elixir and Phoenix apps on Miren with a Dockerfile.miren that builds a Mix release.
-keywords: [elixir, phoenix, mix release, otp, ecto, secret_key_base, dockerfile, deploy]
+description: Deploy Elixir and Phoenix apps on Miren. Miren detects mix.exs, builds a Mix release, and runs it on a slim image, no Dockerfile required.
+keywords: [elixir, phoenix, mix release, otp, ecto, liveview, secret_key_base, deploy]
---
import CliCommand from '@site/src/components/CliCommand';
# Elixir on Miren
-Elixir isn't auto-detected, so you deploy it with a `Dockerfile.miren` that builds a
-Mix release. This guide uses Phoenix as the example — the same pattern works for any
-Elixir release. The Dockerfile and steps below were validated end-to-end on a live
-Miren cluster with a fresh `mix phx.new` app, Postgres addon, and migrations.
+Miren auto-detects Elixir apps from `mix.exs`, builds a [Mix
+release](https://hexdocs.pm/mix/Mix.Tasks.Release.html), and ships just the release on a
+slim Debian image. Phoenix apps get their assets built and their endpoint started
+automatically. No Dockerfile required.
:::tip[Let your agent do this]
Ask your AI coding agent to "set up this Phoenix app on Miren" after installing the
-[Miren agent skills](../agent-skills.md). It can generate the release
-(`mix phx.gen.release`), drop in the `Dockerfile.miren`, wire up the database addon and
-secrets, and deploy — using this page as its reference.
+[Miren agent skills](../agent-skills.md). It wires up the database addon and secrets and
+deploys, using this page as its reference.
:::
## Does this source build need a Dockerfile?
-Yes. Miren doesn't auto-detect the BEAM yet, so add a `Dockerfile.miren` to your
-project root. Miren builds from it instead of guessing the stack — see
+No. Miren detects Elixir from `mix.exs` and builds the release for you. Provide a
+`Dockerfile.miren` only for custom build steps. See
[Using Dockerfile.miren](./index.md#using-dockerfilemiren).
-:::tip[Want native support?]
-Miren auto-detects and builds common stacks (Python, Node, Bun, Go, Ruby, Rust)
-without a Dockerfile. This language isn't one of them yet — if you'd like first-class
-support, [request it](https://linear.miren.garden/suggest).
-:::
-
-## Generate a release
+## Set up the app
-If you're on Phoenix and haven't set up a release yet:
+From your project root:
-```bash
-mix phx.gen.release
+
+```miren
+miren init
+miren deploy
```
+
-This generates `rel/overlays/bin/server` and `rel/overlays/bin/migrate`, which get
-copied into the release root during `mix release`.
-
-:::info[Version requirements]
-Phoenix 1.8+ requires Elixir 1.15+ (1.18 recommended) and works well on Erlang/OTP 27.
-Match the Elixir image's OTP suffix to your Erlang version.
-:::
-
-## The Dockerfile
-
-Create `Dockerfile.miren` in your project root. Replace `my_app` with your OTP app name
-(the `:app` in `mix.exs`):
-
-```dockerfile
-ARG ELIXIR_VERSION=1.18.4
-ARG OTP_VERSION=27.3.4
-ARG DEBIAN_CODENAME=bookworm
-
-ARG BUILDER_IMAGE="hexpm/elixir:${ELIXIR_VERSION}-erlang-${OTP_VERSION}-debian-${DEBIAN_CODENAME}-20250929-slim"
-ARG RUNNER_IMAGE="debian:${DEBIAN_CODENAME}-slim"
-
-# ----- Build stage -----
-FROM ${BUILDER_IMAGE} AS builder
-
-RUN apt-get update -y && apt-get install -y build-essential git \
- && apt-get clean && rm -f /var/lib/apt/lists/*_*
-
-WORKDIR /app
-RUN mix local.hex --force && mix local.rebar --force
-
-ENV MIX_ENV="prod"
-
-COPY mix.exs mix.lock ./
-RUN mix deps.get --only $MIX_ENV
-RUN mkdir config
-COPY config/config.exs config/${MIX_ENV}.exs config/
-RUN mix deps.compile
-
-COPY priv priv
-COPY lib lib
-COPY assets assets
-COPY rel rel
-
-RUN mix compile
-RUN mix assets.deploy
+For a Phoenix app, `miren init` generates and stores `SECRET_KEY_BASE` for you. If
+the app uses DNSCluster, it also stores a shared `RELEASE_COOKIE` and configures
+`DNS_CLUSTER_QUERY=web.app.miren`. Set `PHX_HOST` to your public route hostname
+before deploying: Phoenix uses it for LiveView websocket origin checks. If the
+app requires `DATABASE_URL`, configure a database addon or supply the URL before
+deploying (see [Environment variables](#environment-variables)).
-COPY config/runtime.exs config/
-RUN mix release
+### Build process
-# ----- Runtime stage -----
-FROM ${RUNNER_IMAGE}
+Miren builds on the [hexpm/elixir](https://hub.docker.com/r/hexpm/elixir) image with
+`MIX_ENV=prod`:
-RUN apt-get update -y && \
- apt-get install -y libstdc++6 openssl libncurses5 locales ca-certificates \
- && apt-get clean && rm -f /var/lib/apt/lists/*_*
+1. `mix deps.get --only prod` and `mix deps.compile`, from `mix.exs`, `mix.lock` and
+ `config/` alone, so dependency builds are cached across code changes.
+2. `mix compile`.
+3. `mix assets.deploy`, if your `mix.exs` defines that alias (Phoenix apps do).
+4. `mix release`.
-RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen
-ENV LANG=en_US.UTF-8
-ENV LANGUAGE=en_US:en
-ENV LC_ALL=en_US.UTF-8
+The release bundles the Erlang runtime, so the final image is `debian:bookworm-slim`
+plus the release at `/app`. The Elixir toolchain and your source stay behind.
-WORKDIR /app
-RUN chown nobody /app
-ENV MIX_ENV="prod"
+### Versions
-COPY --from=builder --chown=nobody:root /app/_build/${MIX_ENV}/rel/my_app ./
+Miren picks the Elixir and Erlang/OTP versions in this order:
-USER nobody
+1. `[build] version` in `.miren/app.toml`
+2. The `elixir` (and `erlang`) entries in `.tool-versions` or `mise.toml`
+3. The default, **Elixir 1.19 on Erlang/OTP 28**
-ENV PHX_SERVER=true
-EXPOSE 4000
+Versions take the forms version managers use: `1.18`, `1.18.4`, or `1.18.4-otp-27`.
+An `erlang` entry picks the OTP when the Elixir version doesn't. Miren builds
+Elixir 1.16 through 1.20, each on its most recent patch release, so `1.18.4` builds on
+1.18.5. The build output says exactly which versions it used.
-CMD ["/app/bin/server"]
+```toml
+[build]
+version = "1.18-otp-26"
```
-:::warning[Two easy-to-miss build steps]
-`mix compile` must run **before** `mix assets.deploy` — Phoenix 1.8's colocated
-LiveView JavaScript is generated during compile and esbuild needs it. And you must
-`COPY rel rel` so the `bin/server` and `bin/migrate` overlay scripts exist in the
-release. Skipping either produces a build that fails at runtime.
-:::
-
-Pick a `hexpm/elixir` tag that actually exists — not every date is published. Browse
-[hub.docker.com/r/hexpm/elixir/tags](https://hub.docker.com/r/hexpm/elixir/tags).
-
-### .dockerignore
-
-Keep build artifacts out of the image context:
-
-```text
-.git
-_build
-deps
-*.ez
-erl_crash.dump
-priv/static/assets
-priv/static/cache_manifest.json
-node_modules
-.elixir_ls
-.env
-mise.toml
-```
+For an exact pairing outside that set, set `version` to a full
+[hexpm/elixir tag](https://hub.docker.com/r/hexpm/elixir/tags) on Debian bookworm,
+such as `1.18.4-erlang-27.3.4.18-debian-bookworm-20260918-slim`.
-## Deploy
+### Release name
-The Dockerfile's `CMD` starts the app. Miren uses it as the web service's startup default,
-so you don't need a `Procfile` or service command.
+Miren builds the release named in a `releases:` block in `mix.exs`, or, without one,
+the default release named after your OTP app (the `app:` in `project/0`). Umbrella
+projects need a `releases:` block.
-Create `.miren/app.toml` naming your app and declaring the database addon
-(covered in the next section):
+In an umbrella Phoenix app, Miren doesn't build the web app's assets yet, since they
+live under `apps/`. Build them with an `onbuild` command, which runs before the
+release is assembled:
```toml
-name = "my_app"
-
-[addons.miren-postgresql]
-variant = "small"
+[build]
+onbuild = ["cd apps/my_app_web && mix assets.deploy"]
```
-Phoenix's generated `config/runtime.exs` already reads `PORT` (defaulting to 4000) and
-binds the endpoint to `0.0.0.0`, so it works with Miren's injected `PORT` without changes.
+### Start command
-Before you deploy, wire up the database and secrets the release needs at boot — see the
-next section — then deploy from your project root:
-
-
-```miren
-miren deploy
-```
-
+The web service runs `/app/bin/ start`. For DNSCluster apps, Miren
+uses the node name `@` by default; explicit
+`RELEASE_NODE` and `RELEASE_DISTRIBUTION` values take precedence. For Phoenix, Miren sets
+`PHX_SERVER=true` so the endpoint starts, and the `runtime.exs` from `mix phx.new`
+already binds `$PORT` on all interfaces. Other apps should read `PORT` from the
+environment and bind `0.0.0.0`.
## Environment variables
A production Phoenix app needs a database and a few secrets, and they must exist
-**before the app boots** — `config/runtime.exs` raises on a missing `DATABASE_URL` or
-`SECRET_KEY_BASE`. Set them before your first `miren deploy`.
+**before the app boots**: `config/runtime.exs` raises on a missing `DATABASE_URL` or
+`SECRET_KEY_BASE`.
:::warning[Set secrets before the app serves traffic]
A web app autoscales to zero, so a deploy can report success without ever starting an
-instance — the missing-secret error only surfaces when the first request tries to boot
+instance. The missing-secret error only surfaces when the first request tries to boot
one, and the instance crashes. Configure the addon and secrets first.
:::
### Database via an addon
-The simplest way to get `DATABASE_URL` is a managed Postgres [addon](../addons.md) — Miren
-provisions it and injects the connection string (plus `PG*` variables) as environment
-variables automatically. Declare it in `.miren/app.toml` (as shown in
-[Deploy](#deploy)):
+The simplest way to get `DATABASE_URL` is a managed Postgres [addon](../addons.md).
+Miren provisions it and injects the connection string (plus `PG*` variables)
+automatically. Declare it in `.miren/app.toml`:
```toml
[addons.miren-postgresql]
@@ -196,35 +128,36 @@ variant = "small"
### Secrets and settings
-Set the remaining variables with `miren env set` — `-s` masks secrets in output and logs:
+`miren init` stores a generated `SECRET_KEY_BASE` and, for DNSCluster apps, a
+shared `RELEASE_COOKIE`. Set your public hostname with `miren env set`,
+where `-s` masks secrets in output and logs:
```miren
-miren env set -s SECRET_KEY_BASE
miren env set -e PHX_HOST=my_app.example.com
```
-Generate `SECRET_KEY_BASE` with `mix phx.gen.secret` (or `openssl rand -base64 64`) and
-paste it at the masked prompt. You can also set these at deploy time with
-`miren deploy -s SECRET_KEY_BASE=... -e PHX_HOST=...`.
-
| Variable | Required | Notes |
|----------|----------|-------|
-| `DATABASE_URL` | Yes | Injected by the `miren-postgresql` addon; or set manually (`ecto://USER:PASS@HOST/DATABASE`) |
-| `SECRET_KEY_BASE` | Yes | Generate with `mix phx.gen.secret` |
-| `PHX_HOST` | Yes | Public hostname used for URL generation |
-| `PHX_SERVER` | Set in Dockerfile | Enables the HTTP server in the release |
-| `PORT` | No | Injected by Miren; `runtime.exs` defaults to 4000 |
+| `DATABASE_URL` | Yes | Injected by the `miren-postgresql` addon |
+| `SECRET_KEY_BASE` | Yes | Generated by `miren init`, or `mix phx.gen.secret` |
+| `PHX_HOST` | For LiveView | Set to your public route hostname before deploy. Phoenix uses it for URLs and the websocket origin check |
+| `PHX_SERVER` | Set by Miren | Starts the endpoint in the release |
+| `PORT` | No | Injected by Miren |
| `POOL_SIZE` | No | DB pool size, defaults to 10 |
-| `DNS_CLUSTER_QUERY` | No | Enables Erlang clustering via DNS discovery |
+| `DNS_CLUSTER_QUERY` | For DNSCluster apps | Defaults to `web.app.miren` on `miren init`; the app must also share a `RELEASE_COOKIE` |
-See [App Configuration — Environment Variables](../app-configuration.md#environment-variables).
+Miren also scans your code and config for `System.fetch_env!/1` and
+`System.get_env/1` and reports what it finds. A `fetch_env!` or a `get_env(...) ||
+raise` counts as required.
+
+See [App Configuration: Environment Variables](../app-configuration.md#environment-variables).
## Migrations
-The release includes a `migrate` script at `/app/bin/migrate` (from
-`mix phx.gen.release`). Run it against your deployed app to apply migrations:
+Run migrations with a one-off command against the release. If you generated release
+helpers with `mix phx.gen.release`, use its `migrate` script:
```miren
@@ -232,28 +165,35 @@ miren app run -a my_app -- /app/bin/migrate
```
+Otherwise, call your release module directly, for example
+`/app/bin/my_app eval "MyApp.Release.migrate()"`. Running migrations at boot from your
+application's supervision tree (with `Ecto.Migrator`) also works well for small apps.
+
## Clustering
-A plain Phoenix app with controller routes is stateless — run multiple replicas behind
-Miren's load balancer with no extra setup. Erlang clustering (for cross-node LiveView
-or channels) is off unless you set `DNS_CLUSTER_QUERY` to a headless DNS name that
-resolves all instance IPs, which activates the scaffolded `DNSCluster`.
+A Phoenix app is stateless by default, so you can run multiple replicas behind Miren's
+load balancer. When the app depends on DNSCluster and reads `DNS_CLUSTER_QUERY`,
+`miren init` configures discovery at `web.app.miren` and stores a shared, sensitive
+`RELEASE_COOKIE` on the app. Miren names nodes `@` so
+DNSCluster can reach them. For apps initialized before this support, set both
+`DNS_CLUSTER_QUERY=web.app.miren` and the same `RELEASE_COOKIE` on all replicas.
+Custom node naming or discovery must agree on the node name and cookie.
## Agent quick reference
-- **Detection:** none — requires `Dockerfile.miren` (multi-stage Mix release)
-- **Release:** `mix phx.gen.release`; `mix compile` **before** `mix assets.deploy`; `COPY rel rel`
-- **Runtime image:** `debian-slim` is fine — `mix release` bundles ERTS, so the runner needs no Erlang install
-- **Runtime env:** `PHX_SERVER=true` in the Dockerfile; endpoint binds `0.0.0.0:$PORT` via `runtime.exs`
-- **Startup:** inherited from the Dockerfile `CMD`; no `Procfile` or service command needed
-- **Secrets first:** set `SECRET_KEY_BASE` + `PHX_HOST` before the app serves traffic, or the instance crashloops
-- **Database:** `[addons.miren-postgresql]` injects `DATABASE_URL` (and `PG*`) automatically
-- **Migrations:** `miren app run -a -- /app/bin/migrate`
-- **Image tags:** pick an existing `hexpm/elixir` tag; match the OTP suffix to your Erlang version
+- **Detection:** `mix.exs` in the project
+- **Version:** `[build] version`, else `.tool-versions` / `mise.toml`, else Elixir 1.19 / OTP 28; forms like `1.18`, `1.18.4-otp-27`, or a full bookworm hexpm tag
+- **Build:** `deps.get`, `deps.compile`, `compile`, `assets.deploy` (if aliased), `release`, all with `MIX_ENV=prod`
+- **Release:** the `releases:` entry in `mix.exs`, else the OTP app name; umbrellas need `releases:`
+- **Umbrella Phoenix:** web app assets aren't built automatically; add `cd apps/_web && mix assets.deploy` to `[build] onbuild`
+- **Start command:** `/app/bin/ start` (IP-named for DNSCluster apps); `PHX_SERVER=true` is set for Phoenix
+- **Secrets:** `SECRET_KEY_BASE` and (for DNSCluster apps) `RELEASE_COOKIE` generated by `miren init`; set `PHX_HOST` to the route host for LiveView
+- **Database:** `[addons.miren-postgresql]` injects `DATABASE_URL` (and `PG*`)
+- **Migrations:** `miren app run -a -- /app/bin/migrate` (from `mix phx.gen.release`)
+- **Dockerfile:** not needed; add `Dockerfile.miren` only for custom builds
## Next steps
-- [Using Dockerfile.miren](./index.md#using-dockerfilemiren) — how custom builds work
-- [Addons](../addons.md) — managed Postgres and other backing services
-- [App Configuration](../app-configuration.md) — customize `.miren/app.toml`
-- [Deployment](../deployment.md) — how deploys build and activate
+- [Addons](../addons.md): managed Postgres and other backing services
+- [App Configuration](../app-configuration.md): customize `.miren/app.toml`
+- [Deployment](../deployment.md): how deploys build and activate
diff --git a/docs/docs/guides/erlang.md b/docs/docs/guides/erlang.md
index 28378f708..fd9ba959d 100644
--- a/docs/docs/guides/erlang.md
+++ b/docs/docs/guides/erlang.md
@@ -26,7 +26,7 @@ Miren builds from it instead of guessing the stack — see
[Using Dockerfile.miren](./index.md#using-dockerfilemiren).
:::tip[Want native support?]
-Miren auto-detects and builds common stacks (Python, Node, Bun, Go, Ruby, Rust)
+Miren auto-detects and builds [common stacks](./index.md#auto-detected-vs-dockerfile)
without a Dockerfile. This language isn't one of them yet — if you'd like first-class
support, [request it](https://linear.miren.garden/suggest).
:::
diff --git a/docs/docs/guides/fsharp.md b/docs/docs/guides/fsharp.md
index 63140c1d6..a7e58494a 100644
--- a/docs/docs/guides/fsharp.md
+++ b/docs/docs/guides/fsharp.md
@@ -25,7 +25,7 @@ Miren builds from it instead of guessing the stack — see
[Using Dockerfile.miren](./index.md#using-dockerfilemiren).
:::tip[Want native support?]
-Miren auto-detects and builds common stacks (Python, Node, Bun, Go, Ruby, Rust)
+Miren auto-detects and builds [common stacks](./index.md#auto-detected-vs-dockerfile)
without a Dockerfile. This language isn't one of them yet — if you'd like first-class
support, [request it](https://linear.miren.garden/suggest).
:::
diff --git a/docs/docs/guides/gleam.md b/docs/docs/guides/gleam.md
index 8e61ba39e..a0239d75b 100644
--- a/docs/docs/guides/gleam.md
+++ b/docs/docs/guides/gleam.md
@@ -21,8 +21,9 @@ page as its reference.
## Does this source build need a Dockerfile?
-Yes. Miren doesn't auto-detect the BEAM yet, so add a `Dockerfile.miren` to your
-project root. Miren builds from it instead of guessing the stack — see
+Yes. Miren auto-detects Elixir's `mix.exs` but not Gleam projects yet, so add a
+`Dockerfile.miren` to your project root. Miren builds from it instead of guessing the
+stack — see
[Using Dockerfile.miren](./index.md#using-dockerfilemiren).
:::info[Validated pattern — adapt the versions]
@@ -35,7 +36,7 @@ the warning below the Dockerfile).
:::
:::tip[Want native support?]
-Miren auto-detects and builds common stacks (Python, Node, Bun, Go, Ruby, Rust)
+Miren auto-detects and builds [common stacks](./index.md#auto-detected-vs-dockerfile)
without a Dockerfile. This language isn't one of them yet — if you'd like first-class
support, [request it](https://linear.miren.garden/suggest).
:::
diff --git a/docs/docs/guides/haskell.md b/docs/docs/guides/haskell.md
index 71f549476..ffcb9c952 100644
--- a/docs/docs/guides/haskell.md
+++ b/docs/docs/guides/haskell.md
@@ -26,7 +26,7 @@ Miren builds from it instead of guessing the stack — see
[Using Dockerfile.miren](./index.md#using-dockerfilemiren).
:::tip[Want native support?]
-Miren auto-detects and builds common stacks (Python, Node, Bun, Go, Ruby, Rust)
+Miren auto-detects and builds [common stacks](./index.md#auto-detected-vs-dockerfile)
without a Dockerfile. This language isn't one of them yet — if you'd like first-class
support, [request it](https://linear.miren.garden/suggest).
:::
diff --git a/docs/docs/guides/index.md b/docs/docs/guides/index.md
index 5adf2285a..840f5c329 100644
--- a/docs/docs/guides/index.md
+++ b/docs/docs/guides/index.md
@@ -27,13 +27,13 @@ agent works from. See [Agent Skills](../agent-skills.md) for setup.
| [Go](./go.md) | Yes | `go.mod` |
| [Ruby](./ruby.md) | Yes | `Gemfile` |
| [Rust](./rust.md) | Yes | `Cargo.toml` |
+| [Elixir](./elixir.md) | Yes | `mix.exs` |
| [Java / JVM](./java.md) | No | `Dockerfile.miren` |
| [PHP](./php.md) | No | `Dockerfile.miren` |
| [.NET / C#](./dotnet.md) | No | `Dockerfile.miren` |
| [C++](./cpp.md) | No | `Dockerfile.miren` |
| [C](./c.md) | No | `Dockerfile.miren` |
| [Deno](./deno.md) | No | `Dockerfile.miren` |
-| [Elixir](./elixir.md) | No | `Dockerfile.miren` |
| [Kotlin](./kotlin.md) | No | `Dockerfile.miren` |
| [Swift](./swift.md) | No | `Dockerfile.miren` |
| [Dart](./dart.md) | No | `Dockerfile.miren` |
@@ -63,7 +63,7 @@ agent works from. See [Agent Skills](../agent-skills.md) for setup.
## Auto-detected vs. Dockerfile
-Six stacks are auto-detected. Miren reads your project files, picks the build stack, and
+Seven stacks are auto-detected. Miren reads your project files, picks the build stack, and
builds a container image for you — **no Dockerfile required**. You run `miren init` once
and `miren deploy`, and Miren figures out the rest.
@@ -75,20 +75,21 @@ and `miren deploy`, and Miren figures out the rest.
| [Bun](./javascript.md) | `package.json` + `bun.lock` | 1 |
| [Go](./go.md) | `go.mod` | Parsed from `go.mod`, else 1.23 |
| [Rust](./rust.md) | `Cargo.toml` | 1.83 |
+| [Elixir](./elixir.md) | `mix.exs` | From `.tool-versions` or `mise.toml`, else 1.19 on OTP 28 |
Each guide covers that stack's detection rules, build process, and start command in
full. Override any default version with `[build] version` in
[`.miren/app.toml`](../app-toml.md#build).
-Every other language here — from Elixir and Gleam to Kotlin, Swift, Julia, and even
+Every other language here — from Gleam and Erlang to Kotlin, Swift, Julia, and even
COBOL — isn't auto-detected, so its guide shows you a `Dockerfile.miren` you
can drop into your project. Miren builds from that Dockerfile instead of guessing. This
is the same escape hatch available to every language when you need full control over the
build.
:::tip[Want native support?]
-Native builds cover the common stacks today (Python, Node, Bun, Go, Ruby, Rust). If you'd
-like Miren to detect and build another language first-class — no Dockerfile needed —
+Native builds cover the stacks in the table above. If you'd like Miren to detect and
+build another language first-class — no Dockerfile needed —
[tell us what to build next](https://linear.miren.garden/suggest).
:::
diff --git a/docs/docs/guides/java.md b/docs/docs/guides/java.md
index 0f96521dc..3ce91f27f 100644
--- a/docs/docs/guides/java.md
+++ b/docs/docs/guides/java.md
@@ -27,7 +27,7 @@ Miren builds from it instead of guessing the stack — see
[Using Dockerfile.miren](./index.md#using-dockerfilemiren).
:::tip[Want native support?]
-Miren auto-detects and builds common stacks (Python, Node, Bun, Go, Ruby, Rust)
+Miren auto-detects and builds [common stacks](./index.md#auto-detected-vs-dockerfile)
without a Dockerfile. This language isn't one of them yet — if you'd like first-class
support, [request it](https://linear.miren.garden/suggest).
:::
diff --git a/docs/docs/guides/jruby.md b/docs/docs/guides/jruby.md
index 7263a8ad2..e9446aab4 100644
--- a/docs/docs/guides/jruby.md
+++ b/docs/docs/guides/jruby.md
@@ -25,7 +25,7 @@ Yes — to run on JRuby specifically. (Miren's auto-detection would pick MRI Rub
[Using Dockerfile.miren](./index.md#using-dockerfilemiren).
:::tip[Want native support?]
-Miren auto-detects and builds common stacks (Python, Node, Bun, Go, Ruby, Rust)
+Miren auto-detects and builds [common stacks](./index.md#auto-detected-vs-dockerfile)
without a Dockerfile. This language isn't one of them yet — if you'd like first-class
support, [request it](https://linear.miren.garden/suggest).
:::
diff --git a/docs/docs/guides/julia.md b/docs/docs/guides/julia.md
index f77808b63..7467a34cd 100644
--- a/docs/docs/guides/julia.md
+++ b/docs/docs/guides/julia.md
@@ -25,7 +25,7 @@ Miren builds from it instead of guessing the stack — see
[Using Dockerfile.miren](./index.md#using-dockerfilemiren).
:::tip[Want native support?]
-Miren auto-detects and builds common stacks (Python, Node, Bun, Go, Ruby, Rust)
+Miren auto-detects and builds [common stacks](./index.md#auto-detected-vs-dockerfile)
without a Dockerfile. This language isn't one of them yet — if you'd like first-class
support, [request it](https://linear.miren.garden/suggest).
:::
diff --git a/docs/docs/guides/klong.md b/docs/docs/guides/klong.md
index a7ce72cbc..f89f5c1f5 100644
--- a/docs/docs/guides/klong.md
+++ b/docs/docs/guides/klong.md
@@ -26,7 +26,7 @@ Yes. Add a `Dockerfile.miren` to your project root. Miren builds from it instead
guessing the stack — see [Using Dockerfile.miren](./index.md#using-dockerfilemiren).
:::tip[Want native support?]
-Miren auto-detects and builds common stacks (Python, Node, Bun, Go, Ruby, Rust)
+Miren auto-detects and builds [common stacks](./index.md#auto-detected-vs-dockerfile)
without a Dockerfile. This language isn't one of them yet — if you'd like first-class
support, [request it](https://linear.miren.garden/suggest).
:::
diff --git a/docs/docs/guides/kotlin.md b/docs/docs/guides/kotlin.md
index f271e785e..8932cbd39 100644
--- a/docs/docs/guides/kotlin.md
+++ b/docs/docs/guides/kotlin.md
@@ -25,7 +25,7 @@ Miren builds from it instead of guessing the stack — see
[Using Dockerfile.miren](./index.md#using-dockerfilemiren).
:::tip[Want native support?]
-Miren auto-detects and builds common stacks (Python, Node, Bun, Go, Ruby, Rust)
+Miren auto-detects and builds [common stacks](./index.md#auto-detected-vs-dockerfile)
without a Dockerfile. This language isn't one of them yet — if you'd like first-class
support, [request it](https://linear.miren.garden/suggest).
:::
diff --git a/docs/docs/guides/lua.md b/docs/docs/guides/lua.md
index 6aad30263..f2f83f1eb 100644
--- a/docs/docs/guides/lua.md
+++ b/docs/docs/guides/lua.md
@@ -25,7 +25,7 @@ Miren builds from it instead of guessing the stack — see
[Using Dockerfile.miren](./index.md#using-dockerfilemiren).
:::tip[Want native support?]
-Miren auto-detects and builds common stacks (Python, Node, Bun, Go, Ruby, Rust)
+Miren auto-detects and builds [common stacks](./index.md#auto-detected-vs-dockerfile)
without a Dockerfile. This language isn't one of them yet — if you'd like first-class
support, [request it](https://linear.miren.garden/suggest).
:::
diff --git a/docs/docs/guides/nim.md b/docs/docs/guides/nim.md
index 5ef618ee0..558cdf15b 100644
--- a/docs/docs/guides/nim.md
+++ b/docs/docs/guides/nim.md
@@ -25,7 +25,7 @@ Miren builds from it instead of guessing the stack — see
[Using Dockerfile.miren](./index.md#using-dockerfilemiren).
:::tip[Want native support?]
-Miren auto-detects and builds common stacks (Python, Node, Bun, Go, Ruby, Rust)
+Miren auto-detects and builds [common stacks](./index.md#auto-detected-vs-dockerfile)
without a Dockerfile. This language isn't one of them yet — if you'd like first-class
support, [request it](https://linear.miren.garden/suggest).
:::
diff --git a/docs/docs/guides/objc.md b/docs/docs/guides/objc.md
index 183fb9d34..d389a8b2d 100644
--- a/docs/docs/guides/objc.md
+++ b/docs/docs/guides/objc.md
@@ -26,7 +26,7 @@ Yes. Add a `Dockerfile.miren` to your project root. Miren builds from it instead
guessing the stack — see [Using Dockerfile.miren](./index.md#using-dockerfilemiren).
:::tip[Want native support?]
-Miren auto-detects and builds common stacks (Python, Node, Bun, Go, Ruby, Rust)
+Miren auto-detects and builds [common stacks](./index.md#auto-detected-vs-dockerfile)
without a Dockerfile. This language isn't one of them yet — if you'd like first-class
support, [request it](https://linear.miren.garden/suggest).
:::
diff --git a/docs/docs/guides/ocaml.md b/docs/docs/guides/ocaml.md
index 800d9dc9e..cae86348b 100644
--- a/docs/docs/guides/ocaml.md
+++ b/docs/docs/guides/ocaml.md
@@ -26,7 +26,7 @@ Miren builds from it instead of guessing the stack — see
[Using Dockerfile.miren](./index.md#using-dockerfilemiren).
:::tip[Want native support?]
-Miren auto-detects and builds common stacks (Python, Node, Bun, Go, Ruby, Rust)
+Miren auto-detects and builds [common stacks](./index.md#auto-detected-vs-dockerfile)
without a Dockerfile. This language isn't one of them yet — if you'd like first-class
support, [request it](https://linear.miren.garden/suggest).
:::
diff --git a/docs/docs/guides/perl.md b/docs/docs/guides/perl.md
index e2ed25b0d..e97dbb721 100644
--- a/docs/docs/guides/perl.md
+++ b/docs/docs/guides/perl.md
@@ -26,7 +26,7 @@ Miren builds from it instead of guessing the stack — see
[Using Dockerfile.miren](./index.md#using-dockerfilemiren).
:::tip[Want native support?]
-Miren auto-detects and builds common stacks (Python, Node, Bun, Go, Ruby, Rust)
+Miren auto-detects and builds [common stacks](./index.md#auto-detected-vs-dockerfile)
without a Dockerfile. This language isn't one of them yet — if you'd like first-class
support, [request it](https://linear.miren.garden/suggest).
:::
diff --git a/docs/docs/guides/php.md b/docs/docs/guides/php.md
index 5fc3a21d9..2a017e186 100644
--- a/docs/docs/guides/php.md
+++ b/docs/docs/guides/php.md
@@ -27,7 +27,7 @@ Miren builds from it instead of guessing the stack — see
[Using Dockerfile.miren](./index.md#using-dockerfilemiren).
:::tip[Want native support?]
-Miren auto-detects and builds common stacks (Python, Node, Bun, Go, Ruby, Rust)
+Miren auto-detects and builds [common stacks](./index.md#auto-detected-vs-dockerfile)
without a Dockerfile. This language isn't one of them yet — if you'd like first-class
support, [request it](https://linear.miren.garden/suggest).
:::
diff --git a/docs/docs/guides/r.md b/docs/docs/guides/r.md
index 67dbd8455..9c215e5e1 100644
--- a/docs/docs/guides/r.md
+++ b/docs/docs/guides/r.md
@@ -25,7 +25,7 @@ Miren builds from it instead of guessing the stack — see
[Using Dockerfile.miren](./index.md#using-dockerfilemiren).
:::tip[Want native support?]
-Miren auto-detects and builds common stacks (Python, Node, Bun, Go, Ruby, Rust)
+Miren auto-detects and builds [common stacks](./index.md#auto-detected-vs-dockerfile)
without a Dockerfile. This language isn't one of them yet — if you'd like first-class
support, [request it](https://linear.miren.garden/suggest).
:::
diff --git a/docs/docs/guides/raku.md b/docs/docs/guides/raku.md
index a3c48ab0a..ea2d4fe4f 100644
--- a/docs/docs/guides/raku.md
+++ b/docs/docs/guides/raku.md
@@ -24,7 +24,7 @@ Miren builds from it instead of guessing the stack — see
[Using Dockerfile.miren](./index.md#using-dockerfilemiren).
:::tip[Want native support?]
-Miren auto-detects and builds common stacks (Python, Node, Bun, Go, Ruby, Rust)
+Miren auto-detects and builds [common stacks](./index.md#auto-detected-vs-dockerfile)
without a Dockerfile. This language isn't one of them yet — if you'd like first-class
support, [request it](https://linear.miren.garden/suggest).
:::
diff --git a/docs/docs/guides/scala.md b/docs/docs/guides/scala.md
index aa6e8dca3..03680754a 100644
--- a/docs/docs/guides/scala.md
+++ b/docs/docs/guides/scala.md
@@ -26,7 +26,7 @@ Miren builds from it instead of guessing the stack — see
[Using Dockerfile.miren](./index.md#using-dockerfilemiren).
:::tip[Want native support?]
-Miren auto-detects and builds common stacks (Python, Node, Bun, Go, Ruby, Rust)
+Miren auto-detects and builds [common stacks](./index.md#auto-detected-vs-dockerfile)
without a Dockerfile. This language isn't one of them yet — if you'd like first-class
support, [request it](https://linear.miren.garden/suggest).
:::
diff --git a/docs/docs/guides/static.md b/docs/docs/guides/static.md
index 5c7f1d360..aad2070c5 100644
--- a/docs/docs/guides/static.md
+++ b/docs/docs/guides/static.md
@@ -36,7 +36,7 @@ Miren builds it before exporting `static.dir` — see
[Using Dockerfile.miren](./index.md#using-dockerfilemiren).
:::tip[Want native support?]
-Miren auto-detects and builds common stacks (Python, Node, Bun, Go, Ruby, Rust)
+Miren auto-detects and builds [common stacks](./index.md#auto-detected-vs-dockerfile)
without a Dockerfile. This language isn't one of them yet — if you'd like first-class
support, [request it](https://linear.miren.garden/suggest).
:::
diff --git a/docs/docs/guides/swift.md b/docs/docs/guides/swift.md
index 33ca85a56..397d65d4a 100644
--- a/docs/docs/guides/swift.md
+++ b/docs/docs/guides/swift.md
@@ -25,7 +25,7 @@ Miren builds from it instead of guessing the stack — see
[Using Dockerfile.miren](./index.md#using-dockerfilemiren).
:::tip[Want native support?]
-Miren auto-detects and builds common stacks (Python, Node, Bun, Go, Ruby, Rust)
+Miren auto-detects and builds [common stacks](./index.md#auto-detected-vs-dockerfile)
without a Dockerfile. This language isn't one of them yet — if you'd like first-class
support, [request it](https://linear.miren.garden/suggest).
:::
diff --git a/docs/docs/guides/truffleruby.md b/docs/docs/guides/truffleruby.md
index 184c846b6..9dcca67a3 100644
--- a/docs/docs/guides/truffleruby.md
+++ b/docs/docs/guides/truffleruby.md
@@ -26,7 +26,7 @@ Add a `Dockerfile.miren` built on the GraalVM TruffleRuby image. See
[Using Dockerfile.miren](./index.md#using-dockerfilemiren).
:::tip[Want native support?]
-Miren auto-detects and builds common stacks (Python, Node, Bun, Go, Ruby, Rust)
+Miren auto-detects and builds [common stacks](./index.md#auto-detected-vs-dockerfile)
without a Dockerfile. This language isn't one of them yet — if you'd like first-class
support, [request it](https://linear.miren.garden/suggest).
:::
diff --git a/docs/docs/guides/zig.md b/docs/docs/guides/zig.md
index fc2918f2b..f9cc2902f 100644
--- a/docs/docs/guides/zig.md
+++ b/docs/docs/guides/zig.md
@@ -30,7 +30,7 @@ code.
:::
:::tip[Want native support?]
-Miren auto-detects and builds common stacks (Python, Node, Bun, Go, Ruby, Rust)
+Miren auto-detects and builds [common stacks](./index.md#auto-detected-vs-dockerfile)
without a Dockerfile. This language isn't one of them yet — if you'd like first-class
support, [request it](https://linear.miren.garden/suggest).
:::
diff --git a/docs/docs/intro.md b/docs/docs/intro.md
index 9464d15d3..182d18225 100644
--- a/docs/docs/intro.md
+++ b/docs/docs/intro.md
@@ -17,7 +17,7 @@ Miren is a container platform for small teams. You install it on a Linux server,
Miren has two sides. The **server** runs on your Linux machine and manages containers, networking, and storage. The **client** is the `miren` CLI on your laptop (or CI runner), which talks to the server over a secure connection.
-You deploy apps by running `miren deploy` from your project directory. Miren can run a configured container image directly, or detect your language (Python, Node, Bun, Go, Ruby, Rust, or a Dockerfile) and build one from your source. Your first app gets a route automatically. Scaling is automatic by default, adjusting instance counts based on traffic.
+You deploy apps by running `miren deploy` from your project directory. Miren can run a configured container image directly, or detect your language (Python, Node, Bun, Go, Ruby, Rust, Elixir, or a Dockerfile) and build one from your source. Your first app gets a route automatically. Scaling is automatic by default, adjusting instance counts based on traffic.
If your server is on a home network or behind a firewall, [Miren Anywhere](./miren-cloud/miren-anywhere.md) still puts your apps on the internet, routing their traffic through Miren Cloud so you don't need port forwarding or a public IP of your own.
diff --git a/docs/docs/services.md b/docs/docs/services.md
index 45eb64f40..aa061c874 100644
--- a/docs/docs/services.md
+++ b/docs/docs/services.md
@@ -49,7 +49,7 @@ Miren detects services in this order:
1. **`.miren/app.toml`** — Services defined in the `[services.*]` sections
2. **`Procfile`** — Services inferred from Procfile entries
-3. **Detected start command** — For an auto-detected language stack (Python, Node, Bun, Go, Ruby, Rust), Miren synthesizes a `web` service from the start command it detects for your framework
+3. **Detected start command** — For an auto-detected language stack (Python, Node, Bun, Go, Ruby, Rust, Elixir), Miren synthesizes a `web` service from the start command it detects for your framework
If none of these provide a service definition, Miren usually synthesizes a `web` service
for a runnable container image. That service uses the image's `ENTRYPOINT` and `CMD`
diff --git a/pkg/imagerefs/imagerefs.go b/pkg/imagerefs/imagerefs.go
index 0eabb3097..8e298d226 100644
--- a/pkg/imagerefs/imagerefs.go
+++ b/pkg/imagerefs/imagerefs.go
@@ -90,6 +90,63 @@ func GetNodeImage(version string) string {
return "oci.miren.cloud/node:" + version + "-slim"
}
+// hexpm tags pair an Elixir release with a specific OTP release and a dated
+// Debian rebuild (1.19.6-erlang-28.5.0.7-debian-bookworm-20260918-slim), so
+// there is no floating "1.19" tag to point at. ElixirReleases maps the
+// versions people actually write onto tags known to exist in one rebuild, and
+// is bumped deliberately, all at once. It must stay on bookworm so the
+// release's ERTS links against the same glibc and OpenSSL as DebianSlim, where
+// the release runs.
+const elixirBuild = "debian-bookworm-20260918-slim"
+
+// ElixirRelease is the build we use for one Elixir minor version.
+type ElixirRelease struct {
+ // Patch is the full Elixir version built for this minor.
+ Patch string
+ // OTP maps each supported OTP major to the full OTP version built.
+ OTP map[string]string
+ // DefaultOTP is the OTP major used when none is asked for.
+ DefaultOTP string
+}
+
+// ElixirReleases is keyed by Elixir minor version ("1.19").
+var ElixirReleases = map[string]ElixirRelease{
+ "1.16": {Patch: "1.16.3", DefaultOTP: "26", OTP: map[string]string{"24": "24.3.4.17", "25": "25.3.2.21", "26": "26.2.5.21"}},
+ "1.17": {Patch: "1.17.3", DefaultOTP: "27", OTP: map[string]string{"25": "25.3.2.21", "26": "26.2.5.21", "27": "27.3.4.18"}},
+ "1.18": {Patch: "1.18.5", DefaultOTP: "27", OTP: map[string]string{"25": "25.3.2.21", "26": "26.2.5.21", "27": "27.3.4.18"}},
+ "1.19": {Patch: "1.19.6", DefaultOTP: "28", OTP: map[string]string{"26": "26.2.5.21", "27": "27.3.4.18", "28": "28.5.0.7"}},
+ "1.20": {Patch: "1.20.4", DefaultOTP: "28", OTP: map[string]string{"27": "27.3.4.18", "28": "28.5.0.7", "29": "29.1.1"}},
+}
+
+// ElixirDefaultVersion is the Elixir minor used when an app doesn't pick one.
+const ElixirDefaultVersion = "1.19"
+
+// ElixirDefaultTag is the full hexpm tag for ElixirDefaultVersion on its
+// default OTP.
+var ElixirDefaultTag = ElixirTag(ElixirDefaultVersion, ElixirReleases[ElixirDefaultVersion].DefaultOTP)
+
+// ElixirTag returns the hexpm tag for an Elixir minor and OTP major from
+// ElixirReleases, or "" when that pairing isn't in the table.
+func ElixirTag(minor, otpMajor string) string {
+ rel, ok := ElixirReleases[minor]
+ if !ok {
+ return ""
+ }
+ otp, ok := rel.OTP[otpMajor]
+ if !ok {
+ return ""
+ }
+ return rel.Patch + "-erlang-" + otp + "-" + elixirBuild
+}
+
+// GetElixirImage returns a hexpm/elixir builder image reference for the given
+// full hexpm tag (e.g. ElixirDefaultTag). It keeps the hexpm/ namespace rather
+// than a bare "elixir", which on Docker Hub is a different image with
+// different tags.
+func GetElixirImage(tag string) string {
+ return "oci.miren.cloud/hexpm/elixir:" + tag
+}
+
// GetRustImage returns a Rust image reference with the specified version
func GetRustImage(version string) string {
return "oci.miren.cloud/rust:" + version
diff --git a/pkg/imagerefs/imagerefs_test.go b/pkg/imagerefs/imagerefs_test.go
index be736a77f..35c999ed0 100644
--- a/pkg/imagerefs/imagerefs_test.go
+++ b/pkg/imagerefs/imagerefs_test.go
@@ -34,3 +34,31 @@ func TestGetGolangImage(t *testing.T) {
}
}
}
+
+func TestGetElixirImage(t *testing.T) {
+ // hexpm has no floating version tags, so the full tag passes through.
+ want := "oci.miren.cloud/hexpm/elixir:" + ElixirDefaultTag
+ if got := GetElixirImage(ElixirDefaultTag); got != want {
+ t.Errorf("GetElixirImage(%q) = %q, want %q", ElixirDefaultTag, got, want)
+ }
+}
+
+func TestElixirTag(t *testing.T) {
+ if ElixirDefaultTag != "1.19.6-erlang-28.5.0.7-debian-bookworm-20260918-slim" {
+ t.Errorf("ElixirDefaultTag = %q", ElixirDefaultTag)
+ }
+ if got := ElixirTag("1.20", "29"); got != "1.20.4-erlang-29.1.1-debian-bookworm-20260918-slim" {
+ t.Errorf("ElixirTag(1.20, 29) = %q", got)
+ }
+ if got := ElixirTag("1.20", "24"); got != "" {
+ t.Errorf("unsupported OTP should be empty, got %q", got)
+ }
+ if got := ElixirTag("1.9", "22"); got != "" {
+ t.Errorf("unknown minor should be empty, got %q", got)
+ }
+ for minor, rel := range ElixirReleases {
+ if _, ok := rel.OTP[rel.DefaultOTP]; !ok {
+ t.Errorf("Elixir %s default OTP %s isn't in its OTP map", minor, rel.DefaultOTP)
+ }
+ }
+}
diff --git a/pkg/stackbuild/augment.go b/pkg/stackbuild/augment.go
index cd36110f7..4c8b2f8a6 100644
--- a/pkg/stackbuild/augment.go
+++ b/pkg/stackbuild/augment.go
@@ -117,8 +117,8 @@ func (h *highlevelBuilder) installNpm(cur llb.State, distro string) llb.State {
default:
return cur.Run(
llb.Shlex("sh -c 'apt-get update && apt-get install -y nodejs npm'"),
- h.CacheMount("/var/lib/apt/lists"),
- h.CacheMount("/var/cache/apt/archives"),
+ h.lockedCacheMount("/var/lib/apt/lists"),
+ h.lockedCacheMount("/var/cache/apt/archives"),
llb.WithCustomName("[phase] Installing npm augmentation"),
).State
}
diff --git a/pkg/stackbuild/elixir.go b/pkg/stackbuild/elixir.go
new file mode 100644
index 000000000..129913b3f
--- /dev/null
+++ b/pkg/stackbuild/elixir.go
@@ -0,0 +1,495 @@
+package stackbuild
+
+import (
+ "bytes"
+ "context"
+ "fmt"
+ "maps"
+ "path/filepath"
+ "regexp"
+ "slices"
+ "strings"
+ "time"
+
+ "github.com/moby/buildkit/client/llb"
+ "github.com/moby/buildkit/client/llb/imagemetaresolver"
+ "miren.dev/runtime/pkg/imagerefs"
+)
+
+// elixirDepEnvVars maps Mix dependency names to the environment variables they
+// typically require.
+var elixirDepEnvVars = map[string][]packageEnvVarDef{
+ "postgrex": {{name: "DATABASE_URL", confidence: "recommended"}},
+ "myxql": {{name: "DATABASE_URL", confidence: "recommended"}},
+ "redix": {{name: "REDIS_URL", confidence: "recommended"}},
+ "sentry": {{name: "SENTRY_DSN", confidence: "recommended"}},
+ "ex_aws": {{name: "AWS_ACCESS_KEY_ID", confidence: "recommended"}, {name: "AWS_SECRET_ACCESS_KEY", confidence: "recommended"}},
+}
+
+// elixirCode anchors a pattern to code that isn't commented out: everything
+// before the match is either not a '#' or a '#{' interpolation. phx.new's
+// runtime.exs is full of commented-out System.get_env examples. The anchor
+// means only the first read on a line is found, which is fine for config.
+const elixirCode = `^(?:[^#]|#\{)*?`
+
+// elixirEnvPatterns find env var reads in Elixir source and config.
+var elixirEnvPatterns = []*regexp.Regexp{
+ regexp.MustCompile(elixirCode + `System\.(?:get_env|fetch_env!?)\(\s*"([A-Z][A-Z0-9_]+)"`),
+}
+
+// elixirOptionalEnvPatterns mark reads that tolerate a missing value.
+// System.get_env/1 returns nil, so it's optional unless the nil is turned into
+// a crash: `System.get_env("X") || raise ...`, including the phx.new style that
+// breaks the line after `||` and raises on the next one. RE2 has no lookahead,
+// so "anything after || except raise or end of line" is spelled out as a
+// character class; a fallback that happens to start with "r" reads as
+// required, which errs on the side of asking.
+var elixirOptionalEnvPatterns = []*regexp.Regexp{
+ regexp.MustCompile(elixirCode + `System\.get_env\(\s*"([A-Z][A-Z0-9_]+)"\s*(?:,|\)\s*(?:\|\|\s*[^\sr]|[^|\s]|$))`),
+ regexp.MustCompile(elixirCode + `System\.fetch_env\(\s*"([A-Z][A-Z0-9_]+)"`),
+}
+
+var (
+ mixAppRe = regexp.MustCompile(`\bapp:\s*:([a-z_][a-zA-Z0-9_]*)`)
+ mixReleasesRe = regexp.MustCompile(`\breleases:\s*\[\s*([a-z_][a-zA-Z0-9_]*):`)
+ mixElixirRe = regexp.MustCompile(`\belixir:\s*"([^"]+)"`)
+)
+
+// ElixirStack implements Stack for Mix projects. It builds a Mix release on a
+// hexpm/elixir image and ships only the release, which bundles ERTS, on
+// debian-slim.
+type ElixirStack struct {
+ MetaStack
+
+ mixExs []byte
+ releaseName string
+ // namedRelease is set when mix.exs has a releases: block; only then does
+ // mix release accept the name as an argument.
+ namedRelease bool
+ umbrella bool
+ hasPhoenix bool
+ hasAssets bool
+ dnsCluster bool
+ // assetsNpm is set when assets/package.json exists: Phoenix keeps npm
+ // dependencies there, out of reach of the root-level npm augmentation.
+ assetsNpm bool
+ // pin is an Elixir version the app pins for its version manager, if any.
+ pin *elixirPin
+
+ requiredEnvVars []EnvVarRequirement
+}
+
+func (s *ElixirStack) BaseDistro() string {
+ return "debian"
+}
+
+func (s *ElixirStack) Name() string {
+ return "elixir"
+}
+
+func (s *ElixirStack) Detect() bool {
+ if !s.hasFile("mix.exs") {
+ return false
+ }
+ s.Event("file", "mix.exs", "Found mix.exs")
+ return true
+}
+
+func (s *ElixirStack) Init(opts BuildOptions) {
+ s.SetCwd("/app")
+
+ s.mixExs, _ = s.readFile("mix.exs")
+
+ if m := mixElixirRe.FindSubmatch(s.mixExs); m != nil {
+ s.Event("config", "elixir-requirement", "mix.exs requires Elixir "+string(m[1]))
+ }
+
+ s.umbrella = bytes.Contains(s.mixExs, []byte("apps_path:"))
+ if s.umbrella {
+ s.Event("config", "umbrella", "Umbrella project")
+ }
+
+ // A releases: block names the release explicitly, and is the only option
+ // for an umbrella; otherwise mix release uses the OTP app name.
+ if m := mixReleasesRe.FindSubmatch(s.mixExs); m != nil {
+ s.releaseName = string(m[1])
+ s.namedRelease = true
+ } else if m := mixAppRe.FindSubmatch(s.mixExs); m != nil && !s.umbrella {
+ s.releaseName = string(m[1])
+ }
+ if s.releaseName != "" {
+ s.Event("config", "release", "Mix release: "+s.releaseName)
+ }
+
+ // Check the lock too: an umbrella declares phoenix in its web app's
+ // mix.exs, not the root one, but the root mix.lock covers every app.
+ lock, _ := s.readFile("mix.lock")
+ if s.hasLockedDep("phoenix", lock) {
+ s.hasPhoenix = true
+ s.Event("framework", "phoenix", "Detected Phoenix")
+ if s.umbrella {
+ s.Event("config", "umbrella-assets", "Umbrella Phoenix app: assets under apps/ aren't built automatically; add their mix assets.deploy to [build] onbuild")
+ }
+ }
+
+ if bytes.Contains(s.mixExs, []byte(`"assets.deploy"`)) {
+ s.hasAssets = true
+ s.Event("config", "assets.deploy", "Will build assets with mix assets.deploy")
+ }
+
+ if s.hasFile("assets/package.json") {
+ s.assetsNpm = true
+ s.Event("augmentation", "npm", "Found assets/package.json, installing npm")
+ }
+
+ if s.hasFile("mix.lock") {
+ s.Event("file", "mix.lock", "Found mix.lock")
+ }
+
+ if s.pin = s.readElixirPin(); s.pin != nil {
+ s.Event("config", "elixir-version", "Elixir "+s.pin.elixir+" pinned in "+s.pin.source)
+ }
+
+ s.requiredEnvVars = s.detectEnvVars()
+ for _, ev := range s.requiredEnvVars {
+ s.Event("env_var", ev.Name, ev.Reason)
+ }
+}
+
+// hasDep reports whether mix.exs declares the named dependency.
+func (s *ElixirStack) hasDep(name string) bool {
+ return bytes.Contains(s.mixExs, []byte("{:"+name+","))
+}
+
+// hasLockedDep reports whether the named package is a direct dependency or
+// appears in mix.lock (so transitive deps count too).
+func (s *ElixirStack) hasLockedDep(name string, lock []byte) bool {
+ return s.hasDep(name) || bytes.Contains(lock, []byte(`"`+name+`": {`))
+}
+
+func (s *ElixirStack) GenerateLLB(ctx context.Context, dir string, opts BuildOptions) (*llb.State, error) {
+ if s.releaseName == "" {
+ return nil, fmt.Errorf("could not determine the Mix release name: add `app: :name` to project/0 in mix.exs, or a releases: block for an umbrella project")
+ }
+
+ build, err := s.builderImage(opts)
+ if err != nil {
+ return nil, err
+ }
+ tag := build.tag
+ // The release runs on bookworm-slim and links against the builder's glibc
+ // and OpenSSL, so a builder on another distro would build fine and then
+ // crash at boot. Only a full tag from build.version can get here that way.
+ if !strings.Contains(tag, "-debian-bookworm-") {
+ return nil, fmt.Errorf("build version %q is not a hexpm/elixir Debian bookworm tag: use a version like %s, or a full bookworm tag (see https://miren.md/guides/elixir#versions)", tag, imagerefs.ElixirDefaultVersion)
+ }
+ s.Event("config", "elixir-image", "Building on hexpm/elixir:"+tag+": "+build.detail)
+
+ localCtx := llb.Local("context",
+ llb.SharedKeyHint(dir),
+ // Local build output would clobber the build's own deps and _build, and
+ // assets/node_modules is always installed fresh below, since a copy
+ // from a laptop can carry the wrong platform's native modules. A root
+ // node_modules is left alone: the JS augmentations treat it as vendored.
+ llb.ExcludePatterns(contextExcludes("_build", "deps", ".elixir_ls", "assets/node_modules")),
+ llb.FollowPaths([]string{"."}),
+ llb.WithCustomName("application code"),
+ )
+
+ // The builder never becomes the final image, so resolve its config for the
+ // build steps without folding its env into the result (see GoStack).
+ mr := imagemetaresolver.New()
+ builder := llb.Image(imagerefs.GetElixirImage(tag), llb.WithMetaResolver(mr))
+
+ h := &highlevelBuilder{opts}
+
+ // The slim images carry no C toolchain or git, which NIF deps and git deps
+ // need respectively.
+ builder = h.aptInstall(builder, "build-essential", "git", "ca-certificates")
+ augs := s.Augmentations()
+ if s.assetsNpm && !slices.Contains(augs, AugNpm) && !slices.Contains(augs, AugYarn) {
+ builder = h.installNpm(builder, s.BaseDistro())
+ }
+ // The JS augmentations install packages as the app user.
+ builder = s.addAppUser(builder)
+ builder = h.applyAugmentations(builder, localCtx, s.BaseDistro(), augs, s.SkipJSInstall())
+
+ builder = builder.
+ AddEnv("MIX_ENV", "prod").
+ AddEnv("LANG", "C.UTF-8").
+ Dir("/app")
+
+ // User env vars have to be in place before any mix step: config/*.exs is
+ // evaluated by compile and again by release, and an
+ // Application.compile_env value that differs between the two makes the
+ // release refuse to boot. Sorted, because env order is part of every later
+ // step's cache key and map order would change it on each build.
+ for _, k := range slices.Sorted(maps.Keys(opts.EnvVars)) {
+ builder = builder.AddEnv(k, opts.EnvVars[k])
+ }
+
+ builder = builder.Run(
+ llb.Shlex("sh -c 'mix local.hex --force && mix local.rebar --force'"),
+ llb.WithCustomName("[phase] Installing Hex and Rebar"),
+ ).Root()
+
+ // Fetch and compile deps from mix.exs, mix.lock and config/ alone, so the
+ // layer survives changes to application code. An umbrella's deps are
+ // declared across apps/*/mix.exs, so it skips straight to the full copy.
+ if !s.umbrella {
+ builder = s.copyDepInputs(builder, localCtx)
+ builder = builder.Run(
+ llb.Shlex("sh -c 'mix deps.get --only prod && mix deps.compile'"),
+ h.CacheMount("/root/.hex/packages"),
+ llb.WithCustomName("[phase] Fetching and compiling Mix dependencies"),
+ ).Root()
+ }
+
+ builder = h.copyApp(builder, localCtx)
+
+ // Compile before assets.deploy: Phoenix 1.8 generates colocated LiveView
+ // hooks during compilation, and esbuild needs them.
+ builder = builder.Run(
+ llb.Shlex("sh -c 'mix deps.get --only prod && mix compile'"),
+ h.CacheMount("/root/.hex/packages"),
+ llb.WithCustomName("[phase] Compiling Elixir application"),
+ ).Root()
+
+ if s.assetsNpm {
+ npmCmd := "npm install"
+ if s.hasFile("assets/package-lock.json") {
+ npmCmd = "npm ci"
+ }
+ builder = builder.Dir("/app/assets").Run(
+ llb.Shlex(npmCmd),
+ h.CacheMount("/root/.npm"),
+ llb.WithCustomName("[phase] Installing asset JS deps with npm"),
+ ).Root().Dir("/app")
+ }
+
+ if s.hasAssets {
+ builder = builder.Run(
+ llb.Shlex("mix assets.deploy"),
+ llb.WithCustomName("[phase] Building assets"),
+ ).Root()
+ }
+
+ builder = s.applyOnBuild(builder, opts)
+
+ releaseCmd := "mix release --overwrite"
+ if s.namedRelease {
+ releaseCmd += " " + s.releaseName
+ }
+ builder = builder.Run(
+ llb.Shlex(releaseCmd),
+ llb.WithCustomName("[phase] Assembling Mix release"),
+ ).Root()
+
+ rt := s.assembleRuntime(ctx, h, builder, opts)
+ return &rt, nil
+}
+
+// copyDepInputs stages just what deps.get and deps.compile read. config/ comes
+// along because deps are compiled against the app's compile-time config, but
+// only when it exists: mix new stopped generating it in Elixir 1.9, and a
+// literal (non-wildcard) copy source fails the build when it's missing.
+func (s *ElixirStack) copyDepInputs(cur, mnt llb.State) llb.State {
+ origin := time.Date(2021, time.January, 1, 0, 0, 0, 0, time.UTC)
+ srcs := []string{"mix.exs", "mix.lock*"}
+ if s.hasDir("config") {
+ srcs = append(srcs, "config")
+ }
+ for _, src := range srcs {
+ cur = cur.File(llb.Copy(mnt, src, "/app/", &llb.CopyInfo{
+ CreateDestPath: true,
+ FollowSymlinks: true,
+ AllowWildcard: true,
+ AllowEmptyWildcard: true,
+ CreatedTime: &origin,
+ }))
+ }
+ return cur
+}
+
+// assembleRuntime puts the release on debian-slim. The release bundles ERTS,
+// so the runtime needs only the shared libraries ERTS links against. hexpm's
+// bookworm builders match DebianSlim's glibc and OpenSSL.
+func (s *ElixirStack) assembleRuntime(ctx context.Context, h *highlevelBuilder, builder llb.State, opts BuildOptions) llb.State {
+ rt := s.baseImage(ctx, imagerefs.DebianSlim, opts)
+ rt = h.aptInstall(rt, "libstdc++6", "openssl", "libncurses6", "ca-certificates")
+ rt = s.addAppUser(rt)
+ rt = rt.File(llb.Mkdir("/app", 0o755, llb.WithParents(true), llb.WithUIDGID(2010, 2011)))
+ rt = rt.File(llb.Copy(builder, "/app/_build/prod/rel/"+s.releaseName, "/app", &llb.CopyInfo{
+ CopyDirContentsOnly: true,
+ CreateDestPath: true,
+ ChownOpt: &appChown,
+ }), llb.WithCustomName("[phase] Copying Mix release"))
+
+ // The BEAM wants a UTF-8 locale; C.UTF-8 ships with Debian, so no
+ // locales package or locale-gen is needed.
+ s.AddEnv("LANG", "C.UTF-8")
+ s.AddEnv("MIX_ENV", "prod")
+ // The app user's home is /app, which it owns; the release writes its
+ // tmp dir and the BEAM its cookie relative to it.
+ s.AddEnv("HOME", "/app")
+ if s.hasPhoenix {
+ // phx.new's runtime.exs only starts the endpoint when this is set.
+ s.AddEnv("PHX_SERVER", "true")
+ }
+ return rt
+}
+
+func (s *ElixirStack) WebCommand() string {
+ if s.releaseName == "" {
+ return ""
+ }
+ if !s.dnsCluster {
+ return "/app/bin/" + s.releaseName + " start"
+ }
+ // DNSCluster discovers peers by IP and constructs release_name@IP. The
+ // release must use the same naming scheme on every replica. Leave explicit
+ // node settings alone for applications with their own clustering setup.
+ return `RELEASE_DISTRIBUTION=${RELEASE_DISTRIBUTION:-name} RELEASE_NODE=${RELEASE_NODE:-` + s.releaseName + `@$(hostname -i)} exec /app/bin/` + s.releaseName + ` start`
+}
+
+// RequiredEnvVars returns the detected environment variable requirements
+func (s *ElixirStack) RequiredEnvVars() []EnvVarRequirement {
+ return s.requiredEnvVars
+}
+
+func (s *ElixirStack) detectEnvVars() []EnvVarRequirement {
+ var results []EnvVarRequirement
+
+ sourceVars := scanSourceFilesForEnvVars(s.dir, []string{".ex", ".exs"}, elixirEnvPatterns, elixirOptionalEnvPatterns)
+ sourceVars = withoutDevOnlyConfig(s.dir, sourceVars)
+ lock, _ := s.readFile("mix.lock")
+
+ // 1. Phoenix core. phx.new's runtime.exs raises without SECRET_KEY_BASE,
+ // and PHX_HOST drives both URL generation and the websocket origin
+ // check, so LiveView can't connect until it matches the app's route.
+ if s.hasPhoenix {
+ results = append(results, EnvVarRequirement{
+ Name: "SECRET_KEY_BASE",
+ Source: "phoenix_core",
+ Confidence: "required",
+ Reason: "Required by Phoenix in production",
+ CanGenerate: true,
+ }, EnvVarRequirement{
+ Name: "PHX_HOST",
+ Source: "phoenix_core",
+ Confidence: "required",
+ Reason: "Set to the public route hostname before deploy; Phoenix checks LiveView websocket origins against it",
+ })
+ }
+ // Only scaffolded DNSCluster apps need these settings. Do not turn on
+ // clustering for an arbitrary Mix app (or one with a custom discovery setup).
+ usesDNSCluster := false
+ for _, v := range sourceVars {
+ usesDNSCluster = usesDNSCluster || v.name == "DNS_CLUSTER_QUERY"
+ }
+ s.dnsCluster = s.hasLockedDep("dns_cluster", lock) && usesDNSCluster
+ if s.dnsCluster {
+ results = append(results, EnvVarRequirement{
+ Name: "DNS_CLUSTER_QUERY", Source: "dns_cluster", Confidence: "required",
+ DefaultValue: "web.app.miren", Reason: "Discover Miren web replicas by DNS",
+ }, EnvVarRequirement{
+ Name: "RELEASE_COOKIE", Source: "dns_cluster", Confidence: "required",
+ CanGenerate: true, Reason: "Shared Erlang cookie for all replicas",
+ })
+ }
+
+ // 2. Dependency-based inference, elevated when the code reads the var
+ // without a fallback.
+ deps := make([]string, 0, len(elixirDepEnvVars))
+ for dep := range elixirDepEnvVars {
+ deps = append(deps, dep)
+ }
+ slices.Sort(deps)
+ for _, dep := range deps {
+ if !s.hasLockedDep(dep, lock) {
+ continue
+ }
+ for _, v := range elixirDepEnvVars[dep] {
+ if hasEnvVar(results, v.name) {
+ continue
+ }
+ confidence := v.confidence
+ if confidence == "recommended" && elevateToRequired(v.name, sourceVars) {
+ confidence = "required"
+ }
+ results = append(results, EnvVarRequirement{
+ Name: v.name,
+ Source: "dep",
+ Confidence: confidence,
+ Reason: dep + " dependency detected",
+ })
+ }
+ }
+
+ // 3. Remaining source references.
+ for _, v := range sourceVars {
+ if hasEnvVar(results, v.name) {
+ continue
+ }
+ confidence, reason := "required", "Referenced in application code"
+ if v.optional {
+ confidence, reason = "optional", "Referenced in application code (has fallback)"
+ }
+ results = append(results, EnvVarRequirement{
+ Name: v.name,
+ Source: "code",
+ Confidence: confidence,
+ Reason: reason,
+ })
+ }
+
+ // 4. Sample env files.
+ for _, filename := range []string{".env.sample", ".env.example"} {
+ for _, v := range parseEnvSampleFile(s.dir, filename) {
+ if !hasEnvVar(results, v) {
+ results = append(results, EnvVarRequirement{
+ Name: v,
+ Source: "config",
+ Confidence: "required",
+ Reason: "Declared in " + filename,
+ })
+ }
+ }
+ }
+
+ return results
+}
+
+// withoutDevOnlyConfig drops vars that are only read from config/dev.exs or
+// config/test.exs, which never load in a prod release.
+func withoutDevOnlyConfig(dir string, vars []detectedEnvVar) []detectedEnvVar {
+ devOnly := map[string]bool{}
+ for _, f := range []string{"config/dev.exs", "config/test.exs"} {
+ for _, v := range scanFileForEnvVars(filepath.Join(dir, f), elixirEnvPatterns, elixirOptionalEnvPatterns) {
+ devOnly[v.name] = true
+ }
+ }
+ if len(devOnly) == 0 {
+ return vars
+ }
+
+ // A var read from dev/test config may also be read elsewhere; keep it if so.
+ elsewhere := map[string]bool{}
+ for _, v := range scanSourceFilesForEnvVars(dir, []string{".ex"}, elixirEnvPatterns, nil) {
+ elsewhere[v.name] = true
+ }
+ for _, f := range []string{"config/config.exs", "config/prod.exs", "config/runtime.exs"} {
+ for _, v := range scanFileForEnvVars(filepath.Join(dir, f), elixirEnvPatterns, nil) {
+ elsewhere[v.name] = true
+ }
+ }
+
+ var kept []detectedEnvVar
+ for _, v := range vars {
+ if devOnly[v.name] && !elsewhere[v.name] {
+ continue
+ }
+ kept = append(kept, v)
+ }
+ return kept
+}
diff --git a/pkg/stackbuild/elixir_test.go b/pkg/stackbuild/elixir_test.go
new file mode 100644
index 000000000..c287f9fbd
--- /dev/null
+++ b/pkg/stackbuild/elixir_test.go
@@ -0,0 +1,490 @@
+package stackbuild
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "miren.dev/runtime/pkg/tarx"
+)
+
+// copyFixture copies testdata/ into a fresh directory and returns it.
+func copyFixture(t *testing.T, name string) string {
+ t.Helper()
+ dir := t.TempDir()
+ src := filepath.Join("testdata", name)
+ err := filepath.Walk(src, func(path string, info os.FileInfo, err error) error {
+ if err != nil {
+ return err
+ }
+ rel, _ := filepath.Rel(src, path)
+ dst := filepath.Join(dir, rel)
+ if info.IsDir() {
+ return os.MkdirAll(dst, 0o755)
+ }
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return err
+ }
+ return os.WriteFile(dst, data, 0o644)
+ })
+ require.NoError(t, err)
+ return dir
+}
+
+func initElixir(t *testing.T, dir string) *ElixirStack {
+ t.Helper()
+ s := &ElixirStack{MetaStack: MetaStack{dir: dir}}
+ require.True(t, s.Detect())
+ s.Init(BuildOptions{})
+ return s
+}
+
+func envByName(vars []EnvVarRequirement) map[string]EnvVarRequirement {
+ m := map[string]EnvVarRequirement{}
+ for _, v := range vars {
+ m[v.Name] = v
+ }
+ return m
+}
+
+func TestElixirDetect(t *testing.T) {
+ t.Run("mix project", func(t *testing.T) {
+ dir := copyFixture(t, "elixir")
+ s := initElixir(t, dir)
+ assert.Equal(t, "hello", s.releaseName)
+ assert.False(t, s.hasPhoenix)
+ assert.False(t, s.hasAssets)
+ assert.Equal(t, "/app/bin/hello start", s.WebCommand())
+ })
+
+ t.Run("phoenix", func(t *testing.T) {
+ dir := copyFixture(t, "phoenix")
+ s := initElixir(t, dir)
+ assert.Equal(t, "hopwatch", s.releaseName)
+ assert.True(t, s.hasPhoenix)
+ assert.True(t, s.hasAssets)
+ assert.Equal(t, `RELEASE_DISTRIBUTION=${RELEASE_DISTRIBUTION:-name} RELEASE_NODE=${RELEASE_NODE:-hopwatch@$(hostname -i)} exec /app/bin/hopwatch start`, s.WebCommand())
+ })
+
+ t.Run("no mix.exs", func(t *testing.T) {
+ s := &ElixirStack{MetaStack: MetaStack{dir: t.TempDir()}}
+ assert.False(t, s.Detect())
+ })
+
+ t.Run("wins over a root package.json", func(t *testing.T) {
+ dir := copyFixture(t, "phoenix")
+ require.NoError(t, os.WriteFile(filepath.Join(dir, "package.json"), []byte(`{}`), 0o644))
+ stack, err := DetectStack(dir, BuildOptions{})
+ require.NoError(t, err)
+ assert.Equal(t, "elixir", stack.Name())
+ })
+}
+
+func TestElixirReleaseName(t *testing.T) {
+ cases := []struct {
+ name string
+ mixExs string
+ want string
+ }{
+ {
+ name: "app name",
+ mixExs: `def project, do: [app: :my_app, version: "0.1.0"]`,
+ want: "my_app",
+ },
+ {
+ name: "explicit release wins",
+ mixExs: `def project do
+ [app: :my_app, releases: [web: [applications: [my_app: :permanent]]]]
+end`,
+ want: "web",
+ },
+ {
+ name: "umbrella with a release",
+ mixExs: `def project, do: [apps_path: "apps", releases: [platform: [applications: [a: :permanent]]]]`,
+ want: "platform",
+ },
+ {
+ name: "umbrella without a release",
+ mixExs: `def project, do: [apps_path: "apps", deps: [{:credo, "~> 1.7", app: false}]]`,
+ want: "",
+ },
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ dir := t.TempDir()
+ require.NoError(t, os.WriteFile(filepath.Join(dir, "mix.exs"), []byte(tc.mixExs), 0o644))
+ s := initElixir(t, dir)
+ assert.Equal(t, tc.want, s.releaseName)
+ })
+ }
+
+ t.Run("missing release name fails the build clearly", func(t *testing.T) {
+ dir := t.TempDir()
+ require.NoError(t, os.WriteFile(filepath.Join(dir, "mix.exs"), []byte(`def project, do: [apps_path: "apps"]`), 0o644))
+ s := initElixir(t, dir)
+ _, err := s.GenerateLLB(context.Background(), dir, BuildOptions{})
+ assert.ErrorContains(t, err, "releases:")
+ assert.Empty(t, s.WebCommand())
+ })
+}
+
+func TestElixirEnvPatterns(t *testing.T) {
+ cases := []struct {
+ line string
+ want string // "" means no match
+ optional bool
+ }{
+ {`url = System.fetch_env!("API_URL")`, "API_URL", false},
+ {`System.fetch_env("API_URL")`, "API_URL", true},
+ {`System.get_env("DATABASE_URL") ||`, "DATABASE_URL", false},
+ {`System.get_env("DATABASE_URL") || raise "missing"`, "DATABASE_URL", false},
+ {`pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10"),`, "POOL_SIZE", true},
+ {`port: String.to_integer(System.get_env("PORT_X", "4000"))`, "PORT_X", true},
+ {`if System.get_env("ECTO_IPV6") in ~w(true 1), do: [:inet6], else: []`, "ECTO_IPV6", true},
+ {`config :app, :q, System.get_env("DNS_CLUSTER_QUERY")`, "DNS_CLUSTER_QUERY", true},
+ {`token = System.get_env("SLACK_WEBHOOK_URL")`, "SLACK_WEBHOOK_URL", true},
+ {`msg = "#{System.get_env("GREETING")}"`, "GREETING", true},
+ {` # keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"),`, "", false},
+ {`x = 1 # System.fetch_env!("TRAILING_COMMENT")`, "", false},
+ {`System.get_env("lowercase")`, "", false},
+ }
+ for _, tc := range cases {
+ t.Run(tc.line, func(t *testing.T) {
+ var got string
+ for _, p := range elixirEnvPatterns {
+ if m := p.FindStringSubmatch(tc.line); m != nil {
+ got = m[1]
+ }
+ }
+ assert.Equal(t, tc.want, got)
+ if tc.want != "" {
+ assert.Equal(t, tc.optional, isOptionalEnvUsageGeneric(tc.line, tc.want, elixirOptionalEnvPatterns))
+ }
+ })
+ }
+}
+
+func TestElixirEnvVars(t *testing.T) {
+ t.Run("phoenix", func(t *testing.T) {
+ dir := copyFixture(t, "phoenix")
+ vars := envByName(initElixir(t, dir).RequiredEnvVars())
+
+ assert.Equal(t, "required", vars["SECRET_KEY_BASE"].Confidence)
+ assert.True(t, vars["SECRET_KEY_BASE"].CanGenerate)
+ assert.Equal(t, "required", vars["PHX_HOST"].Confidence)
+ assert.Equal(t, "web.app.miren", vars["DNS_CLUSTER_QUERY"].DefaultValue)
+ assert.True(t, vars["RELEASE_COOKIE"].CanGenerate)
+ assert.Equal(t, "required", vars["RELEASE_COOKIE"].Confidence)
+ // postgrex is in the lock, and runtime.exs raises without it.
+ assert.Equal(t, "required", vars["DATABASE_URL"].Confidence)
+ assert.Equal(t, "optional", vars["POOL_SIZE"].Confidence)
+ assert.Equal(t, "optional", vars["ECTO_IPV6"].Confidence)
+
+ for _, name := range []string{"PORT", "PHX_SERVER", "SOME_APP_SSL_KEY_PATH", "MAILGUN_API_KEY"} {
+ assert.NotContains(t, vars, name)
+ }
+ })
+
+ t.Run("plain mix", func(t *testing.T) {
+ dir := copyFixture(t, "elixir")
+ vars := envByName(initElixir(t, dir).RequiredEnvVars())
+ assert.Equal(t, "required", vars["API_TOKEN"].Confidence)
+ assert.Equal(t, "optional", vars["GREETING"].Confidence)
+ assert.NotContains(t, vars, "SECRET_KEY_BASE")
+ assert.NotContains(t, vars, "DNS_CLUSTER_QUERY")
+ assert.NotContains(t, vars, "RELEASE_COOKIE")
+ })
+
+ t.Run("dns_cluster dependency without runtime query does not enable clustering", func(t *testing.T) {
+ dir := copyFixture(t, "phoenix")
+ path := filepath.Join(dir, "config", "runtime.exs")
+ config, err := os.ReadFile(path)
+ require.NoError(t, err)
+ config = []byte(strings.ReplaceAll(string(config), `System.get_env("DNS_CLUSTER_QUERY")`, "nil"))
+ require.NoError(t, os.WriteFile(path, config, 0o644))
+ s := initElixir(t, dir)
+ vars := envByName(s.RequiredEnvVars())
+ assert.NotContains(t, vars, "DNS_CLUSTER_QUERY")
+ assert.NotContains(t, vars, "RELEASE_COOKIE")
+ assert.Equal(t, "/app/bin/hopwatch start", s.WebCommand())
+ })
+
+ t.Run("dev-only config is ignored", func(t *testing.T) {
+ dir := copyFixture(t, "elixir")
+ require.NoError(t, os.WriteFile(filepath.Join(dir, "config", "dev.exs"),
+ []byte(`import Config
+config :hello, dev_token: System.fetch_env!("DEV_ONLY_TOKEN")
+config :hello, shared: System.fetch_env!("API_TOKEN")
+`), 0o644))
+ vars := envByName(initElixir(t, dir).RequiredEnvVars())
+ assert.NotContains(t, vars, "DEV_ONLY_TOKEN")
+ assert.Contains(t, vars, "API_TOKEN")
+ })
+
+ t.Run("deps and _build are not scanned", func(t *testing.T) {
+ dir := copyFixture(t, "elixir")
+ for _, d := range []string{"deps/somedep/lib", "_build/prod/lib"} {
+ require.NoError(t, os.MkdirAll(filepath.Join(dir, d), 0o755))
+ require.NoError(t, os.WriteFile(filepath.Join(dir, d, "x.ex"),
+ []byte(`System.fetch_env!("FROM_DEPS")`), 0o644))
+ }
+ vars := envByName(initElixir(t, dir).RequiredEnvVars())
+ assert.NotContains(t, vars, "FROM_DEPS")
+ })
+}
+
+func TestElixir(t *testing.T) {
+ if !checkDocker() {
+ t.Skip("Docker not available")
+ }
+
+ dir := copyFixture(t, "elixir")
+ s := initElixir(t, dir)
+ state, err := s.GenerateLLB(context.Background(), dir, BuildOptions{})
+ require.NoError(t, err)
+
+ buildLLB(t, dir, state, func(r io.Reader) {
+ m, err := tarx.TarToMap(r)
+ require.NoError(t, err)
+ require.Contains(t, m, "app/bin/hello")
+ require.Contains(t, m, "app/releases/0.1.0/sys.config")
+ for name := range m {
+ // The release bundles ERTS; the Elixir toolchain stays behind.
+ require.NotContains(t, name, "usr/local/lib/elixir")
+ }
+ })
+
+ cfg := s.Image().Config
+ assert.Contains(t, cfg.Env, "MIX_ENV=prod")
+ assert.Contains(t, cfg.Env, "LANG=C.UTF-8")
+ assert.NotContains(t, cfg.Env, "PHX_SERVER=true")
+ assert.Equal(t, "2010", cfg.User)
+}
+
+func TestElixirWithNpm(t *testing.T) {
+ if !checkDocker() {
+ t.Skip("Docker not available")
+ }
+
+ // A root package.json triggers the npm augmentation, which installs as
+ // the app user; assets/package.json gets the Elixir stack's own install.
+ dir := copyFixture(t, "elixir")
+ require.NoError(t, os.MkdirAll(filepath.Join(dir, "assets"), 0o755))
+ pkg := []byte(`{"name": "x", "private": true, "version": "0.0.0"}`)
+ require.NoError(t, os.WriteFile(filepath.Join(dir, "package.json"), pkg, 0o644))
+ require.NoError(t, os.WriteFile(filepath.Join(dir, "assets", "package.json"), pkg, 0o644))
+
+ stack, err := DetectStack(dir, BuildOptions{})
+ require.NoError(t, err)
+ require.Equal(t, "elixir", stack.Name())
+ require.True(t, stack.(*ElixirStack).assetsNpm)
+
+ state, err := stack.GenerateLLB(context.Background(), dir, BuildOptions{})
+ require.NoError(t, err)
+
+ buildLLB(t, dir, state, func(r io.Reader) {
+ m, err := tarx.TarToMap(r)
+ require.NoError(t, err)
+ require.Contains(t, m, "app/bin/hello")
+ })
+}
+
+func TestElixirBuildVersion(t *testing.T) {
+ cases := []struct {
+ version string
+ want string // tag prefix; "" means an error
+ errHas string
+ }{
+ {"1.18", "1.18.5-erlang-27.3.4.18-", ""},
+ {"1.18.4", "1.18.5-erlang-27.3.4.18-", ""},
+ {"1.18-otp-26", "1.18.5-erlang-26.2.5.21-", ""},
+ {"1.20.1-otp-29", "1.20.4-erlang-29.1.1-", ""},
+ {"1.19.6-erlang-28.5.0.7-debian-bookworm-20260918-slim", "1.19.6-erlang-28.5.0.7-debian-bookworm-", ""},
+ {"1.9", "", "no Miren build for Elixir 1.9"},
+ {"1.18-otp-29", "", "available: OTP 25, 26, 27"},
+ {"latest", "", "unrecognized Elixir version"},
+ {"1.19.6-erlang-28.5.0.7-debian-trixie-20260918-slim", "", "not a hexpm/elixir Debian bookworm tag"},
+ {"1.19.6-erlang-28.5.0.7-alpine-3.22.1", "", "not a hexpm/elixir Debian bookworm tag"},
+ }
+ for _, tc := range cases {
+ t.Run(tc.version, func(t *testing.T) {
+ dir := copyFixture(t, "elixir")
+ s := initElixir(t, dir)
+ b, err := s.builderImage(BuildOptions{Version: tc.version})
+ if err == nil && !strings.Contains(b.tag, "-debian-bookworm-") {
+ err = fmt.Errorf("not a hexpm/elixir Debian bookworm tag")
+ }
+ if tc.want == "" {
+ assert.ErrorContains(t, err, tc.errHas)
+ return
+ }
+ require.NoError(t, err)
+ assert.True(t, strings.HasPrefix(b.tag, tc.want), b.tag)
+ })
+ }
+
+ t.Run("GenerateLLB refuses a non-bookworm full tag", func(t *testing.T) {
+ dir := copyFixture(t, "elixir")
+ s := initElixir(t, dir)
+ _, err := s.GenerateLLB(context.Background(), dir, BuildOptions{Version: "1.19.6-erlang-28.5.0.7-alpine-3.22.1"})
+ assert.ErrorContains(t, err, "not a hexpm/elixir Debian bookworm tag")
+ })
+}
+
+func TestElixirPinnedVersion(t *testing.T) {
+ cases := []struct {
+ name string
+ files map[string]string
+ opts BuildOptions
+ want string // tag prefix; "" means an error
+ note string // expected substring of an elixir-version event
+ }{
+ {
+ name: "tool-versions with an otp suffix",
+ files: map[string]string{".tool-versions": "erlang 26.2.5\nelixir 1.18.4-otp-26 # pinned\n"},
+ want: "1.18.5-erlang-26.2.5.21-",
+ },
+ {
+ name: "tool-versions erlang line picks the otp",
+ files: map[string]string{".tool-versions": "elixir 1.17.3\nerlang 25.3.2\n"},
+ want: "1.17.3-erlang-25.3.2.21-",
+ },
+ {
+ name: "mise string entry",
+ files: map[string]string{"mise.toml": "[tools]\nelixir = \"1.20\"\n"},
+ want: "1.20.4-erlang-28.5.0.7-",
+ },
+ {
+ name: "mise list and table entries",
+ files: map[string]string{".mise.toml": "[tools]\nelixir = [\"1.18.4-otp-27\", \"1.17\"]\nerlang = { version = \"27.3\" }\n"},
+ want: "1.18.5-erlang-27.3.4.18-",
+ },
+ {
+ name: "tool-versions wins over mise",
+ files: map[string]string{".tool-versions": "elixir 1.17\n", "mise.toml": "[tools]\nelixir = \"1.20\"\n"},
+ want: "1.17.3-",
+ },
+ {
+ name: "an otp the minor doesn't build falls back to its default",
+ files: map[string]string{".tool-versions": "elixir 1.18.4\nerlang 29.1\n"},
+ want: "1.18.5-erlang-27.3.4.18-",
+ note: "isn't built on OTP 29; building Elixir 1.18.5 on OTP 27.3.4.18",
+ },
+ {
+ name: "an unknown minor is an error, naming the file",
+ files: map[string]string{".tool-versions": "elixir 1.12.3\n"},
+ note: ".tool-versions: no Miren build for Elixir 1.12",
+ },
+ {
+ name: "build.version wins over a pin",
+ files: map[string]string{".tool-versions": "elixir 1.17\n"},
+ opts: BuildOptions{Version: "1.20"},
+ want: "1.20.4-",
+ },
+ {
+ name: "no pin uses the default",
+ want: "1.19.6-erlang-28.5.0.7-",
+ },
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ dir := copyFixture(t, "elixir")
+ for name, content := range tc.files {
+ require.NoError(t, os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644))
+ }
+ s := initElixir(t, dir)
+ b, err := s.builderImage(tc.opts)
+ if tc.want == "" {
+ assert.ErrorContains(t, err, tc.note)
+ return
+ }
+ require.NoError(t, err)
+ assert.True(t, strings.HasPrefix(b.tag, tc.want), b.tag)
+ if tc.note != "" {
+ var found bool
+ for _, ev := range s.Events() {
+ found = found || strings.Contains(ev.Message, tc.note)
+ }
+ assert.True(t, found, "expected an event containing %q", tc.note)
+ }
+ })
+ }
+}
+
+func TestElixirWithoutConfigUsesBuildEnv(t *testing.T) {
+ if !checkDocker() {
+ t.Skip("Docker not available")
+ }
+
+ // mix new hasn't generated config/ since Elixir 1.9, and this module
+ // reads a user env var at compile time, so the build needs both a
+ // config-less dep stage and the env vars in place before mix compile.
+ dir := copyFixture(t, "elixir")
+ require.NoError(t, os.RemoveAll(filepath.Join(dir, "config")))
+ require.NoError(t, os.WriteFile(filepath.Join(dir, "lib", "token.ex"), []byte(`defmodule Hello.Token do
+ @token System.fetch_env!("BUILD_TOKEN")
+ def token, do: @token
+end
+`), 0o644))
+
+ s := initElixir(t, dir)
+ opts := BuildOptions{EnvVars: map[string]string{"BUILD_TOKEN": "from-miren"}}
+ state, err := s.GenerateLLB(context.Background(), dir, opts)
+ require.NoError(t, err)
+
+ buildLLB(t, dir, state, func(r io.Reader) {
+ m, err := tarx.TarToMap(r)
+ require.NoError(t, err)
+ require.Contains(t, m, "app/bin/hello")
+ })
+}
+
+func TestElixirUmbrellaPhoenix(t *testing.T) {
+ // mix phx.new --umbrella declares phoenix in apps/_web/mix.exs; only
+ // the root mix.lock shows it at the top level.
+ dir := t.TempDir()
+ files := map[string]string{
+ "mix.exs": `defmodule Shop.Umbrella.MixProject do
+ use Mix.Project
+ def project do
+ [apps_path: "apps", releases: [shop: [applications: [shop: :permanent, shop_web: :permanent]]]]
+ end
+end
+`,
+ "mix.lock": `%{
+ "phoenix": {:hex, :phoenix, "1.8.14", "abc", [:mix], [], "hexpm", "def"},
+}
+`,
+ "apps/shop_web/mix.exs": `defmodule ShopWeb.MixProject do
+ use Mix.Project
+ def project, do: [app: :shop_web, deps: [{:phoenix, "~> 1.8"}]]
+end
+`,
+ }
+ for name, content := range files {
+ path := filepath.Join(dir, name)
+ require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755))
+ require.NoError(t, os.WriteFile(path, []byte(content), 0o644))
+ }
+
+ s := initElixir(t, dir)
+ assert.True(t, s.umbrella)
+ assert.True(t, s.hasPhoenix, "phoenix in mix.lock should count")
+ assert.Equal(t, "shop", s.releaseName)
+ assert.Equal(t, "/app/bin/shop start", s.WebCommand())
+ assert.Equal(t, "required", envByName(s.RequiredEnvVars())["SECRET_KEY_BASE"].Confidence)
+
+ var warned bool
+ for _, ev := range s.Events() {
+ warned = warned || ev.Name == "umbrella-assets"
+ }
+ assert.True(t, warned, "umbrella Phoenix apps should be told assets aren't built")
+}
diff --git a/pkg/stackbuild/elixir_version.go b/pkg/stackbuild/elixir_version.go
new file mode 100644
index 000000000..d98a7f8e9
--- /dev/null
+++ b/pkg/stackbuild/elixir_version.go
@@ -0,0 +1,170 @@
+package stackbuild
+
+import (
+ "bufio"
+ "bytes"
+ "fmt"
+ "maps"
+ "regexp"
+ "slices"
+ "strings"
+
+ "github.com/pelletier/go-toml/v2"
+ "miren.dev/runtime/pkg/imagerefs"
+)
+
+// elixirVersionRe matches the versions people write for Elixir: a minor or a
+// full version, optionally with asdf's OTP suffix ("1.18", "1.18.4",
+// "1.18.4-otp-27").
+var elixirVersionRe = regexp.MustCompile(`^(\d+\.\d+)(?:\.(\d+))?(?:-otp-(\d+))?$`)
+
+// otpMajorRe takes the major from an Erlang version ("27", "27.3.4").
+var otpMajorRe = regexp.MustCompile(`^(\d+)(?:\.|$)`)
+
+// elixirPin is an Elixir (and maybe Erlang) version an app pins in a version
+// manager's file.
+type elixirPin struct {
+ elixir string // as written, e.g. "1.18.4-otp-27"
+ erlang string // as written, e.g. "27.3.4"; may be empty
+ source string // the file it came from
+}
+
+// elixirBuild is the image an Elixir build resolved to.
+type elixirBuild struct {
+ tag string
+ detail string // human-readable: what was asked for and what it maps to
+}
+
+// resolveElixirVersion maps an Elixir version and optional OTP major onto a
+// tag from imagerefs.ElixirReleases.
+func resolveElixirVersion(version, otpMajor string) (elixirBuild, error) {
+ m := elixirVersionRe.FindStringSubmatch(version)
+ if m == nil {
+ return elixirBuild{}, fmt.Errorf("unrecognized Elixir version %q: use a version like 1.18 or 1.18-otp-27, or a full hexpm/elixir tag", version)
+ }
+ minor, patch := m[1], m[2]
+ if m[3] != "" {
+ otpMajor = m[3]
+ }
+
+ rel, ok := imagerefs.ElixirReleases[minor]
+ if !ok {
+ return elixirBuild{}, fmt.Errorf("no Miren build for Elixir %s (available: %s): pick one of those, or set build.version to a full hexpm/elixir tag", minor, strings.Join(slices.Sorted(maps.Keys(imagerefs.ElixirReleases)), ", "))
+ }
+ if otpMajor == "" {
+ otpMajor = rel.DefaultOTP
+ }
+ if _, ok := rel.OTP[otpMajor]; !ok {
+ return elixirBuild{}, fmt.Errorf("no Miren build for Elixir %s on OTP %s (available: OTP %s): pick one of those, or set build.version to a full hexpm/elixir tag", minor, otpMajor, strings.Join(slices.Sorted(maps.Keys(rel.OTP)), ", "))
+ }
+
+ detail := fmt.Sprintf("Elixir %s on OTP %s", rel.Patch, rel.OTP[otpMajor])
+ if patch != "" && minor+"."+patch != rel.Patch {
+ detail += fmt.Sprintf(" (the %s release Miren builds, for %s)", minor, version)
+ }
+ return elixirBuild{tag: imagerefs.ElixirTag(minor, otpMajor), detail: detail}, nil
+}
+
+// readElixirPin looks for a pinned Elixir version where Elixir projects
+// usually keep one: asdf's .tool-versions, then mise's config.
+func (s *ElixirStack) readElixirPin() *elixirPin {
+ if content, err := s.readFile(".tool-versions"); err == nil {
+ tools := toolVersions(content)
+ if tools["elixir"] != "" {
+ return &elixirPin{elixir: tools["elixir"], erlang: tools["erlang"], source: ".tool-versions"}
+ }
+ }
+ for _, name := range []string{"mise.toml", ".mise.toml"} {
+ content, err := s.readFile(name)
+ if err != nil {
+ continue
+ }
+ var cfg struct {
+ Tools map[string]any `toml:"tools"`
+ }
+ if toml.Unmarshal(content, &cfg) != nil {
+ continue
+ }
+ if elixir := miseVersion(cfg.Tools["elixir"]); elixir != "" {
+ return &elixirPin{elixir: elixir, erlang: miseVersion(cfg.Tools["erlang"]), source: name}
+ }
+ }
+ return nil
+}
+
+// toolVersions parses an asdf .tool-versions file into tool -> version. When a
+// tool lists several versions, the first is the one asdf uses.
+func toolVersions(content []byte) map[string]string {
+ out := map[string]string{}
+ sc := bufio.NewScanner(bytes.NewReader(content))
+ for sc.Scan() {
+ line, _, _ := strings.Cut(sc.Text(), "#")
+ if fields := strings.Fields(line); len(fields) >= 2 {
+ out[fields[0]] = fields[1]
+ }
+ }
+ return out
+}
+
+// miseVersion reads a mise tool entry, which can be a string, a list of
+// versions (the first wins), or a table with a version key.
+func miseVersion(v any) string {
+ switch v := v.(type) {
+ case string:
+ return v
+ case []any:
+ if len(v) > 0 {
+ return miseVersion(v[0])
+ }
+ case map[string]any:
+ return miseVersion(v["version"])
+ }
+ return ""
+}
+
+// otpMajor returns the major version from an Erlang version, or "".
+func otpMajor(erlang string) string {
+ if m := otpMajorRe.FindStringSubmatch(erlang); m != nil {
+ return m[1]
+ }
+ return ""
+}
+
+// builderImage picks the hexpm/elixir tag to build on: build.version when set
+// (a version like 1.18, or a full tag as the escape hatch), else a version the
+// app pins in .tool-versions or mise.toml, else the default.
+func (s *ElixirStack) builderImage(opts BuildOptions) (elixirBuild, error) {
+ if v := opts.Version; v != "" {
+ if strings.Contains(v, "-erlang-") {
+ return elixirBuild{tag: v, detail: "a full hexpm/elixir tag from build.version"}, nil
+ }
+ b, err := resolveElixirVersion(v, "")
+ if err != nil {
+ return b, fmt.Errorf("build.version: %w", err)
+ }
+ b.detail += " (from build.version)"
+ return b, nil
+ }
+
+ if pin := s.pin; pin != nil {
+ otp := otpMajor(pin.erlang)
+ b, err := resolveElixirVersion(pin.elixir, otp)
+ if err != nil && otp != "" {
+ // The Elixir is fine but its OTP isn't one we build; a different OTP
+ // within the same Elixir is the least surprising substitute.
+ if fallback, ferr := resolveElixirVersion(pin.elixir, ""); ferr == nil {
+ s.Event("config", "elixir-version", fmt.Sprintf("Elixir %s isn't built on OTP %s; building %s instead", pin.elixir, otp, fallback.detail))
+ b, err = fallback, nil
+ }
+ }
+ if err != nil {
+ return b, fmt.Errorf("%s: %w", pin.source, err)
+ }
+ b.detail += " (from " + pin.source + ")"
+ return b, nil
+ }
+
+ b, err := resolveElixirVersion(imagerefs.ElixirDefaultVersion, "")
+ b.detail += " (default)"
+ return b, err
+}
diff --git a/pkg/stackbuild/envdetect.go b/pkg/stackbuild/envdetect.go
index fc8682bc9..d2c3cca81 100644
--- a/pkg/stackbuild/envdetect.go
+++ b/pkg/stackbuild/envdetect.go
@@ -43,6 +43,9 @@ var ignoredEnvVars = map[string]bool{
"RAILS_MASTER_KEY": true, // Detected via rails_core
"RAILS_ENV": true, // Detected via rails_core with default
"PORT": true,
+ "MIX_ENV": true, // Set to prod by the Elixir stack
+ "PHX_SERVER": true, // Set by the Elixir stack for Phoenix apps
+ "PHX_HOST": true, // Detected via phoenix_core
}
// parseEnvSampleFile parses a .env.sample or .env.example file and returns variable names
@@ -142,6 +145,8 @@ var skipDirs = map[string]bool{
"log": true,
"logs": true,
"target": true, // Rust build directory
+ "deps": true, // Mix dependency sources
+ "_build": true, // Mix build output
"dist": true,
"build": true,
".venv": true,
diff --git a/pkg/stackbuild/stackbuild.go b/pkg/stackbuild/stackbuild.go
index 8b8b0222e..b416e76e6 100644
--- a/pkg/stackbuild/stackbuild.go
+++ b/pkg/stackbuild/stackbuild.go
@@ -96,6 +96,9 @@ func DetectStack(dir string, opts BuildOptions) (Stack, error) {
stacks := []Stack{
&RubyStack{MetaStack: ms},
&PythonStack{MetaStack: ms},
+ // Ahead of Bun/Node: a Phoenix app may carry a package.json for its
+ // assets, which the npm augmentation handles.
+ &ElixirStack{MetaStack: ms},
&BunStack{MetaStack: ms},
&NodeStack{MetaStack: ms},
&GoStack{MetaStack: ms},
@@ -389,15 +392,26 @@ func (h *highlevelBuilder) CacheMountFrom(path string, from llb.State) llb.RunOp
)
}
+// lockedCacheMount is CacheMount for tools that can't share a cache directory
+// with a concurrent run of themselves: BuildKit hands it to one step at a time.
+func (h *highlevelBuilder) lockedCacheMount(path string) llb.RunOption {
+ return llb.AddMount(path, llb.Scratch(),
+ llb.AsPersistentCacheDir(h.CacheNS+"-"+path, llb.CacheMountLocked),
+ )
+}
+
func (h *highlevelBuilder) Access(cur llb.State, path, into string) llb.RunOption {
return llb.AddMount(into, cur, llb.SourcePath(path), llb.Readonly)
}
func (h *highlevelBuilder) aptInstall(cur llb.State, pkgs ...string) llb.State {
+ // Locked, not shared: apt takes an exclusive lock on these directories, so
+ // two stages installing packages in parallel (a builder and its runtime
+ // image) would otherwise fail on each other's lock.
return cur.Run(
llb.Shlexf("sh -c 'apt-get update && apt-get install -y %s'", strings.Join(pkgs, " ")),
- h.CacheMount("/var/lib/apt/lists"),
- h.CacheMount("/var/cache/apt/archives"),
+ h.lockedCacheMount("/var/lib/apt/lists"),
+ h.lockedCacheMount("/var/cache/apt/archives"),
llb.WithCustomName("[phase] Installing OS packages"),
).State
}
diff --git a/pkg/stackbuild/testdata/elixir/config/config.exs b/pkg/stackbuild/testdata/elixir/config/config.exs
new file mode 100644
index 000000000..408906af6
--- /dev/null
+++ b/pkg/stackbuild/testdata/elixir/config/config.exs
@@ -0,0 +1,3 @@
+import Config
+
+config :hello, greeting: System.get_env("GREETING", "hi")
diff --git a/pkg/stackbuild/testdata/elixir/config/runtime.exs b/pkg/stackbuild/testdata/elixir/config/runtime.exs
new file mode 100644
index 000000000..c8a94bce7
--- /dev/null
+++ b/pkg/stackbuild/testdata/elixir/config/runtime.exs
@@ -0,0 +1,3 @@
+import Config
+
+config :hello, api_token: System.fetch_env!("API_TOKEN")
diff --git a/pkg/stackbuild/testdata/elixir/lib/hello.ex b/pkg/stackbuild/testdata/elixir/lib/hello.ex
new file mode 100644
index 000000000..eb38e96f5
--- /dev/null
+++ b/pkg/stackbuild/testdata/elixir/lib/hello.ex
@@ -0,0 +1,8 @@
+defmodule Hello.Application do
+ use Application
+
+ @impl true
+ def start(_type, _args) do
+ Supervisor.start_link([], strategy: :one_for_one, name: Hello.Supervisor)
+ end
+end
diff --git a/pkg/stackbuild/testdata/elixir/mix.exs b/pkg/stackbuild/testdata/elixir/mix.exs
new file mode 100644
index 000000000..6f9f95217
--- /dev/null
+++ b/pkg/stackbuild/testdata/elixir/mix.exs
@@ -0,0 +1,17 @@
+defmodule Hello.MixProject do
+ use Mix.Project
+
+ def project do
+ [
+ app: :hello,
+ version: "0.1.0",
+ elixir: "~> 1.15",
+ start_permanent: Mix.env() == :prod,
+ deps: []
+ ]
+ end
+
+ def application do
+ [extra_applications: [:logger], mod: {Hello.Application, []}]
+ end
+end
diff --git a/pkg/stackbuild/testdata/phoenix/config/config.exs b/pkg/stackbuild/testdata/phoenix/config/config.exs
new file mode 100644
index 000000000..fe7095447
--- /dev/null
+++ b/pkg/stackbuild/testdata/phoenix/config/config.exs
@@ -0,0 +1,71 @@
+# This file is responsible for configuring your application
+# and its dependencies with the aid of the Config module.
+#
+# This configuration file is loaded before any dependency and
+# is restricted to this project.
+
+# General application configuration
+import Config
+
+config :hopwatch,
+ ecto_repos: [Hopwatch.Repo],
+ generators: [timestamp_type: :utc_datetime]
+
+# Configure the endpoint
+config :hopwatch, HopwatchWeb.Endpoint,
+ url: [host: "localhost"],
+ adapter: Bandit.PhoenixAdapter,
+ render_errors: [
+ formats: [html: HopwatchWeb.ErrorHTML, json: HopwatchWeb.ErrorJSON],
+ layout: false
+ ],
+ pubsub_server: Hopwatch.PubSub,
+ live_view: [signing_salt: "rMAMCxVH"]
+
+# Configure LiveView
+config :phoenix_live_view,
+ # the attribute set on all root tags. Used for Phoenix.LiveView.ColocatedCSS.
+ root_tag_attribute: "phx-r"
+
+# Configure the mailer
+#
+# By default it uses the "Local" adapter which stores the emails
+# locally. You can see the emails in your browser, at "/dev/mailbox".
+#
+# For production it's recommended to configure a different adapter
+# at the `config/runtime.exs`.
+config :hopwatch, Hopwatch.Mailer, adapter: Swoosh.Adapters.Local
+
+# Configure esbuild (the version is required)
+config :esbuild,
+ version: "0.25.4",
+ hopwatch: [
+ args:
+ ~w(js/app.js --bundle --target=es2022 --outdir=../priv/static/assets/js --external:/fonts/* --external:/images/* --alias:@=.),
+ cd: Path.expand("../assets", __DIR__),
+ env: %{"NODE_PATH" => [Path.expand("../deps", __DIR__), Mix.Project.build_path()]}
+ ]
+
+# Configure tailwind (the version is required)
+config :tailwind,
+ version: "4.3.0",
+ hopwatch: [
+ args: ~w(
+ --input=assets/css/app.css
+ --output=priv/static/assets/css/app.css
+ ),
+ cd: Path.expand("..", __DIR__),
+ env: %{"NODE_PATH" => [Path.expand("../deps", __DIR__), Mix.Project.build_path()]}
+ ]
+
+# Configure Elixir's Logger
+config :logger, :default_formatter,
+ format: "$time $metadata[$level] $message\n",
+ metadata: [:request_id]
+
+# Use Jason for JSON parsing in Phoenix
+config :phoenix, :json_library, Jason
+
+# Import environment specific config. This must remain at the bottom
+# of this file so it overrides the configuration defined above.
+import_config "#{config_env()}.exs"
diff --git a/pkg/stackbuild/testdata/phoenix/config/dev.exs b/pkg/stackbuild/testdata/phoenix/config/dev.exs
new file mode 100644
index 000000000..bdd856c0f
--- /dev/null
+++ b/pkg/stackbuild/testdata/phoenix/config/dev.exs
@@ -0,0 +1,77 @@
+import Config
+
+# Configure your database
+config :hopwatch, Hopwatch.Repo,
+ username: "postgres",
+ password: "postgres",
+ hostname: "localhost",
+ database: "hopwatch_dev",
+ stacktrace: true,
+ show_sensitive_data_on_connection_error: true,
+ pool_size: 10
+
+# For development, we disable any cache and enable
+# debugging and code reloading.
+#
+# The watchers configuration can be used to run external
+# watchers to your application. For example, we can use it
+# to bundle .js and .css sources.
+config :hopwatch, HopwatchWeb.Endpoint,
+ # Binding to loopback ipv4 address prevents access from other machines.
+ # Change to `ip: {0, 0, 0, 0}` to allow access from other machines.
+ http: [ip: {127, 0, 0, 1}],
+ check_origin: false,
+ code_reloader: true,
+ debug_errors: true,
+ secret_key_base: "2BvXLX6PQdRlwdnG7zV1PFoBxFY7EzpX+gTi0I9hevAoBFwOfNmJ8bGwBsXpdppz",
+ watchers: [
+ esbuild: {Esbuild, :install_and_run, [:hopwatch, ~w(--sourcemap=inline --watch)]},
+ tailwind: {Tailwind, :install_and_run, [:hopwatch, ~w(--watch)]}
+ ]
+
+# ## SSL Support
+#
+# In order to use HTTPS in development, a self-signed
+# certificate can be generated by running the following
+# Mix task:
+#
+# mix phx.gen.cert
+#
+# Run `mix help phx.gen.cert` for more information.
+#
+# The `http:` config above can be replaced with:
+#
+# https: [
+# port: 4001,
+# cipher_suite: :strong,
+# keyfile: "priv/cert/selfsigned_key.pem",
+# certfile: "priv/cert/selfsigned.pem"
+# ],
+#
+# If desired, both `http:` and `https:` keys can be
+# configured to run both http and https servers on
+# different ports.
+
+# Enable dev routes for dashboard and mailbox
+config :hopwatch, dev_routes: true
+
+# Do not include metadata nor timestamps in development logs
+config :logger, :default_formatter, format: "[$level] $message\n"
+
+# Set a higher stacktrace during development. Avoid configuring such
+# in production as building large stacktraces may be expensive.
+config :phoenix, :stacktrace_depth, 20
+
+# Initialize plugs at runtime for faster development compilation
+config :phoenix, :plug_init_mode, :runtime
+
+config :phoenix_live_view,
+ # Include debug annotations and locations in rendered markup.
+ # Changing this configuration will require mix clean and a full recompile.
+ debug_heex_annotations: true,
+ debug_attributes: true,
+ # Enable helpful, but potentially expensive runtime checks
+ enable_expensive_runtime_checks: true
+
+# Disable swoosh api client as it is only required for production adapters.
+config :swoosh, :api_client, false
diff --git a/pkg/stackbuild/testdata/phoenix/config/runtime.exs b/pkg/stackbuild/testdata/phoenix/config/runtime.exs
new file mode 100644
index 000000000..f7f1dda16
--- /dev/null
+++ b/pkg/stackbuild/testdata/phoenix/config/runtime.exs
@@ -0,0 +1,137 @@
+import Config
+
+# config/runtime.exs is executed for all environments, including
+# during releases. It is executed after compilation and before the
+# system starts, so it is typically used to load production configuration
+# and secrets from environment variables or elsewhere. Do not define
+# any compile-time configuration in here, as it won't be applied.
+# The block below contains prod specific runtime configuration.
+
+# ## Using releases
+#
+# If you use `mix release`, you need to explicitly enable the server
+# by passing the PHX_SERVER=true when you start it:
+#
+# PHX_SERVER=true bin/hopwatch start
+#
+# Alternatively, you can use `mix phx.gen.release` to generate a `bin/server`
+# script that automatically sets the env var above.
+if System.get_env("PHX_SERVER") do
+ config :hopwatch, HopwatchWeb.Endpoint, server: true
+end
+
+config :hopwatch, HopwatchWeb.Endpoint,
+ http: [port: String.to_integer(System.get_env("PORT", "4000"))]
+
+if config_env() == :dev do
+ # Reload browser tabs when matching files change.
+ config :hopwatch, HopwatchWeb.Endpoint,
+ live_reload: [
+ web_console_logger: true,
+ patterns: [
+ # Static assets, except user uploads
+ ~r"priv/static/(?!uploads/).*\.(js|css|png|jpeg|jpg|gif|svg)$",
+ # Gettext translations
+ ~r"priv/gettext/.*\.po$",
+ # Router, Controllers, LiveViews and LiveComponents
+ ~r"lib/hopwatch_web/router\.ex$",
+ ~r"lib/hopwatch_web/(controllers|live|components)/.*\.(ex|heex)$"
+ ]
+ ]
+end
+
+if config_env() == :prod do
+ database_url =
+ System.get_env("DATABASE_URL") ||
+ raise """
+ environment variable DATABASE_URL is missing.
+ For example: ecto://USER:PASS@HOST/DATABASE
+ """
+
+ maybe_ipv6 = if System.get_env("ECTO_IPV6") in ~w(true 1), do: [:inet6], else: []
+
+ config :hopwatch, Hopwatch.Repo,
+ # ssl: true,
+ url: database_url,
+ pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10"),
+ # For machines with several cores, consider starting multiple pools of `pool_size`
+ # pool_count: 4,
+ socket_options: maybe_ipv6
+
+ # The secret key base is used to sign/encrypt cookies and other secrets.
+ # A default value is used in config/dev.exs and config/test.exs but you
+ # want to use a different value for prod and you most likely don't want
+ # to check this value into version control, so we use an environment
+ # variable instead.
+ secret_key_base =
+ System.get_env("SECRET_KEY_BASE") ||
+ raise """
+ environment variable SECRET_KEY_BASE is missing.
+ You can generate one by calling: mix phx.gen.secret
+ """
+
+ host = System.get_env("PHX_HOST") || "example.com"
+
+ config :hopwatch, :dns_cluster_query, System.get_env("DNS_CLUSTER_QUERY")
+
+ config :hopwatch, HopwatchWeb.Endpoint,
+ url: [host: host, port: 443, scheme: "https"],
+ http: [
+ # Enable IPv6 and bind on all interfaces.
+ # Set it to {0, 0, 0, 0, 0, 0, 0, 1} for local network only access.
+ # See the documentation on https://bandit.hexdocs.pm/Bandit.html#t:options/0
+ # for details about using IPv6 vs IPv4 and loopback vs public addresses.
+ ip: {0, 0, 0, 0, 0, 0, 0, 0}
+ ],
+ secret_key_base: secret_key_base
+
+ # ## SSL Support
+ #
+ # To get SSL working, you will need to add the `https` key
+ # to your endpoint configuration:
+ #
+ # config :hopwatch, HopwatchWeb.Endpoint,
+ # https: [
+ # ...,
+ # port: 443,
+ # cipher_suite: :strong,
+ # keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"),
+ # certfile: System.get_env("SOME_APP_SSL_CERT_PATH")
+ # ]
+ #
+ # The `cipher_suite` is set to `:strong` to support only the
+ # latest and more secure SSL ciphers. This means old browsers
+ # and clients may not be supported. You can set it to
+ # `:compatible` for wider support.
+ #
+ # `:keyfile` and `:certfile` expect an absolute path to the key
+ # and cert in disk or a relative path inside priv, for example
+ # "priv/ssl/server.key". For all supported SSL configuration
+ # options, see https://plug.hexdocs.pm/Plug.SSL.html#configure/1
+ #
+ # We also recommend setting `force_ssl` in your config/prod.exs,
+ # ensuring no data is ever sent via http, always redirecting to https:
+ #
+ # config :hopwatch, HopwatchWeb.Endpoint,
+ # force_ssl: [hsts: true]
+ #
+ # Check `Plug.SSL` for all available options in `force_ssl`.
+
+ # ## Configuring the mailer
+ #
+ # In production you need to configure the mailer to use a different adapter.
+ # Here is an example configuration for Mailgun:
+ #
+ # config :hopwatch, Hopwatch.Mailer,
+ # adapter: Swoosh.Adapters.Mailgun,
+ # api_key: System.get_env("MAILGUN_API_KEY"),
+ # domain: System.get_env("MAILGUN_DOMAIN")
+ #
+ # Most non-SMTP adapters require an API client. Swoosh supports Req, Hackney,
+ # and Finch out-of-the-box. This configuration is typically done at
+ # compile-time in your config/prod.exs:
+ #
+ # config :swoosh, :api_client, Swoosh.ApiClient.Req
+ #
+ # See https://swoosh.hexdocs.pm/Swoosh.html#module-installation for details.
+end
diff --git a/pkg/stackbuild/testdata/phoenix/mix.exs b/pkg/stackbuild/testdata/phoenix/mix.exs
new file mode 100644
index 000000000..de7daecda
--- /dev/null
+++ b/pkg/stackbuild/testdata/phoenix/mix.exs
@@ -0,0 +1,101 @@
+defmodule Hopwatch.MixProject do
+ use Mix.Project
+
+ def project do
+ [
+ app: :hopwatch,
+ version: "0.1.0",
+ elixir: "~> 1.17",
+ elixirc_paths: elixirc_paths(Mix.env()),
+ start_permanent: Mix.env() == :prod,
+ aliases: aliases(),
+ deps: deps(),
+ compilers: [:phoenix_live_view] ++ Mix.compilers(),
+ listeners: [Phoenix.CodeReloader]
+ ]
+ end
+
+ # Configuration for the OTP application.
+ #
+ # Type `mix help compile.app` for more information.
+ def application do
+ [
+ mod: {Hopwatch.Application, []},
+ extra_applications: [:logger, :runtime_tools]
+ ]
+ end
+
+ def cli do
+ [
+ preferred_envs: [precommit: :test]
+ ]
+ end
+
+ # Specifies which paths to compile per environment.
+ defp elixirc_paths(:test), do: ["lib", "test/support"]
+ defp elixirc_paths(_), do: ["lib"]
+
+ # Specifies your project dependencies.
+ #
+ # Type `mix help deps` for examples and options.
+ defp deps do
+ [
+ {:phoenix, "~> 1.8.14"},
+ {:phoenix_ecto, "~> 4.5"},
+ {:ecto_sql, "~> 3.13"},
+ {:postgrex, ">= 0.0.0"},
+ {:phoenix_html, "~> 4.1"},
+ {:phoenix_live_reload, "~> 1.2", only: :dev},
+ {:phoenix_live_view, "~> 1.2.0"},
+ {:lazy_html, ">= 0.1.0", only: :test},
+ {:phoenix_live_dashboard, "~> 0.8.3"},
+ {:esbuild, "~> 0.10", runtime: Mix.env() == :dev},
+ {:tailwind, "~> 0.5", runtime: Mix.env() == :dev},
+ {:heroicons,
+ github: "tailwindlabs/heroicons",
+ tag: "v2.2.0",
+ sparse: "optimized",
+ app: false,
+ compile: false,
+ depth: 1},
+ {:daisyui,
+ github: "saadeghi/daisyui",
+ tag: "v5.5.20",
+ sparse: "packages/bundle",
+ app: false,
+ compile: false,
+ depth: 1},
+ {:swoosh, "~> 1.16"},
+ {:req, "~> 0.5"},
+ {:telemetry_metrics, "~> 1.0"},
+ {:telemetry_poller, "~> 1.0"},
+ {:gettext, "~> 1.0"},
+ {:jason, "~> 1.2"},
+ {:dns_cluster, "~> 0.2.0"},
+ {:bandit, "~> 1.5"}
+ ]
+ end
+
+ # Aliases are shortcuts or tasks specific to the current project.
+ # For example, to install project dependencies and perform other setup tasks, run:
+ #
+ # $ mix setup
+ #
+ # See the documentation for `Mix` for more info on aliases.
+ defp aliases do
+ [
+ setup: ["deps.get", "ecto.setup", "assets.setup", "assets.build"],
+ "ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"],
+ "ecto.reset": ["ecto.drop", "ecto.setup"],
+ test: ["ecto.create --quiet", "ecto.migrate --quiet", "test"],
+ "assets.setup": ["tailwind.install --if-missing", "esbuild.install --if-missing"],
+ "assets.build": ["compile", "tailwind hopwatch", "esbuild hopwatch"],
+ "assets.deploy": [
+ "tailwind hopwatch --minify",
+ "esbuild hopwatch --minify",
+ "phx.digest"
+ ],
+ precommit: ["compile --warnings-as-errors", "deps.unlock --unused", "format", "test"]
+ ]
+ end
+end
diff --git a/pkg/stackbuild/testdata/phoenix/mix.lock b/pkg/stackbuild/testdata/phoenix/mix.lock
new file mode 100644
index 000000000..0490143aa
--- /dev/null
+++ b/pkg/stackbuild/testdata/phoenix/mix.lock
@@ -0,0 +1,46 @@
+%{
+ "bandit": {:hex, :bandit, "1.12.5", "af205a8e550f304caae09a97d29fd3c79a7f337526ea7cd772d2ff11d2f7c800", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.5", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "c5684ca062fa407cac115aec3256383f3e2ec9fdced7904d59cf5a7bb7ed6181"},
+ "cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"},
+ "daisyui": {:git, "https://github.com/saadeghi/daisyui.git", "22ecff57f2c391b80a75617325748cf4d13fdf47", [tag: "v5.5.20", sparse: "packages/bundle", depth: 1]},
+ "db_connection": {:hex, :db_connection, "2.10.2", "ae391e803a5adff104da913c2fc1c0c14a37f8b10001dcef568796e1fb7bf95c", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "510b14482330f1af6490a2fa0efd8d4f1435d1529b165647df22ac0f2df0fa93"},
+ "decimal": {:hex, :decimal, "3.1.1", "430d87b04011ce6cbd4fd205be758311a81f87d552d40904abd00f015935b1d0", [:mix], [], "hexpm", "c5f25f2ced74a0587d03e6023f595db8e924c9d3922c8c8ffd9edfc4498cf1f6"},
+ "dns_cluster": {:hex, :dns_cluster, "0.2.0", "aa8eb46e3bd0326bd67b84790c561733b25c5ba2fe3c7e36f28e88f384ebcb33", [:mix], [], "hexpm", "ba6f1893411c69c01b9e8e8f772062535a4cf70f3f35bcc964a324078d8c8240"},
+ "ecto": {:hex, :ecto, "3.14.2", "99db28a864293a789c970651de711e3cae184291e0e7ea1166c54055ac41c1f3", [:mix], [{:decimal, "~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "25d60b8c816a07d19d85b80bdf60978bd8b102209dda198d768cd7c6745339a6"},
+ "ecto_sql": {:hex, :ecto_sql, "3.14.0", "06446ab8410d2f85bfbb80857ee224ab3b693700cbb38f6535d507449a627b2e", [:mix], [{:db_connection, "~> 2.9", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.14.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.8", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f4d8d36faf294c9417b5a37ec7ac8217ee2abdef5fcf197ba690f361548d3949"},
+ "elixir_make": {:hex, :elixir_make, "0.10.0", "16577e2583a79bb79237bbff349619ef5d80afffc07eac6e4faf0d00e2ddaf7d", [:mix], [], "hexpm", "dc1f09fb7fa68866b886abd5f0f3c83553b1a19a52359a899e92af1bb3b31982"},
+ "esbuild": {:hex, :esbuild, "0.10.0", "b0aa3388a1c23e727c5a3e7427c932d89ee791746b0081bbe56103e9ef3d291f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "468489cda427b974a7cc9f03ace55368a83e1a7be12fba7e30969af78e5f8c70"},
+ "expo": {:hex, :expo, "1.1.1", "4202e1d2ca6e2b3b63e02f69cfe0a404f77702b041d02b58597c00992b601db5", [:mix], [], "hexpm", "5fb308b9cb359ae200b7e23d37c76978673aa1b06e2b3075d814ce12c5811640"},
+ "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"},
+ "finch": {:hex, :finch, "0.23.0", "e3f9287ac25a8832f848b144c2b57346aac65b205e2e0629a52adfe6507fd837", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.8", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "80e58d3f936f57e3fdf404f83a3642897ae6d9fb642934e46da4d8fe761b99d5"},
+ "fine": {:hex, :fine, "0.1.6", "4bf7151493443c454aac9f2fa2f34f5fefd0346a83fb5586a016c4a135c63247", [:mix], [], "hexpm", "5638eb4495488e885ebec167fa57973e5c35e1a50c344eb7666c90ec1c4e3b12"},
+ "gettext": {:hex, :gettext, "1.0.2", "5457e1fd3f4abe47b0e13ff85086aabae760497a3497909b8473e0acee57673b", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "eab805501886802071ad290714515c8c4a17196ea76e5afc9d06ca85fb1bfeb3"},
+ "heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "0435d4ca364a608cc75e2f8683d374e55abbae26", [tag: "v2.2.0", sparse: "optimized", depth: 1]},
+ "hpax": {:hex, :hpax, "1.1.0", "782931867cc23217c68fb5f68fe1a11f5e7544c7fda82c8a7019a5df5a4a1cdf", [:mix], [], "hexpm", "0b8d0f05832f55571d65ac720f79bf8994138ffbb133209dc4685eae0ad456a8"},
+ "idna": {:hex, :idna, "7.1.0", "1067a13043538129602d2f2ce6899d8713125c7d19734aa557ce2e3ea55bd4f1", [:rebar3], [], "hexpm", "6ae959a025bf36df61a8cab8508d9654891b5426a84c44d82deaffd6ddf8c71f"},
+ "jason": {:hex, :jason, "1.4.5", "2e3a008590b0b8d7388c20293e9dcc9cf3e5d642fd2a114e4cbbb52e595d940a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b0c823996102bcd0239b3c2444eb00409b72f6a140c1950bc8b457d836b30684"},
+ "lazy_html": {:hex, :lazy_html, "0.1.12", "31a55ee622918fce988c94b06232227b42daa64e4eab14ac32081d0f3fd8db6f", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.9", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}], "hexpm", "8a0da594776caee58782c6f93b2abaa5bdb809daf8d43351a561f7de9dc2e2a8"},
+ "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
+ "mint": {:hex, :mint, "1.10.1", "c53e70867cf74017716884d8d33e0742b08b32e9cdb0031cbc69a429dc5555e3", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "0ba2a904605ed8406393444fb8b3356dc58eb59ee6c7fb94ac3f015e1be129e8"},
+ "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
+ "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"},
+ "phoenix": {:hex, :phoenix, "1.8.14", "9279cbbcd755ac8d5f42d206915e7df13b9ab49096b98114f1c99ff908e5b42e", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 2.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "2782ff375824b2b5e41561fbae4764ee7b875af6898483bca49f24a9d1e37816"},
+ "phoenix_ecto": {:hex, :phoenix_ecto, "4.7.0", "75c4b9dfb3efdc42aec2bd5f8bccd978aca0651dbcbc7a3f362ea5d9d43153c6", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "1d75011e4254cb4ddf823e81823a9629559a1be93b4321a6a5f11a5306fbf4cc"},
+ "phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"},
+ "phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.7", "405880012cb4b706f26dd1c6349125bfc903fb9e44d1ea668adaf4e04d4884b7", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "3a8625cab39ec261d48a13b7468dc619c0ede099601b084e343968309bd4d7d7"},
+ "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.7.0", "fb1e429f6d8778ce3a6962debdc5e555428a05a6e7b058d6dbad13d281a2c31f", [:mix], [{:file_system, "~> 0.2.10 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "dc9f44271aa6fc4ab7797f2aa374ba096ef2c87520586280eb095626b7387a68"},
+ "phoenix_live_view": {:hex, :phoenix_live_view, "1.2.12", "35848150bbab579e9d0aff79525269d7c0801a4f61232b84f635c11222cee88f", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "656810d716e3369545dd63981196a5d68b77fdb253afe02ef0c6fa14cfd8dc2b"},
+ "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.3.0", "03916bfbc31a5121945b3cfffe5aec647a5c97fe1dc172a319b94428562359c9", [:mix], [], "hexpm", "eec7be6e9cf02e2551d389b558402d6c637cd3973796326e7ba4bb03c6b2e91d"},
+ "phoenix_template": {:hex, :phoenix_template, "1.1.0", "b329582281b1e00e4dc664afd60ef5b489cbb356923caa3b1e00abe8651b9a18", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "eba70070de79b2c3501ef205a74a69f98ab352f3785aa15da9ed161f9fe0fd5d"},
+ "plug": {:hex, :plug, "1.20.3", "56c480c633ec2ce10140e236e15233bf576e1d323887d7c96711bd02ab5160db", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "be266aee1b8536ef6409d58cf39a3121319f0ec47cfa1b24024485aa0e76ad76"},
+ "plug_crypto": {:hex, :plug_crypto, "2.2.0", "144014737daaf485407f5ed77daeaad74d651b216a28c87543f8cc7043f8efc8", [:mix], [], "hexpm", "83a95744ab1c75876542b6fab135fcc176280e0f301a111c1f757fddcec95d2c"},
+ "postgrex": {:hex, :postgrex, "0.22.4", "d271f595dfd25230b6398354e19d17bb5e2d20130fd2d9bdca7e15f125d43552", [:mix], [{:db_connection, "~> 2.9", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "4aae45a2d60e35b04eea2602440be152fae332901f1fc7a60fc7cb7f0f9a9c5a"},
+ "req": {:hex, :req, "0.7.4", "23e9ffec17de032a46a4b15ed65c09793893bf4a7c680f4bbf6227fce6bdf74d", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:finch, "~> 0.21", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "4b192d63253e8dcc6221ef992ea9ebef7d3555166e8423aa5b553e86bc3c69a2"},
+ "swoosh": {:hex, :swoosh, "1.28.1", "1a836e6a938a93bd2dbd99734267bda876894473e76b510ece20e5681510ceb1", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, ">= 1.9.0 and < 5.0.0", [hex: :hackney, repo: "hexpm", optional: true]}, {:idna, ">= 6.0.0 and < 8.0.0", [hex: :idna, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "d84e08c445f70617afd3a31633db2ea02ceabdfec482bde4b824da63b8150bdc"},
+ "tailwind": {:hex, :tailwind, "0.5.1", "35435b13158c90d37da11e1cfc808755fca1d7b6c5ab87b1b19c5de87e2f0a10", [:mix], [], "hexpm", "c4e26302a59fec72abc5610ecb6ad2116d9aa31f31aab2d4b8eb6e95d25a689c"},
+ "telemetry": {:hex, :telemetry, "1.4.2", "a0cb522801dffb1c49fe6e30561badffc7b6d0e180db1300df759faa22062855", [:rebar3], [], "hexpm", "928f6495066506077862c0d1646609eed891a4326bee3126ba54b60af61febb1"},
+ "telemetry_metrics": {:hex, :telemetry_metrics, "1.2.0", "7632c19c01d88d8aaca5da1a0e8912f5af39b79e7c08a2c253aeb3c14c2c957e", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "71dde12fc29b58b9c77ec17ec319109e5ca848d010fc1965ed4463bba1837c07"},
+ "telemetry_poller": {:hex, :telemetry_poller, "1.3.0", "d5c46420126b5ac2d72bc6580fb4f537d35e851cc0f8dbd571acf6d6e10f5ec7", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "51f18bed7128544a50f75897db9974436ea9bfba560420b646af27a9a9b35211"},
+ "thousand_island": {:hex, :thousand_island, "1.5.0", "f50a213cac97262b6d5ebb85745aa2c00fec1413191e6e66834788d45425cecb", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "708923d40523e43cf99041ab37a0d4b0ec426ac6438fa3716ab23d919eaeb412"},
+ "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},
+ "websock_adapter": {:hex, :websock_adapter, "0.6.0", "73db5ab8aaefd1a876a97ce3e6afc96562625de69ef17a4e04426e034849d0b8", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "50021a85bce8f203b086705d9e0c5415e2c7eb05d319111b0428fe71f9934617"},
+}