From c8f7f6489bb2b4b1acf8e4dcdbb6a4d6d1a8b216 Mon Sep 17 00:00:00 2001 From: Neil Galvin Date: Tue, 25 Aug 2026 17:24:31 +0100 Subject: [PATCH] feat(elixir): add run-prod-compile to catch production-build breakage at PR time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Mix release evaluates config/config.exs at BUILD time with config_env() == :prod — a path the normal dev/test compile never exercises. A missing config/prod.exs, a broken import_config, or code that only compiles under dev/test therefore passes CI green and fails when the image is built. Found the hard way in a consumer repo: its Docker image had never built, not once. Every merge to main for months died on ** (File.Error) could not read file "/app/config/prod.exs" while every PR went green. The image build is gated `if: github.ref == 'refs/heads/main'` — a sound pattern, it keeps PR-controlled Dockerfiles off the self-hosted runners — but the consequence is that this class of failure always lands post-merge, on a diff that already passed review, in a job sitting at the end of the run behind `needs:`. The one place it surfaced was the one place nobody was looking. `MIX_ENV=prod mix compile` closes that gap for a fraction of the cost of a full image build, and would have caught this exact bug on the PR that introduced it. Deliberately not --warnings-as-errors: the question here is whether the production build works, not warning hygiene, and a prod-only warning in a dependency should not red-line a consumer. `_build` is already cached, so the extra prod tree costs a full compile only when the lockfile changes. Added to the `ci` job only, not `test-db` — the two would be redundant, and test-db's job exists to run the suite against services. Off by default, consistent with the other opt-in gates. actionlint clean. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01YEMvdSY4vBpcbE2cP1MP7A --- .github/workflows/elixir.yml | 34 ++++++++++++++++++++++++++++++++++ CHANGELOG.md | 30 ++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+) diff --git a/.github/workflows/elixir.yml b/.github/workflows/elixir.yml index 47d3fd2..9a248aa 100644 --- a/.github/workflows/elixir.yml +++ b/.github/workflows/elixir.yml @@ -66,6 +66,29 @@ on: itself queries. Pair it with run-hex-advisory-check. type: boolean default: false + run-prod-compile: + description: | + Additionally compile with `MIX_ENV=prod`, to catch breakage that only + exists in the production build. + + Worth turning on for anything that ships a Mix release. A release + evaluates `config/config.exs` at *build* time under `MIX_ENV=prod`, + which is a path the normal dev/test compile never touches — a missing + `config/prod.exs`, a bad `import_config`, or code that only compiles + under dev/test all pass CI and then fail when the image is built. + + Where a repo builds its image on `main` only (a common pattern, to + keep PR-controlled Dockerfiles off self-hosted runners), that failure + lands *after* merge, so `main` goes red on a diff that already passed + review. This check moves it to PR time for a fraction of the cost of + a full image build. + + Not `--warnings-as-errors`: the goal is "does the production build + work", not warning hygiene, and prod-only warnings in a dependency + shouldn't red-line a consumer. `_build` is already cached, so the + extra prod tree costs a full compile only when the lockfile changes. + type: boolean + default: false run-hex-advisory-check: description: | Fail the build when Hex's own resolver reports a security advisory @@ -212,6 +235,17 @@ jobs: - name: Compile (warnings as errors) run: mix compile --warnings-as-errors + - name: Compile (MIX_ENV=prod) + # Exercises the production build path: config.exs is re-evaluated with + # config_env() == :prod, so a missing config/prod.exs or a broken + # import_config fails here rather than in a post-merge image build. + if: inputs.run-prod-compile + env: + MIX_ENV: prod + run: | + mix deps.get + mix compile + - name: Format check if: inputs.run-format-check run: mix format --check-formatted diff --git a/CHANGELOG.md b/CHANGELOG.md index e2197de..266bf86 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,36 @@ project uses [SemVer](https://semver.org/) for the `vMAJOR.MINOR.PATCH` tags. ## [Unreleased] +### Added + +- `elixir.yml` — `run-prod-compile`, which additionally compiles with + `MIX_ENV=prod`. + + A Mix release evaluates `config/config.exs` at *build* time under + `config_env() == :prod`, a path the normal dev/test compile never touches. + So a missing `config/prod.exs`, a broken `import_config`, or code that only + compiles under dev/test passes CI green and then fails when the image is + built. + + Found in a consumer repo where the image build had **never** succeeded — + every merge to `main` for months failed with + `could not read file "/app/config/prod.exs"`, while every PR was green. The + image build was gated `if: github.ref == 'refs/heads/main'` (a reasonable + pattern, keeping PR-controlled Dockerfiles off self-hosted runners), so the + failure always landed post-merge on a diff that had already passed review, + at the end of the run behind `needs:`. Nothing was watching the one place it + showed up. + + This check moves that class of failure to PR time for a fraction of the cost + of a full image build. Deliberately not `--warnings-as-errors` — the + question is whether the production build *works*, not warning hygiene, and a + prod-only warning in a dependency shouldn't red-line a consumer. `_build` is + already cached, so the extra prod tree costs a full compile only when the + lockfile changes. + + Off by default, like the other opt-in gates. Worth enabling for anything + that ships a release. + ## [2.16.0] - 2026-08-24 ### Added