diff --git a/.github/workflows/update_lockfiles.yml b/.github/workflows/update_lockfiles.yml
index d1faff4a..583187aa 100644
--- a/.github/workflows/update_lockfiles.yml
+++ b/.github/workflows/update_lockfiles.yml
@@ -13,7 +13,10 @@ jobs:
update-lockfiles:
name: Refresh mix.lock files
runs-on: ubuntu-latest
- timeout-minutes: 15
+ # The smart bump runs the full test suite (including integration tests) to verify
+ # each update, and may bisect dependency-by-dependency on failure, so it needs a
+ # much larger budget than a blunt `mix deps.update --all`.
+ timeout-minutes: 120
steps:
- name: Get auth token
id: token
@@ -33,13 +36,33 @@ jobs:
elixir-version: "1.18"
otp-version: "27.2"
- - name: Refresh lockfiles
+ - name: Install dependencies
+ run: mix deps.get
+
+ - name: Bump lockfiles
+ id: bump
run: |
- for lock in mix.lock test_integrations/*/mix.lock; do
- dir=$(dirname "$lock")
- echo "==> Refreshing mix.lock in $dir"
- (cd "$dir" && mix deps.update --all)
- done
+ # Carefully bump patch/minor versions, verifying each against the full test
+ # suite. Only updates that keep everything green are written to the lockfiles;
+ # major bumps and anything that breaks tests are held back and reported.
+ #
+ # No --keep-going on purpose: if the task can't assemble a green set of
+ # lockfiles it exits non-zero and fails this step, so no PR is opened with a
+ # broken set. The report and logs are still published by the always() upload
+ # step below, so a failed week is visible via the workflow artifact.
+ mix sentry.bump_lockfiles --output-dir tmp/lockfile-bump --timeout 600
+
+ # The run directory is timestamped; expose the newest one to later steps.
+ run_dir=$(ls -dt tmp/lockfile-bump/run-* | head -1)
+ echo "run_dir=$run_dir" >> "$GITHUB_OUTPUT"
+
+ - name: Upload bump report and logs
+ if: always()
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
+ with:
+ name: lockfile-bump-report
+ path: tmp/lockfile-bump/run-*
+ if-no-files-found: ignore
- name: Configure git
run: |
@@ -76,20 +99,60 @@ jobs:
env:
BRANCH_NAME: ${{ steps.create-branch.outputs.branch_name }}
COMMIT_TITLE: ${{ steps.create-branch.outputs.commit_title }}
+ RUN_DIR: ${{ steps.bump.outputs.run_dir }}
with:
github-token: ${{ steps.token.outputs.token }}
script: |
+ const fs = require('fs');
const branchName = process.env.BRANCH_NAME;
const commitTitle = process.env.COMMIT_TITLE;
- const prBody = `Automated weekly refresh of the committed \`mix.lock\` files via \`mix deps.update --all\`, keeping dependency pins current with the latest security patches allowed by each project's version constraints.
-
- #skip-changelog
- ## Action required
- - If CI passes on this PR, it's safe to approve and merge.
- - If CI fails, a dependency update broke something — investigate before merging.
+ // Build a per-project summary from the bump report.
+ let summary = '';
+ try {
+ const report = JSON.parse(fs.readFileSync(`${process.env.RUN_DIR}/report.json`, 'utf8'));
+ const s = report.summary;
+ summary += `**Status:** \`${report.overall_status}\` — bumped ${s.bumped}, skipped (major) ${s.skipped_major}, failed ${s.failed}\n\n`;
+
+ for (const p of report.projects) {
+ if (!p.bumped.length && !p.skipped_major.length && !p.failed.length) continue;
+ summary += `${p.name} — ${p.status}
\n\n`;
+ if (p.bumped.length) {
+ summary += `**Bumped**\n`;
+ for (const b of p.bumped) summary += `- \`${b.dep}\` ${b.from} → ${b.to}\n`;
+ summary += `\n`;
+ }
+ if (p.skipped_major.length) {
+ summary += `**Held back (would cross a major boundary)**\n`;
+ for (const sk of p.skipped_major) summary += `- \`${sk.dep}\` ${sk.from} → ${sk.to} (${sk.reason})\n`;
+ summary += `\n`;
+ }
+ if (p.failed.length) {
+ summary += `**Failed verification**\n`;
+ for (const f of p.failed) summary += `- \`${f.dep}\` ${f.from} → ${f.to} — ${f.failure_type} (log: \`${f.log_file}\`)\n`;
+ summary += `\n`;
+ }
+ summary += ` \n\n`;
+ }
+ } catch (e) {
+ summary = `_Could not read bump report: ${e.message}_\n\n`;
+ }
- _🤖 Automatically created by [.github/workflows/update_lockfiles.yml](https://github.com/getsentry/sentry-elixir/blob/master/.github/workflows/update_lockfiles.yml)._`.replace(/^ {12}/gm, '');
+ const prBody = [
+ "Automated weekly lockfile update produced by `mix sentry.bump_lockfiles`.",
+ "",
+ "Only patch/minor bumps that keep the full test suite (including integration tests) green are written to the lockfiles. Major bumps — and anything that broke the build or tests — are held back and listed below as candidates for manual follow-up.",
+ "",
+ "#skip-changelog",
+ "",
+ "## Summary",
+ summary,
+ "## Details",
+ "- Per-step logs and the full JSON report are attached as the **lockfile-bump-report** workflow artifact.",
+ "- CI on this PR re-validates the changes; if it passes it's safe to approve and merge.",
+ "",
+ "_🤖 Automatically created by [.github/workflows/update_lockfiles.yml](https://github.com/getsentry/sentry-elixir/blob/master/.github/workflows/update_lockfiles.yml)._",
+ ].join("\n");
// Close superseded lockfile PRs — they're now obsolete.
const existingPRs = await github.paginate(github.rest.pulls.list, {
diff --git a/lib/mix/tasks/sentry.bump_lockfiles.ex b/lib/mix/tasks/sentry.bump_lockfiles.ex
new file mode 100644
index 00000000..6ea0742c
--- /dev/null
+++ b/lib/mix/tasks/sentry.bump_lockfiles.ex
@@ -0,0 +1,278 @@
+defmodule Mix.Tasks.Sentry.BumpLockfiles do
+ @shortdoc "Carefully bumps dependency lockfiles, keeping only changes that pass tests"
+
+ @moduledoc """
+ Carefully bumps the project's `mix.lock` files, keeping only the dependency updates
+ that are proven not to break compilation or tests.
+
+ The weekly `update_lockfiles` GitHub workflow refreshes every `mix.lock` with a blunt
+ `mix deps.update --all`, which can silently introduce a breaking dependency bump. This
+ task does the same refresh, but *gradually and safely*: it never crosses a major
+ version by default, validates against the full test suite (including integration
+ tests), and emits a JSON report naming exactly which dependencies were bumped, which
+ were skipped as major, and which broke the build or the tests.
+
+ ## How it works
+
+ 1. **Snapshot** every lockfile so any step can be reverted.
+ 2. **Optimistic phase** — apply every policy-allowed bump at once and run the full
+ suite. If it's green, we're done.
+ 3. **Gradual phase** (only on failure) — revert, then bump one dependency at a time,
+ running the full suite after each. A bump is kept only if everything stays green,
+ so the assembled lockfiles are always a validated, passing set, and the dependency
+ responsible for any breakage is identified.
+ 4. **Artifacts** — write a self-contained run directory and print a colored summary.
+
+ ## Run artifacts
+
+ Each run creates a timestamped directory under `--output-dir` (default
+ `tmp/lockfile-bump`) so results are easy to inspect and turn into follow-up tasks:
+
+ ```text
+ tmp/lockfile-bump/
+ latest -> run- # symlink to the most recent run
+ run-/
+ report.json # full structured report
+ locks/ # the verified mix.lock files (used by --apply)
+ logs/
+ discover-.log # output of `deps.update --all` per project
+ optimistic.log # the optimistic bump-everything validation
+ gradual-.log # full deps.get/compile/test output per attempted bump
+ final.log # the final full validation
+ ```
+
+ Each failed dependency in `report.json` references its `log_file` so you can jump
+ straight to the relevant test output.
+
+ ## Lockfiles
+
+ Bumps the root `mix.lock` and the `mix.lock` of each integration project that the
+ `test.integrations` suite validates: `prod_mode`, `umbrella`, `phoenix_app`, and
+ `legacy_otel`. The `tracing` integration is intentionally excluded — it relies on a
+ Playwright end-to-end suite that is not part of this task's validation oracle, so its
+ lockfile is left untouched.
+
+ ## Major versions
+
+ By default no bump crosses a major boundary. Following semver, a `0.x` minor bump
+ (e.g. `0.20 -> 0.21`) is also treated as breaking. Use `--allow-major` or
+ `--allow-major-for` to opt in.
+
+ ## Performance
+
+ Best case (everything passes optimistically) runs the suite once. The gradual phase
+ runs the full suite per attempted dependency, which can be slow — scope it down with
+ `--only`, `--projects`, or `--skip-integrations` when iterating locally.
+
+ ## Usage
+
+ ```shell
+ mix sentry.bump_lockfiles
+ mix sentry.bump_lockfiles --skip-integrations --output-dir tmp/bumps
+ mix sentry.bump_lockfiles --allow-major-for opentelemetry,opentelemetry_api
+ mix sentry.bump_lockfiles --apply tmp/lockfile-bump/latest
+ ```
+
+ ## Options
+
+ * `--allow-major` - allow bumps that cross a major boundary
+ * `--allow-major-for dep1,dep2` - allow major bumps only for the listed dependencies
+ * `--strict-0x` / `--no-strict-0x` - treat `0.x` minor bumps as breaking (default: true)
+ * `--only dep1,dep2` - only consider the listed dependencies
+ * `--projects root,phoenix_app` - only operate on the listed projects
+ * `--skip-integrations` - only bump and validate the root project
+ * `--output-dir DIR` - base directory for run artifacts (default: `tmp/lockfile-bump`).
+ Each run creates a timestamped subdirectory; see "Run artifacts" above.
+ * `--apply PATH` - skip discovery/verification and apply the *exact* verified
+ lockfiles captured by a previous run (e.g. one produced by an earlier `--dry-run`).
+ `PATH` is a run directory (or its `report.json`); this copies the captured
+ lockfiles back verbatim and runs `mix deps.get`. No tests are re-run. Combine with
+ `--projects` to apply to a subset.
+ * `--dry-run` - run everything but restore the original lockfiles afterward
+ * `--timeout SECONDS` - hard cap per test run; the run is killed and recorded as a failure
+ * `--verbose` - stream subprocess output live
+ * `--no-final-check` - skip the final full validation of the assembled lockfiles
+ * `--keep-going` - always exit 0 (report-only); by default a `failed` result exits non-zero
+
+ """
+
+ @moduledoc since: "13.3.0"
+
+ use Mix.Task
+
+ alias Sentry.Dev.Applier
+ alias Sentry.Dev.Bumper
+ alias Sentry.Dev.Report
+
+ @switches [
+ allow_major: :boolean,
+ allow_major_for: :string,
+ strict_0x: :boolean,
+ only: :string,
+ projects: :string,
+ skip_integrations: :boolean,
+ output_dir: :string,
+ apply: :string,
+ dry_run: :boolean,
+ timeout: :integer,
+ verbose: :boolean,
+ no_final_check: :boolean,
+ keep_going: :boolean
+ ]
+
+ @default_output_dir "tmp/lockfile-bump"
+
+ @impl true
+ def run(args) do
+ {opts, _args} = OptionParser.parse!(args, strict: @switches)
+
+ case opts[:apply] do
+ nil -> bump(opts)
+ path -> apply_report(path, opts)
+ end
+ end
+
+ defp apply_report(path, opts) do
+ report_file = report_path(path)
+ report = report_file |> Report.read() |> relocate_locks_dir(report_file)
+ summary = Applier.apply(report, opts)
+ Applier.print_summary(summary)
+
+ if not summary.all_applied? and not opts[:keep_going] do
+ Mix.shell().error("Some lockfiles could not be applied. See above.")
+ exit({:shutdown, 1})
+ end
+ end
+
+ # `--apply` accepts either a run directory or a direct path to its report.json.
+ defp report_path(path) do
+ if File.dir?(path), do: Path.join(path, "report.json"), else: path
+ end
+
+ # The verified lockfiles are always a sibling of the report (`/locks` next to
+ # `/report.json`), but the report stores `verified_locks_dir` relative to the
+ # bump-time cwd. Re-anchor it to the report's actual directory so `--apply` works from
+ # any working directory and on a run directory that was moved or downloaded (e.g. the
+ # CI artifact), rather than only from the original cwd with `tmp/` still in place.
+ @doc false
+ def relocate_locks_dir(report, report_file) do
+ case report["verified_locks_dir"] do
+ nil ->
+ report
+
+ locks_dir ->
+ relocated = Path.join(Path.dirname(report_file), Path.basename(locks_dir))
+ Map.put(report, "verified_locks_dir", relocated)
+ end
+ end
+
+ defp bump(opts) do
+ run_dir = new_run_dir(opts)
+ logs_dir = Path.join(run_dir, "logs")
+ locks_dir = Path.join(run_dir, "locks")
+ File.mkdir_p!(logs_dir)
+ backups = if opts[:dry_run], do: backup_lockfiles(), else: nil
+
+ report =
+ try do
+ result = opts |> Keyword.put(:logs_dir, logs_dir) |> Bumper.run()
+ # Capture the verified lockfiles (on disk now) so they can be applied later,
+ # before any dry-run restore reverts the working tree.
+ save_verified_locks(locks_dir)
+
+ result
+ |> Report.build()
+ |> Map.merge(%{
+ run_dir: Path.relative_to_cwd(run_dir),
+ logs_dir: Path.relative_to_cwd(logs_dir),
+ verified_locks_dir: Path.relative_to_cwd(locks_dir)
+ })
+ after
+ if backups, do: restore_lockfiles(backups)
+ end
+
+ Report.write(report, Path.join(run_dir, "report.json"))
+ Report.print_summary(report)
+ _ = update_latest(run_dir)
+
+ Mix.shell().info([
+ "\nRun artifacts written to ",
+ :cyan,
+ Path.relative_to_cwd(run_dir),
+ :reset
+ ])
+
+ Mix.shell().info([
+ "Apply the verified lockfiles later with: ",
+ :bright,
+ "mix sentry.bump_lockfiles --apply #{Path.relative_to_cwd(run_dir)}",
+ :reset
+ ])
+
+ if report.overall_status == "failed" and not opts[:keep_going] do
+ Mix.shell().error(
+ "Could not assemble a green set of lockfiles. See the report for details."
+ )
+
+ exit({:shutdown, 1})
+ end
+ end
+
+ defp new_run_dir(opts) do
+ base = Keyword.get(opts, :output_dir, @default_output_dir)
+ timestamp = DateTime.utc_now() |> DateTime.to_iso8601() |> String.replace(":", "-")
+ Path.join(base, "run-#{timestamp}")
+ end
+
+ # Best-effort `latest` symlink pointing at the most recent run, so `--apply /latest`
+ # works without knowing the timestamp. Tolerates filesystems that disallow symlinks.
+ defp update_latest(run_dir) do
+ latest = Path.join(Path.dirname(run_dir), "latest")
+ _ = File.rm(latest)
+ File.ln_s(Path.basename(run_dir), latest)
+ rescue
+ _ -> :ok
+ end
+
+ defp lockfiles do
+ Path.wildcard("mix.lock") ++ Path.wildcard("test_integrations/*/mix.lock")
+ end
+
+ defp save_verified_locks(locks_dir) do
+ Enum.each(lockfiles(), fn lock ->
+ dest = Path.join(locks_dir, lock)
+ File.mkdir_p!(Path.dirname(dest))
+ File.cp!(lock, dest)
+ end)
+ end
+
+ defp backup_lockfiles do
+ Map.new(lockfiles(), fn lock ->
+ backup = Path.join(System.tmp_dir!(), "sentry_bump_dryrun_#{:erlang.phash2(lock)}.lock")
+ File.cp!(lock, backup)
+ {lock, backup}
+ end)
+ end
+
+ defp restore_lockfiles(backups) do
+ Enum.each(backups, fn {lock, backup} ->
+ File.cp!(backup, lock)
+ _ = File.rm(backup)
+
+ # Reconcile the on-disk deps with the restored lock, otherwise the next compile
+ # fails with a lock mismatch because the run fetched newer versions. This runs in
+ # the dry-run cleanup path; a failure here can't abort the already-finished run,
+ # but it must not pass silently — warn loudly with the real reason.
+ case Sentry.Dev.Cmd.mix(Path.dirname(lock), ["deps.get"]) do
+ {:ok, _output} ->
+ :ok
+
+ {:error, status, output} ->
+ Mix.shell().error(
+ "Warning: could not reconcile #{lock} after the dry-run restore (exit #{status}). " <>
+ "You may need to run `mix deps.get` manually.\n#{output}"
+ )
+ end
+ end)
+ end
+end
diff --git a/lib/sentry/dev.ex b/lib/sentry/dev.ex
new file mode 100644
index 00000000..360e5beb
--- /dev/null
+++ b/lib/sentry/dev.ex
@@ -0,0 +1,21 @@
+defmodule Sentry.Dev do
+ @moduledoc """
+ Shared helpers for the `mix sentry.bump_lockfiles` dev tooling (the `Sentry.Dev.*`
+ modules).
+
+ This module is dev/CI tooling and is not part of the public API.
+ """
+
+ @moduledoc since: "13.3.0"
+
+ @doc """
+ Splits a comma-separated CLI option string into a trimmed list of values.
+
+ `nil` (an unset option) becomes `[]`.
+ """
+ @spec csv(String.t() | nil) :: [String.t()]
+ def csv(nil), do: []
+
+ def csv(str) when is_binary(str),
+ do: str |> String.split(",", trim: true) |> Enum.map(&String.trim/1)
+end
diff --git a/lib/sentry/dev/applier.ex b/lib/sentry/dev/applier.ex
new file mode 100644
index 00000000..cdb75b3f
--- /dev/null
+++ b/lib/sentry/dev/applier.ex
@@ -0,0 +1,108 @@
+defmodule Sentry.Dev.Applier do
+ @moduledoc """
+ Applies the verified lockfiles captured by a prior `mix sentry.bump_lockfiles` run.
+
+ A bump run (including a `--dry-run`) saves the exact verified `mix.lock` files to a
+ sidecar directory next to the report. This module copies those lockfiles back into the
+ working tree verbatim and runs `mix deps.get` to materialize them — no tests are
+ re-run and nothing is re-resolved, so the applied versions are precisely the ones that
+ were verified.
+
+ Re-resolving from the report's version list is deliberately *not* used: updating a
+ subset of dependencies (`mix deps.update `) can pick different versions than the
+ full run did, so only the captured lockfiles reproduce the verified set exactly.
+
+ This module is dev/CI tooling and is not part of the public API.
+ """
+
+ @moduledoc since: "13.3.0"
+
+ alias Sentry.Dev
+ alias Sentry.Dev.Cmd
+
+ @doc """
+ Applies the verified lockfiles referenced by `report` to the working tree.
+
+ Honors `:projects` (a CSV string of project names to restrict to). Returns a summary
+ map with one entry per applied project.
+ """
+ @spec apply(map(), keyword()) :: map()
+ def apply(report, opts) do
+ locks_dir = report["verified_locks_dir"] || raise_no_locks()
+ only = Dev.csv(opts[:projects])
+
+ results =
+ report["projects"]
+ |> filter_projects(only)
+ |> Enum.map(&apply_project(&1, locks_dir))
+
+ %{
+ source_report: report["generated_at"],
+ projects: results,
+ all_applied?: Enum.all?(results, &(&1.status == "applied"))
+ }
+ end
+
+ defp apply_project(project, locks_dir) do
+ name = project["name"]
+ dir = project["dir"]
+ src = Path.join(locks_dir, Path.join(dir, "mix.lock"))
+ bumped = length(project["bumped"])
+
+ if File.exists?(src) do
+ File.cp!(src, Path.join(dir, "mix.lock"))
+
+ case Cmd.mix(dir, ["deps.get"], env: [{"MIX_ENV", project["mix_env"]}]) do
+ {:ok, _output} ->
+ %{name: name, status: "applied", bumped: bumped}
+
+ {:error, status, output} ->
+ # The lockfile was copied but `deps.get` could not materialize it. Don't report
+ # this as "applied" — surface it so the failure isn't silently swallowed.
+ %{
+ name: name,
+ status: "deps_get_failed",
+ bumped: bumped,
+ error: "exit #{status}\n#{output}"
+ }
+ end
+ else
+ %{name: name, status: "missing_lock", bumped: bumped}
+ end
+ end
+
+ defp filter_projects(projects, []), do: projects
+ defp filter_projects(projects, only), do: Enum.filter(projects, &(&1["name"] in only))
+
+ @spec raise_no_locks() :: no_return()
+ defp raise_no_locks do
+ Mix.raise(
+ "The report has no captured lockfiles (\"verified_locks_dir\"). Re-run the bump " <>
+ "to produce one, then apply it."
+ )
+ end
+
+ @doc """
+ Prints a human-readable summary of an apply run.
+ """
+ @spec print_summary(map()) :: :ok
+ def print_summary(summary) do
+ shell = Mix.shell()
+ shell.info("\n" <> String.duplicate("─", 60))
+ shell.info([:bright, "Applied verified lockfiles", :reset])
+
+ Enum.each(summary.projects, fn project ->
+ color = if project.status == "applied", do: :green, else: :red
+
+ shell.info([
+ " ",
+ color,
+ "#{project.name}: #{project.status} (#{project.bumped} bump(s))",
+ :reset
+ ])
+ end)
+
+ shell.info(String.duplicate("─", 60))
+ :ok
+ end
+end
diff --git a/lib/sentry/dev/bumper.ex b/lib/sentry/dev/bumper.ex
new file mode 100644
index 00000000..11b1dbc3
--- /dev/null
+++ b/lib/sentry/dev/bumper.ex
@@ -0,0 +1,519 @@
+defmodule Sentry.Dev.Bumper do
+ @moduledoc """
+ Orchestrates the careful lockfile bump for `mix sentry.bump_lockfiles`.
+
+ The strategy is optimistic-first: bump every policy-allowed dependency at once and
+ run the full test suite. If that is green we are done. Otherwise we revert and bump
+ one dependency at a time (cumulative greedy), running the full suite after each, so
+ the assembled lockfiles are always a validated, passing set and we can attribute the
+ breakage to a specific dependency.
+
+ This module is dev/CI tooling and is not part of the public API.
+ """
+
+ @moduledoc since: "13.3.0"
+
+ alias Sentry.Dev
+ alias Sentry.Dev.Cmd
+ alias Sentry.Dev.Lockfile
+ alias Sentry.Dev.VersionPolicy
+
+ @integrations [
+ %{name: "prod_mode", subdir: "prod_mode", mix_env: "prod"},
+ %{name: "umbrella", subdir: "umbrella", mix_env: "test"},
+ %{name: "phoenix_app", subdir: "phoenix_app", mix_env: "test"},
+ %{name: "legacy_otel", subdir: "legacy_otel", mix_env: "test"}
+ ]
+
+ @log_snippet_bytes 6_000
+
+ @doc """
+ Runs the full bump and returns the data needed to build the JSON report.
+
+ Test-only options:
+
+ * `:runner` - a `fun(dir, args, cmd_opts) -> Cmd.mix/3 result` used in place of
+ `Sentry.Dev.Cmd.mix/3`, so the orchestration can be exercised without shelling out.
+ * `:base_dir` - directory the projects are resolved against (default `"."`), so a
+ run can be pointed at a temporary fixture tree instead of the real repo.
+ """
+ @spec run(keyword()) :: map()
+ def run(opts) do
+ vp_opts =
+ VersionPolicy.opts(
+ allow_major: opts[:allow_major] || false,
+ allow_major_for: Dev.csv(opts[:allow_major_for]),
+ strict_0x: Keyword.get(opts, :strict_0x, true)
+ )
+
+ only = Dev.csv(opts[:only])
+ projects = discover_projects(opts)
+ snapshot_base(projects)
+
+ try do
+ discovered = Enum.map(projects, &discover_candidates(&1, vp_opts, only, opts))
+ run_phases(projects, discovered, vp_opts, opts)
+ after
+ Enum.each(projects, &File.rm(&1.base_backup))
+ end
+ end
+
+ ## Project discovery
+
+ defp discover_projects(opts) do
+ only_projects = Dev.csv(opts[:projects])
+ base_dir = Keyword.get(opts, :base_dir, ".")
+ root = %{name: "root", dir: base_dir, mix_env: "test", kind: :root, check_locked: false}
+
+ integrations =
+ for int <- @integrations do
+ %{
+ name: int.name,
+ dir: Path.join([base_dir, "test_integrations", int.subdir]),
+ mix_env: int.mix_env,
+ kind: :integration,
+ check_locked: true
+ }
+ end
+
+ all = if opts[:skip_integrations], do: [root], else: [root | integrations]
+ all = if only_projects == [], do: all, else: Enum.filter(all, &(&1.name in only_projects))
+
+ Enum.map(all, fn project ->
+ backup = Path.join(System.tmp_dir!(), "sentry_bump_base_#{project.name}.lock")
+ Map.put(project, :base_backup, backup)
+ end)
+ end
+
+ defp lock_path(project), do: Path.join(project.dir, "mix.lock")
+
+ ## Base snapshot / restore
+
+ defp snapshot_base(projects) do
+ Enum.each(projects, fn project ->
+ lock = lock_path(project)
+ if File.exists?(lock), do: File.cp!(lock, project.base_backup)
+ end)
+ end
+
+ defp restore_base(project) do
+ if File.exists?(project.base_backup) do
+ File.cp!(project.base_backup, lock_path(project))
+ end
+ end
+
+ defp base_lock(project), do: Lockfile.read(project.base_backup)
+
+ ## Candidate discovery
+
+ # Bumps everything once to learn what *can* move, then restores the lock. Splits the
+ # available bumps into ones we're allowed to apply and ones skipped as a major bump.
+ defp discover_candidates(project, vp_opts, only, opts) do
+ base = base_lock(project)
+ log_to = log_path(opts, "discover-#{project.name}")
+
+ case run_mix(opts, project.dir, ["deps.update", "--all"], env: env(project), log_to: log_to) do
+ {:ok, _output} ->
+ new = Lockfile.read(lock_path(project))
+ restore_base(project)
+
+ candidates =
+ base
+ |> Lockfile.diff(new)
+ # Newly-added transitive deps (from: nil) can't be updated on their own — they
+ # only appear when their parent is bumped, so they ride along as also_changed.
+ |> Enum.reject(&is_nil(&1.from))
+ |> filter_only(only)
+
+ {allowed, skipped} =
+ Enum.split_with(candidates, &VersionPolicy.allowed?(&1.dep, &1.from, &1.to, vp_opts))
+
+ skipped_major =
+ Enum.map(skipped, &Map.put(&1, :reason, skip_reason(&1, vp_opts)))
+
+ %{project: project, allowed: allowed, skipped_major: skipped_major}
+
+ {:error, status, output} ->
+ restore_base(project)
+
+ Mix.raise(
+ "`mix deps.update --all` failed in #{project.dir} (exit #{status}):\n" <> tail(output)
+ )
+ end
+ end
+
+ defp skip_reason(change, vp_opts),
+ do: to_string(VersionPolicy.classify(change.from, change.to, vp_opts))
+
+ defp filter_only(candidates, []), do: candidates
+ defp filter_only(candidates, only), do: Enum.filter(candidates, &(&1.dep in only))
+
+ ## Phase orchestration
+
+ defp run_phases(projects, discovered, vp_opts, opts) do
+ skipped_by_project = Map.new(discovered, &{&1.project.name, &1.skipped_major})
+
+ case optimistic(discovered, vp_opts, opts) do
+ {:ok, bumped_by_project} ->
+ build_result(projects, opts, %{
+ optimistic_passed: true,
+ full_validation_passed: true,
+ bumped: bumped_by_project,
+ skipped: skipped_by_project,
+ failed: %{}
+ })
+
+ {:failed, reason} ->
+ Mix.shell().info([
+ :yellow,
+ "Optimistic bump did not pass (#{reason}); bisecting.",
+ :reset
+ ])
+
+ Enum.each(projects, &restore_base/1)
+ gradual(projects, discovered, skipped_by_project, vp_opts, opts)
+ end
+ end
+
+ ## Optimistic phase — apply every allowed bump at once, then validate
+
+ defp optimistic(discovered, vp_opts, opts) do
+ results = Enum.map(discovered, &apply_allowed(&1, vp_opts, opts))
+
+ cond do
+ Enum.any?(results, &(&1 == :error)) ->
+ {:failed, "could not apply allowed bumps"}
+
+ Enum.any?(results, &(&1 == :dragged)) ->
+ {:failed, "applying allowed bumps would cross a major boundary"}
+
+ true ->
+ case validate_all(projects_of(discovered), opts, "optimistic") do
+ :ok -> {:ok, optimistic_bumps(discovered)}
+ {:fail, name, failure_type, _log} -> {:failed, "#{name}: #{failure_type}"}
+ end
+ end
+ end
+
+ defp apply_allowed(%{project: project, allowed: allowed}, vp_opts, opts) do
+ case Enum.map(allowed, & &1.dep) do
+ [] ->
+ :ok
+
+ names ->
+ log_to = log_path(opts, "optimistic")
+
+ case run_mix(opts, project.dir, ["deps.update" | names],
+ env: env(project),
+ log_to: log_to
+ ) do
+ {:ok, _} -> if dragged_forbidden?(project, vp_opts), do: :dragged, else: :ok
+ {:error, _status, _output} -> :error
+ end
+ end
+ end
+
+ defp dragged_forbidden?(project, vp_opts) do
+ project
+ |> base_lock()
+ |> Lockfile.diff(Lockfile.read(lock_path(project)))
+ |> Enum.any?(&(not VersionPolicy.allowed?(&1.dep, &1.from, &1.to, vp_opts)))
+ end
+
+ defp optimistic_bumps(discovered) do
+ Map.new(discovered, fn %{project: project} ->
+ bumped =
+ project
+ |> base_lock()
+ |> Lockfile.diff(Lockfile.read(lock_path(project)))
+ |> Enum.map(&Map.merge(&1, %{also_changed: []}))
+
+ {project.name, bumped}
+ end)
+ end
+
+ ## Gradual phase (cumulative greedy)
+
+ defp gradual(projects, discovered, skipped_by_project, vp_opts, opts) do
+ worklist =
+ Enum.flat_map(discovered, fn %{project: project, allowed: allowed} ->
+ Enum.map(allowed, &Map.put(&1, :project, project))
+ end)
+
+ initial_acc = %{
+ bumped: Map.new(projects, &{&1.name, []}),
+ skipped: skipped_by_project,
+ failed: Map.new(projects, &{&1.name, []})
+ }
+
+ acc =
+ Enum.reduce(worklist, initial_acc, fn item, acc ->
+ attempt_bump(item, projects_of(discovered), vp_opts, opts, acc)
+ end)
+
+ full_ok = opts[:no_final_check] || validate_all(projects_of(discovered), opts, "final") == :ok
+
+ build_result(projects, opts, %{
+ optimistic_passed: false,
+ full_validation_passed: full_ok,
+ bumped: acc.bumped,
+ skipped: acc.skipped,
+ failed: acc.failed
+ })
+ end
+
+ defp attempt_bump(item, all_projects, vp_opts, opts, acc) do
+ project = item.project
+ lock = lock_path(project)
+ pre = Lockfile.read(lock)
+
+ if already_at_target?(pre, item) do
+ # A previous kept bump already dragged this dep to its target as a sibling, so
+ # it's recorded there — re-attempting would double-list it.
+ acc
+ else
+ try_bump(item, project, lock, pre, all_projects, vp_opts, opts, acc)
+ end
+ end
+
+ defp already_at_target?(lock, item) do
+ Lockfile.hex_version(Map.get(lock, item.dep)) == {:ok, item.to}
+ end
+
+ defp try_bump(item, project, lock, pre, all_projects, vp_opts, opts, acc) do
+ bak = lock <> ".sentry_bump_bak"
+ File.cp!(lock, bak)
+ label = gradual_label(item.dep)
+
+ Mix.shell().info([
+ :cyan,
+ "==> Trying #{project.name}: #{item.dep} #{item.from} -> #{item.to}",
+ :reset
+ ])
+
+ result =
+ case run_mix(opts, project.dir, ["deps.update", item.dep],
+ env: env(project),
+ log_to: log_path(opts, label)
+ ) do
+ {:ok, _output} ->
+ evaluate_step(item, project, pre, all_projects, vp_opts, opts, label)
+
+ {:error, status, output} ->
+ {:reject, :deps_get, project.name, "exit #{status}\n#{tail(output)}"}
+ end
+
+ record_step(result, item, project, bak, opts, acc)
+ end
+
+ defp evaluate_step(item, project, pre, all_projects, vp_opts, opts, label) do
+ step_changes = Lockfile.diff(pre, Lockfile.read(lock_path(project)))
+
+ if Enum.any?(step_changes, &(not VersionPolicy.allowed?(&1.dep, &1.from, &1.to, vp_opts))) do
+ {:skip_major, "requires_major_dep"}
+ else
+ also_changed = Enum.reject(step_changes, &(&1.dep == item.dep))
+
+ case validate_all(all_projects, opts, label) do
+ :ok -> {:keep, also_changed}
+ {:fail, name, failure_type, log} -> {:reject, failure_type, name, log}
+ end
+ end
+ end
+
+ defp record_step({:keep, also_changed}, item, project, bak, _opts, acc) do
+ _ = File.rm(bak)
+ Mix.shell().info([:green, " kept #{item.dep}", :reset])
+ bumped = item |> Map.merge(%{also_changed: also_changed}) |> Map.delete(:project)
+ append(acc, :bumped, project.name, bumped)
+ end
+
+ defp record_step({:skip_major, reason}, item, project, bak, opts, acc) do
+ _ = restore_step(project, bak, opts)
+ Mix.shell().info([:yellow, " skipped #{item.dep} (#{reason})", :reset])
+ skipped = item |> Map.put(:reason, reason) |> Map.delete(:project)
+ append(acc, :skipped, project.name, skipped)
+ end
+
+ defp record_step({:reject, failure_type, manifest_project, log}, item, project, bak, opts, acc) do
+ _ = restore_step(project, bak, opts)
+
+ Mix.shell().info([
+ :red,
+ " failed #{item.dep} (#{failure_type} in #{manifest_project})",
+ :reset
+ ])
+
+ failed =
+ item
+ |> Map.merge(%{
+ failure_type: to_string(failure_type),
+ attributed_to: project.name,
+ manifested_in: manifest_project,
+ kept_at: item.from,
+ log_file: relative_log_file(opts, gradual_label(item.dep)),
+ log_snippet: tail(log)
+ })
+ |> Map.delete(:project)
+
+ append(acc, :failed, project.name, failed)
+ end
+
+ defp restore_step(project, bak, opts) do
+ File.cp!(bak, lock_path(project))
+ _ = File.rm(bak)
+ run_mix(opts, project.dir, ["deps.get"], env: env(project))
+ end
+
+ defp append(acc, key, project_name, value),
+ do: update_in(acc, [key, project_name], &(&1 ++ [value]))
+
+ ## Validation oracle
+
+ defp validate_all(projects, opts, label) do
+ log_to = log_path(opts, label)
+
+ Enum.reduce_while(projects, :ok, fn project, :ok ->
+ case validate_project(project, opts, log_to) do
+ :ok -> {:cont, :ok}
+ {:fail, failure_type, log} -> {:halt, {:fail, project.name, failure_type, log}}
+ end
+ end)
+ end
+
+ defp validate_project(project, opts, log_to) do
+ cmd_opts = [
+ env: env(project),
+ verbose: opts[:verbose],
+ timeout: opts[:timeout],
+ log_to: log_to
+ ]
+
+ deps_get_args =
+ if project.check_locked, do: ["deps.get", "--check-locked"], else: ["deps.get"]
+
+ with {:deps, {:ok, _}} <- {:deps, run_mix(opts, project.dir, deps_get_args, cmd_opts)},
+ {:compile, {:ok, _}} <- {:compile, run_mix(opts, project.dir, ["compile"], cmd_opts)},
+ {:test, {:ok, _}} <-
+ {:test, run_mix(opts, project.dir, ["test", "--no-color"], cmd_opts)} do
+ :ok
+ else
+ {:deps, {:error, _status, log}} ->
+ {:fail, if(project.check_locked, do: :deps_locked, else: :deps_get), log}
+
+ {:compile, {:error, _status, log}} ->
+ {:fail, :build, log}
+
+ {:test, {:error, _status, log}} ->
+ {:fail, :tests, log}
+ end
+ end
+
+ ## Helpers
+
+ defp projects_of(discovered), do: Enum.map(discovered, & &1.project)
+
+ # All subprocess calls funnel through here so tests can inject a fake runner via the
+ # `:runner` option instead of shelling out to real `mix`.
+ defp run_mix(opts, dir, args, cmd_opts) do
+ runner = Keyword.get(opts, :runner, &Cmd.mix/3)
+ runner.(dir, args, cmd_opts)
+ end
+
+ defp env(project), do: [{"MIX_ENV", project.mix_env}]
+
+ defp gradual_label(dep), do: "gradual-#{dep}"
+
+ # Absolute path of the log file for a labelled step, or nil when logging is disabled.
+ defp log_path(opts, label) do
+ case opts[:logs_dir] do
+ nil -> nil
+ dir -> Path.join(dir, "#{label}.log")
+ end
+ end
+
+ # Path of the log file relative to the run directory, for inclusion in the report.
+ defp relative_log_file(opts, label) do
+ if opts[:logs_dir], do: Path.join("logs", "#{label}.log")
+ end
+
+ defp tail(output) when is_binary(output) do
+ if byte_size(output) > @log_snippet_bytes do
+ "...(truncated)...\n" <>
+ binary_part(output, byte_size(output) - @log_snippet_bytes, @log_snippet_bytes)
+ else
+ output
+ end
+ end
+
+ ## Result assembly
+
+ defp build_result(projects, opts, data) do
+ project_results =
+ Enum.map(projects, fn project ->
+ bumped = Map.get(data.bumped, project.name, [])
+ skipped = Map.get(data.skipped, project.name, [])
+ failed = Map.get(data.failed, project.name, [])
+
+ %{
+ name: project.name,
+ dir: project.dir,
+ mix_env: project.mix_env,
+ optimistic_passed: data.optimistic_passed,
+ status: project_status(bumped, skipped, failed),
+ bumped: bumped,
+ skipped_major: skipped,
+ failed: failed
+ }
+ end)
+
+ %{
+ optimistic_passed: data.optimistic_passed,
+ full_validation_passed: data.full_validation_passed,
+ overall_status: overall_status(data, project_results),
+ options: normalize_options(opts),
+ projects: project_results
+ }
+ end
+
+ # Classifies a single project's outcome from its bumped/skipped/failed lists.
+ # Public only so it can be unit-tested directly.
+ @doc false
+ @spec project_status([map()], [map()], [map()]) :: String.t()
+ def project_status(bumped, skipped, failed) do
+ cond do
+ failed != [] -> "partial"
+ skipped != [] and bumped != [] -> "partial"
+ bumped == [] -> "unchanged"
+ true -> "green"
+ end
+ end
+
+ # Classifies the overall run outcome from the validation flags and per-project results.
+ # Public only so it can be unit-tested directly.
+ @doc false
+ @spec overall_status(map(), [map()]) :: String.t()
+ def overall_status(data, project_results) do
+ any_failed = Enum.any?(project_results, &(&1.failed != []))
+ any_skipped = Enum.any?(project_results, &(&1.skipped_major != []))
+
+ cond do
+ not data.full_validation_passed -> "failed"
+ data.optimistic_passed and not any_skipped -> "green"
+ any_failed or any_skipped -> "partial"
+ true -> "green"
+ end
+ end
+
+ defp normalize_options(opts) do
+ %{
+ allow_major: opts[:allow_major] || false,
+ allow_major_for: Dev.csv(opts[:allow_major_for]),
+ strict_0x: Keyword.get(opts, :strict_0x, true),
+ only: Dev.csv(opts[:only]),
+ projects: Dev.csv(opts[:projects]),
+ skip_integrations: opts[:skip_integrations] || false,
+ no_final_check: opts[:no_final_check] || false
+ }
+ end
+end
diff --git a/lib/sentry/dev/cmd.ex b/lib/sentry/dev/cmd.ex
new file mode 100644
index 00000000..afdaaf1f
--- /dev/null
+++ b/lib/sentry/dev/cmd.ex
@@ -0,0 +1,145 @@
+defmodule Sentry.Dev.Cmd do
+ @moduledoc """
+ Runs `mix` subprocesses for `mix sentry.bump_lockfiles`, capturing output and exit codes.
+
+ Each project is operated on as a separate OS process so that exit codes are
+ unambiguous (compile failures vs test failures vs locked-deps failures). Output is
+ captured for the JSON report and, when `:verbose` is set, streamed live.
+
+ Subprocesses are launched via `Port.open/2` (not `System.cmd/3`) so that a `:timeout`
+ can actually terminate the running command: on expiry we send `SIGKILL` to the OS
+ process. `System.cmd/3` offers no way to do this — a timed-out `mix test` would keep
+ running detached and could corrupt the next attempt's `_build`.
+
+ This module is dev/CI tooling and is not part of the public API.
+ """
+
+ @moduledoc since: "13.3.0"
+
+ @typep result :: {:ok, String.t()} | {:error, non_neg_integer() | :timeout, String.t()}
+
+ @doc """
+ Runs `mix` with the given `args` in `dir`.
+
+ Options:
+
+ * `:env` - extra environment variables as `[{"KEY", "VALUE"}]`
+ * `:verbose` - stream the output live as it arrives (default `false`)
+ * `:timeout` - hard cap in seconds measured from launch; on expiry the OS process is
+ killed with `SIGKILL` and `{:error, :timeout, output}` is returned
+ * `:log_to` - a file path to append the captured output to, prefixed with a header
+ identifying the command. Parent directories are created as needed.
+
+ Returns `{:ok, output}` on exit status 0, `{:error, status, output}` otherwise.
+ """
+ @spec mix(Path.t(), [String.t()], keyword()) :: result()
+ def mix(dir, args, opts \\ []), do: command("mix", dir, args, opts)
+
+ # Generic runner behind `mix/3`, exposed only so the subprocess/timeout machinery can
+ # be exercised in tests with a cheap executable like `sh` instead of a real `mix` run.
+ @doc false
+ @spec command(String.t(), Path.t(), [String.t()], keyword()) :: result()
+ def command(executable, dir, args, opts \\ []) do
+ result = run(executable, dir, args, opts)
+ log(Keyword.get(opts, :log_to), dir, args, result)
+ result
+ end
+
+ defp run(executable, dir, args, opts) do
+ path =
+ System.find_executable(executable) ||
+ raise ArgumentError, "could not find executable on PATH: #{executable}"
+
+ port =
+ Port.open({:spawn_executable, path}, [
+ :binary,
+ :exit_status,
+ :stderr_to_stdout,
+ {:args, args},
+ {:cd, dir},
+ {:env, env_charlists(Keyword.get(opts, :env))}
+ ])
+
+ ctx = %{
+ port: port,
+ deadline: deadline(Keyword.get(opts, :timeout)),
+ verbose: Keyword.get(opts, :verbose, false),
+ args: args
+ }
+
+ collect(ctx, [])
+ end
+
+ defp deadline(nil), do: :infinity
+ defp deadline(seconds), do: System.monotonic_time(:millisecond) + seconds * 1000
+
+ defp remaining(:infinity), do: :infinity
+ defp remaining(deadline), do: max(deadline - System.monotonic_time(:millisecond), 0)
+
+ defp collect(%{port: port} = ctx, acc) do
+ receive do
+ {^port, {:data, data}} ->
+ if ctx.verbose, do: IO.write(data)
+ collect(ctx, [data | acc])
+
+ {^port, {:exit_status, 0}} ->
+ {:ok, finalize(acc)}
+
+ {^port, {:exit_status, status}} ->
+ {:error, status, finalize(acc)}
+ after
+ remaining(ctx.deadline) ->
+ kill(port)
+
+ {:error, :timeout,
+ finalize(acc) <> "\n[killed: timed out running mix #{Enum.join(ctx.args, " ")}]"}
+ end
+ end
+
+ defp finalize(acc), do: acc |> Enum.reverse() |> IO.iodata_to_binary()
+
+ # SIGKILL the OS process (after `mix`'s exec chain this is the BEAM running the suite),
+ # then close the port and drain any straggler messages so they don't leak to the caller.
+ defp kill(port) do
+ _ =
+ with {:os_pid, os_pid} <- Port.info(port, :os_pid) do
+ System.cmd("kill", ["-KILL", Integer.to_string(os_pid)], stderr_to_stdout: true)
+ end
+
+ close(port)
+ flush(port)
+ end
+
+ defp close(port) do
+ if Port.info(port), do: Port.close(port)
+ rescue
+ ArgumentError -> :ok
+ end
+
+ defp flush(port) do
+ receive do
+ {^port, _} -> flush(port)
+ after
+ 0 -> :ok
+ end
+ end
+
+ defp env_charlists(nil), do: []
+
+ defp env_charlists(env),
+ do: Enum.map(env, fn {key, value} -> {to_charlist(key), to_charlist(value)} end)
+
+ defp log(nil, _dir, _args, _result), do: :ok
+
+ defp log(path, dir, args, result) do
+ {status, output} =
+ case result do
+ {:ok, out} -> {0, out}
+ {:error, status, out} -> {status, out}
+ end
+
+ File.mkdir_p!(Path.dirname(path))
+ header = "\n===== #{dir} $ mix #{Enum.join(args, " ")} (exit #{status}) =====\n"
+ File.write!(path, header <> output, [:append])
+ end
+end
diff --git a/lib/sentry/dev/lockfile.ex b/lib/sentry/dev/lockfile.ex
new file mode 100644
index 00000000..b4de5040
--- /dev/null
+++ b/lib/sentry/dev/lockfile.ex
@@ -0,0 +1,114 @@
+defmodule Sentry.Dev.Lockfile do
+ @moduledoc """
+ Reads and diffs `mix.lock` files for the `mix sentry.bump_lockfiles` task.
+
+ A `mix.lock` is an Elixir map literal mapping a dependency name to a tuple. Hex
+ dependencies look like `{:hex, :name, "1.2.3", "hash", build_tools, deps, "hexpm",
+ "outer_hash"}` — the version lives at element 2. Git and path dependencies use a
+ different tuple shape and carry no comparable version, so they are skipped.
+
+ This module is dev/CI tooling and is not part of the public API.
+ """
+
+ @moduledoc since: "13.3.0"
+
+ @type lock :: %{optional(String.t()) => tuple()}
+ @type change :: %{dep: String.t(), from: String.t(), to: String.t()}
+
+ @doc """
+ Parses a `mix.lock` file into a map of dependency name (string) to lock entry.
+
+ The file is *parsed*, never evaluated: `Code.string_to_quoted/2` turns it into an AST,
+ which is then converted to a term while rejecting anything that is not a plain data
+ literal. This keeps a malicious or malformed `mix.lock` from executing code (a real
+ risk since the lock is otherwise indistinguishable from Elixir source).
+
+ A `mix.lock` literal uses keyword syntax (`"dep": {...}`), so its keys parse to atoms;
+ they are normalized to strings here. Returns an empty map if the file does not exist
+ (a project may have no lock yet).
+ """
+ @spec read(Path.t()) :: lock()
+ def read(path) do
+ if File.exists?(path) do
+ path
+ |> File.read!()
+ |> parse()
+ |> Map.new(fn {name, entry} -> {to_string(name), entry} end)
+ else
+ %{}
+ end
+ end
+
+ defp parse(contents) do
+ # `emit_warnings: false` silences the "quoted keyword" warning mix.lock entries
+ # trigger. `string_to_quoted` only parses — it does not run the contents.
+ case Code.string_to_quoted(contents, emit_warnings: false) do
+ {:ok, ast} -> to_term(ast)
+ {:error, reason} -> raise ArgumentError, "could not parse mix.lock: #{inspect(reason)}"
+ end
+ end
+
+ # Convert a literal-only AST into a term. Maps, tuples, lists, and scalars are allowed;
+ # anything else (a function call, a variable — i.e. executable code) is rejected.
+ defp to_term({:%{}, _meta, pairs}),
+ do: Map.new(pairs, fn {k, v} -> {to_term(k), to_term(v)} end)
+
+ defp to_term({:{}, _meta, elems}), do: elems |> Enum.map(&to_term/1) |> List.to_tuple()
+ defp to_term({left, right}), do: {to_term(left), to_term(right)}
+ defp to_term(list) when is_list(list), do: Enum.map(list, &to_term/1)
+ defp to_term(scalar) when is_atom(scalar) or is_binary(scalar) or is_number(scalar), do: scalar
+
+ defp to_term(other) do
+ raise ArgumentError, "mix.lock contains a non-literal expression: #{inspect(other)}"
+ end
+
+ @doc """
+ Extracts the version string from a hex lock entry.
+
+ Returns `:not_hex` for git/path entries, which have no comparable version.
+ """
+ @spec hex_version(tuple()) :: {:ok, String.t()} | :not_hex
+ def hex_version({:hex, _name, version, _hash, _build_tools, _deps, _repo, _outer_hash})
+ when is_binary(version),
+ do: {:ok, version}
+
+ def hex_version({:hex, _name, version, _hash, _build_tools, _deps, _repo})
+ when is_binary(version),
+ do: {:ok, version}
+
+ def hex_version(_other), do: :not_hex
+
+ @doc """
+ Returns the version changes between two parsed locks.
+
+ Only hex dependencies whose version differs are reported. Each change is
+ `%{dep: name, from: old_version, to: new_version}`. Dependencies that only exist
+ in the new lock are reported with `from: nil`.
+ """
+ @spec diff(lock(), lock()) :: [change()]
+ def diff(old_lock, new_lock) do
+ new_lock
+ |> Enum.flat_map(fn {name, new_entry} ->
+ with {:ok, new_version} <- hex_version(new_entry),
+ false <- new_version == old_version(old_lock, name) do
+ [%{dep: name, from: old_version(old_lock, name), to: new_version}]
+ else
+ _ -> []
+ end
+ end)
+ |> Enum.sort_by(& &1.dep)
+ end
+
+ defp old_version(lock, name) do
+ case Map.fetch(lock, name) do
+ {:ok, entry} ->
+ case hex_version(entry) do
+ {:ok, version} -> version
+ :not_hex -> nil
+ end
+
+ :error ->
+ nil
+ end
+ end
+end
diff --git a/lib/sentry/dev/report.ex b/lib/sentry/dev/report.ex
new file mode 100644
index 00000000..0aa7824a
--- /dev/null
+++ b/lib/sentry/dev/report.ex
@@ -0,0 +1,144 @@
+defmodule Sentry.Dev.Report do
+ @moduledoc """
+ Builds, encodes, and prints the JSON report for `mix sentry.bump_lockfiles`.
+
+ This module is dev/CI tooling and is not part of the public API.
+ """
+
+ @moduledoc since: "13.3.0"
+
+ @schema_version 1
+
+ @doc """
+ Wraps the bumper result in report metadata and a summary.
+ """
+ @spec build(map()) :: map()
+ def build(result) do
+ result
+ |> Map.put(:schema_version, @schema_version)
+ |> Map.put(:generated_at, DateTime.utc_now() |> DateTime.to_iso8601())
+ |> Map.put(:elixir_version, System.version())
+ |> Map.put(:otp_version, otp_version())
+ |> Map.put(:summary, summary(result))
+ end
+
+ @doc """
+ Encodes the report as pretty-printed JSON.
+ """
+ @spec encode(map()) :: String.t()
+ def encode(report) do
+ if Code.ensure_loaded?(Jason) do
+ Jason.encode!(report, pretty: true)
+ else
+ Mix.raise("Jason is required to encode the report but is not available")
+ end
+ end
+
+ @doc """
+ Writes the encoded report to `path`, creating parent directories as needed.
+ """
+ @spec write(map(), Path.t()) :: :ok
+ def write(report, path) do
+ File.mkdir_p!(Path.dirname(path))
+ File.write!(path, encode(report))
+ end
+
+ @doc """
+ Reads and decodes a previously written report from `path` (string-keyed map).
+ """
+ @spec read(Path.t()) :: map()
+ def read(path) do
+ if Code.ensure_loaded?(Jason) do
+ path |> File.read!() |> Jason.decode!()
+ else
+ Mix.raise("Jason is required to read the report but is not available")
+ end
+ end
+
+ @doc """
+ Prints a colored, human-readable summary of the report.
+ """
+ @spec print_summary(map()) :: :ok
+ def print_summary(report) do
+ summary = report.summary
+ shell = Mix.shell()
+
+ shell.info("\n" <> String.duplicate("─", 60))
+ shell.info([:bright, "Dependency bump report", :reset])
+
+ shell.info([
+ "Overall status: ",
+ status_color(report.overall_status),
+ report.overall_status,
+ :reset
+ ])
+
+ shell.info(
+ "Bumped: #{summary.bumped} • Skipped (major): #{summary.skipped_major} • Failed: #{summary.failed}"
+ )
+
+ Enum.each(report.projects, &print_project(&1, shell))
+ shell.info(String.duplicate("─", 60))
+ :ok
+ end
+
+ defp print_project(project, shell) do
+ shell.info(["\n", :bright, project.name, :reset, " (#{project.status})"])
+
+ Enum.each(project.bumped, fn b ->
+ shell.info([" ", :green, "✓ #{b.dep} #{b.from} -> #{b.to}", :reset])
+ end)
+
+ Enum.each(project.skipped_major, fn s ->
+ shell.info([
+ " ",
+ :yellow,
+ "⤳ #{s.dep} #{s.from} -> #{s.to} (skipped: #{s.reason})",
+ :reset
+ ])
+ end)
+
+ Enum.each(project.failed, fn f ->
+ shell.info([
+ " ",
+ :red,
+ "✗ #{f.dep} #{f.from} -> #{f.to} (#{f.failure_type} in #{f.manifested_in})",
+ :reset
+ ])
+ end)
+ end
+
+ defp summary(result) do
+ projects = result.projects
+
+ %{
+ projects: length(projects),
+ bumped: count(projects, :bumped),
+ skipped_major: count(projects, :skipped_major),
+ failed: count(projects, :failed),
+ optimistic_passed: result.optimistic_passed,
+ full_validation_passed: result.full_validation_passed
+ }
+ end
+
+ defp count(projects, key),
+ do: Enum.reduce(projects, 0, fn p, acc -> acc + length(Map.fetch!(p, key)) end)
+
+ defp status_color("green"), do: :green
+ defp status_color("partial"), do: :yellow
+ defp status_color("failed"), do: :red
+
+ defp otp_version do
+ case :file.read_file(
+ Path.join([
+ :code.root_dir(),
+ "releases",
+ :erlang.system_info(:otp_release),
+ "OTP_VERSION"
+ ])
+ ) do
+ {:ok, version} -> String.trim(version)
+ _ -> List.to_string(:erlang.system_info(:otp_release))
+ end
+ end
+end
diff --git a/lib/sentry/dev/version_policy.ex b/lib/sentry/dev/version_policy.ex
new file mode 100644
index 00000000..b8e1fb78
--- /dev/null
+++ b/lib/sentry/dev/version_policy.ex
@@ -0,0 +1,95 @@
+defmodule Sentry.Dev.VersionPolicy do
+ @moduledoc """
+ Classifies version bumps and decides which are allowed for `mix sentry.bump_lockfiles`.
+
+ The default policy keeps patch and minor bumps and rejects anything that crosses a
+ major boundary. Following semver, `0.x` releases treat the *minor* segment as the
+ breaking axis, so a `0.20 -> 0.21` bump is considered breaking by default.
+
+ This module is dev/CI tooling and is not part of the public API.
+ """
+
+ @moduledoc since: "13.3.0"
+
+ @type opts :: %{
+ allow_major: boolean(),
+ allow_major_for: MapSet.t(String.t()),
+ strict_0x: boolean()
+ }
+
+ @type kind :: :patch | :minor | :major | :"0x_minor_breaking" | :downgrade | :unparseable
+
+ @doc """
+ Builds a normalized options map from the keyword options parsed off the CLI.
+ """
+ @spec opts(keyword()) :: opts()
+ def opts(parsed) do
+ %{
+ allow_major: Keyword.get(parsed, :allow_major, false),
+ allow_major_for: parsed |> Keyword.get(:allow_major_for, []) |> MapSet.new(),
+ strict_0x: Keyword.get(parsed, :strict_0x, true)
+ }
+ end
+
+ @doc """
+ Classifies a bump from `from` to `to`.
+
+ Returns `:unparseable` if either version cannot be parsed, `:downgrade` if `to` is
+ not greater than `from`, and otherwise `:patch`, `:minor`, `:major`, or
+ `:"0x_minor_breaking"` (a breaking minor bump within the `0.x` series).
+ """
+ @spec classify(String.t() | nil, String.t(), opts()) :: kind()
+ def classify(from, to, opts) do
+ with {:ok, from_v} <- parse(from),
+ {:ok, to_v} <- parse(to) do
+ cond do
+ Version.compare(to_v, from_v) != :gt ->
+ :downgrade
+
+ from_v.major == 0 and to_v.major == 0 and from_v.minor != to_v.minor and opts.strict_0x ->
+ :"0x_minor_breaking"
+
+ from_v.major != to_v.major ->
+ :major
+
+ from_v.minor != to_v.minor ->
+ :minor
+
+ true ->
+ :patch
+ end
+ else
+ _ -> :unparseable
+ end
+ end
+
+ @doc """
+ Returns `true` when a bump from `from` to `to` crosses a breaking boundary under the
+ current policy. A new dependency (`from == nil`) is never breaking.
+ """
+ @spec breaking?(String.t() | nil, String.t(), opts()) :: boolean()
+ def breaking?(nil, _to, _opts), do: false
+
+ def breaking?(from, to, opts) do
+ classify(from, to, opts) in [:major, :"0x_minor_breaking"]
+ end
+
+ @doc """
+ Returns `true` when bumping `dep` from `from` to `to` is allowed.
+
+ Non-breaking bumps are always allowed. Breaking bumps are allowed only when
+ `:allow_major` is set globally or `dep` appears in `:allow_major_for`.
+ """
+ @spec allowed?(String.t(), String.t() | nil, String.t(), opts()) :: boolean()
+ def allowed?(dep, from, to, opts) do
+ cond do
+ not breaking?(from, to, opts) -> true
+ opts.allow_major -> true
+ MapSet.member?(opts.allow_major_for, dep) -> true
+ true -> false
+ end
+ end
+
+ defp parse(nil), do: :error
+ defp parse(version) when is_binary(version), do: Version.parse(version)
+end
diff --git a/mix.exs b/mix.exs
index 0c81ec9e..d29993bd 100644
--- a/mix.exs
+++ b/mix.exs
@@ -147,15 +147,16 @@ defmodule Sentry.Mixfile do
defp package do
[
- files: [
- "lib",
- "LICENSE",
- "mix.exs",
- "CHANGELOG.md",
- "CONTRIBUTING.md",
- "ISSUE_TEMPLATE.md",
- "README.md"
- ],
+ files:
+ package_lib_files() ++
+ [
+ "LICENSE",
+ "mix.exs",
+ "CHANGELOG.md",
+ "CONTRIBUTING.md",
+ "ISSUE_TEMPLATE.md",
+ "README.md"
+ ],
maintainers: ["Mitchell Henke", "Jason Stiebs"],
licenses: ["MIT"],
links: %{
@@ -165,6 +166,18 @@ defmodule Sentry.Mixfile do
]
end
+ # Ship all of `lib`, minus the `mix sentry.bump_lockfiles` dev/CI tooling
+ # (`Sentry.Dev.*` and the Mix task). That tooling lives under `lib` so it compiles
+ # with the project, but it is not part of the public API and must not leak into the
+ # published Hex package.
+ defp package_lib_files do
+ excluded = ["lib/sentry/dev.ex", "lib/mix/tasks/sentry.bump_lockfiles.ex"]
+
+ "lib/**/*.ex"
+ |> Path.wildcard()
+ |> Enum.reject(&(&1 in excluded or String.starts_with?(&1, "lib/sentry/dev/")))
+ end
+
defp aliases do
[
test: ["sentry.package_source_code", "test"],
diff --git a/test/mix/tasks/sentry_bump_lockfiles_test.exs b/test/mix/tasks/sentry_bump_lockfiles_test.exs
new file mode 100644
index 00000000..6dcc5d3b
--- /dev/null
+++ b/test/mix/tasks/sentry_bump_lockfiles_test.exs
@@ -0,0 +1,33 @@
+defmodule Mix.Tasks.Sentry.BumpLockfilesTest do
+ use ExUnit.Case, async: true
+
+ alias Mix.Tasks.Sentry.BumpLockfiles
+
+ describe "relocate_locks_dir/2" do
+ test "re-anchors the verified locks dir to the report's own directory" do
+ # The stored path is relative to the bump-time cwd, but the locks always live next
+ # to the report. Re-anchoring makes --apply work from any cwd / a moved run dir.
+ report = %{"verified_locks_dir" => "tmp/lockfile-bump/run-2026-06-19/locks"}
+
+ relocated =
+ BumpLockfiles.relocate_locks_dir(report, "/downloads/run-2026-06-19/report.json")
+
+ assert relocated["verified_locks_dir"] == "/downloads/run-2026-06-19/locks"
+ end
+
+ test "works when --apply points at the run directory (report.json appended)" do
+ report = %{"verified_locks_dir" => "tmp/lockfile-bump/run-x/locks"}
+
+ relocated =
+ BumpLockfiles.relocate_locks_dir(report, "relative/run-x/report.json")
+
+ assert relocated["verified_locks_dir"] == "relative/run-x/locks"
+ end
+
+ test "leaves a report without captured lockfiles untouched" do
+ report = %{"projects" => []}
+
+ assert BumpLockfiles.relocate_locks_dir(report, "/anywhere/report.json") == report
+ end
+ end
+end
diff --git a/test/sentry/dev/applier_test.exs b/test/sentry/dev/applier_test.exs
new file mode 100644
index 00000000..168a6b7b
--- /dev/null
+++ b/test/sentry/dev/applier_test.exs
@@ -0,0 +1,53 @@
+defmodule Sentry.Dev.ApplierTest do
+ use ExUnit.Case, async: true
+
+ alias Sentry.Dev.Applier
+
+ defp report(projects, locks_dir) do
+ %{
+ "generated_at" => "2026-06-12T00:00:00Z",
+ "verified_locks_dir" => locks_dir,
+ "projects" => projects
+ }
+ end
+
+ defp project(name, dir, bumped \\ []) do
+ %{"name" => name, "dir" => dir, "mix_env" => "test", "bumped" => bumped}
+ end
+
+ test "raises when the report has no captured lockfiles" do
+ report = %{"projects" => [], "verified_locks_dir" => nil}
+
+ assert_raise Mix.Error, ~r/no captured lockfiles/, fn ->
+ Applier.apply(report, [])
+ end
+ end
+
+ test "reports missing_lock when the sidecar lockfile is absent" do
+ empty_dir = Path.join(System.tmp_dir!(), "applier_test_#{System.unique_integer([:positive])}")
+ File.mkdir_p!(empty_dir)
+ on_exit(fn -> File.rm_rf!(empty_dir) end)
+
+ report = report([project("root", ".", [%{"dep" => "plug"}])], empty_dir)
+ summary = Applier.apply(report, [])
+
+ assert summary.all_applied? == false
+ assert [%{name: "root", status: "missing_lock", bumped: 1}] = summary.projects
+ end
+
+ test "filters to the requested projects" do
+ empty_dir = Path.join(System.tmp_dir!(), "applier_test_#{System.unique_integer([:positive])}")
+ File.mkdir_p!(empty_dir)
+ on_exit(fn -> File.rm_rf!(empty_dir) end)
+
+ report =
+ report(
+ [project("root", "."), project("phoenix_app", "test_integrations/phoenix_app")],
+ empty_dir
+ )
+
+ summary = Applier.apply(report, projects: "phoenix_app")
+
+ assert [%{name: "phoenix_app"}] = summary.projects
+ end
+end
diff --git a/test/sentry/dev/bumper_test.exs b/test/sentry/dev/bumper_test.exs
new file mode 100644
index 00000000..613030b9
--- /dev/null
+++ b/test/sentry/dev/bumper_test.exs
@@ -0,0 +1,194 @@
+defmodule Sentry.Dev.BumperTest do
+ # async: false — the bumper snapshots base lockfiles to a fixed path in the system tmp
+ # dir keyed by project name ("root"), so concurrent runs would clobber each other.
+ use ExUnit.Case, async: false
+
+ import ExUnit.CaptureIO
+
+ alias Sentry.Dev.Bumper
+ alias Sentry.Dev.Lockfile
+
+ describe "project_status/3" do
+ test "classifies the per-project outcome" do
+ assert Bumper.project_status([], [], []) == "unchanged"
+ assert Bumper.project_status([%{dep: "a"}], [], []) == "green"
+ # A project with only a held-back major and no applied bump didn't change.
+ assert Bumper.project_status([], [%{dep: "a"}], []) == "unchanged"
+ assert Bumper.project_status([%{dep: "a"}], [%{dep: "b"}], []) == "partial"
+ assert Bumper.project_status([%{dep: "a"}], [], [%{dep: "b"}]) == "partial"
+ end
+ end
+
+ describe "overall_status/2" do
+ test "a failed final validation dominates everything" do
+ data = %{full_validation_passed: false, optimistic_passed: false}
+ assert Bumper.overall_status(data, [%{failed: [], skipped_major: []}]) == "failed"
+ end
+
+ test "a clean optimistic pass with nothing skipped is green" do
+ data = %{full_validation_passed: true, optimistic_passed: true}
+ assert Bumper.overall_status(data, [%{failed: [], skipped_major: []}]) == "green"
+ end
+
+ test "any failed or skipped project makes the run partial" do
+ data = %{full_validation_passed: true, optimistic_passed: false}
+
+ assert Bumper.overall_status(data, [%{failed: [:x], skipped_major: []}]) == "partial"
+ assert Bumper.overall_status(data, [%{failed: [], skipped_major: [:y]}]) == "partial"
+ end
+
+ test "a green gradual run (everything kept, nothing skipped) is green" do
+ data = %{full_validation_passed: true, optimistic_passed: false}
+ assert Bumper.overall_status(data, [%{failed: [], skipped_major: []}]) == "green"
+ end
+ end
+
+ describe "run/1 orchestration (with an injected runner)" do
+ setup do
+ base_dir =
+ Path.join(System.tmp_dir!(), "bumper_test_#{System.unique_integer([:positive])}")
+
+ File.mkdir_p!(base_dir)
+ on_exit(fn -> File.rm_rf!(base_dir) end)
+
+ %{base_dir: base_dir, lock: Path.join(base_dir, "mix.lock")}
+ end
+
+ test "optimistic pass: every allowed bump applied at once, run is green", %{
+ base_dir: base_dir,
+ lock: lock
+ } do
+ write_lock(lock, %{"good" => "1.0.0", "other" => "1.0.0"})
+ targets = %{"good" => "1.1.0", "other" => "1.2.0"}
+
+ {result, _io} =
+ with_io(fn ->
+ Bumper.run(
+ runner: always_green_runner(targets),
+ base_dir: base_dir,
+ skip_integrations: true
+ )
+ end)
+
+ assert result.optimistic_passed == true
+ assert result.full_validation_passed == true
+ assert result.overall_status == "green"
+
+ assert [root] = result.projects
+ assert root.status == "green"
+ assert root.skipped_major == []
+ assert root.failed == []
+
+ assert Enum.sort_by(root.bumped, & &1.dep) == [
+ %{dep: "good", from: "1.0.0", to: "1.1.0", also_changed: []},
+ %{dep: "other", from: "1.0.0", to: "1.2.0", also_changed: []}
+ ]
+
+ # The verified lockfile on disk carries both bumps.
+ assert read_versions(lock) == %{"good" => "1.1.0", "other" => "1.2.0"}
+ end
+
+ test "bisect: keeps the good bump, attributes the test failure, holds back the major",
+ %{base_dir: base_dir, lock: lock} do
+ write_lock(lock, %{"good" => "1.0.0", "bad" => "1.0.0", "major" => "1.0.0"})
+ # `bad` is an allowed minor bump that nonetheless breaks the suite; `major` crosses a
+ # major boundary and is held back without ever being attempted.
+ targets = %{"good" => "1.1.0", "bad" => "1.1.0", "major" => "2.0.0"}
+
+ {result, _io} =
+ with_io(fn ->
+ Bumper.run(
+ runner: breaks_when_bad_bumped_runner(targets),
+ base_dir: base_dir,
+ skip_integrations: true
+ )
+ end)
+
+ assert result.optimistic_passed == false
+ assert result.full_validation_passed == true
+ assert result.overall_status == "partial"
+
+ assert [root] = result.projects
+ assert root.status == "partial"
+
+ assert [%{dep: "good", from: "1.0.0", to: "1.1.0"}] = root.bumped
+ assert [%{dep: "major", from: "1.0.0", to: "2.0.0", reason: "major"}] = root.skipped_major
+
+ assert [%{dep: "bad", from: "1.0.0", to: "1.1.0", failure_type: "tests"} = failure] =
+ root.failed
+
+ assert failure.attributed_to == "root"
+ assert failure.kept_at == "1.0.0"
+
+ # The good bump is kept; the breaking bump is reverted to its original version.
+ assert read_versions(lock) == %{"good" => "1.1.0", "bad" => "1.0.0", "major" => "1.0.0"}
+ end
+ end
+
+ # A runner where every test passes; `deps.update` writes the target versions.
+ defp always_green_runner(targets) do
+ fn dir, args, _cmd_opts ->
+ lock = Path.join(dir, "mix.lock")
+
+ case args do
+ ["deps.update", "--all"] -> write_lock(lock, targets) && {:ok, ""}
+ ["deps.update" | deps] -> bump(lock, deps, targets) && {:ok, ""}
+ _ -> {:ok, ""}
+ end
+ end
+ end
+
+ # A runner whose suite fails whenever `bad` has been bumped to its target.
+ defp breaks_when_bad_bumped_runner(targets) do
+ fn dir, args, _cmd_opts ->
+ lock = Path.join(dir, "mix.lock")
+
+ case args do
+ ["deps.update", "--all"] ->
+ write_lock(lock, targets)
+ {:ok, ""}
+
+ ["deps.update" | deps] ->
+ bump(lock, deps, targets)
+ {:ok, ""}
+
+ ["test" | _] ->
+ if read_versions(lock)["bad"] == "1.1.0",
+ do: {:error, 1, "tests failed because of bad"},
+ else: {:ok, ""}
+
+ _ ->
+ {:ok, ""}
+ end
+ end
+ end
+
+ defp bump(lock, deps, targets) do
+ updated =
+ Enum.reduce(deps, read_versions(lock), fn dep, acc ->
+ Map.put(acc, dep, Map.fetch!(targets, dep))
+ end)
+
+ write_lock(lock, updated)
+ end
+
+ defp write_lock(path, versions) do
+ body =
+ versions
+ |> Enum.sort()
+ |> Enum.map_join("", fn {name, version} ->
+ ~s( "#{name}": {:hex, :#{name}, "#{version}", "hash", [:mix], [], "hexpm", "outer"},\n)
+ end)
+
+ File.write!(path, "%{\n" <> body <> "}\n")
+ end
+
+ defp read_versions(path) do
+ path
+ |> Lockfile.read()
+ |> Map.new(fn {name, entry} ->
+ {:ok, version} = Lockfile.hex_version(entry)
+ {name, version}
+ end)
+ end
+end
diff --git a/test/sentry/dev/cmd_test.exs b/test/sentry/dev/cmd_test.exs
new file mode 100644
index 00000000..4b6a813c
--- /dev/null
+++ b/test/sentry/dev/cmd_test.exs
@@ -0,0 +1,77 @@
+defmodule Sentry.Dev.CmdTest do
+ use ExUnit.Case, async: true
+
+ alias Sentry.Dev.Cmd
+
+ defp tmp_path(suffix) do
+ path =
+ Path.join(System.tmp_dir!(), "cmd_test_#{System.unique_integer([:positive])}_#{suffix}")
+
+ on_exit(fn -> File.rm_rf!(path) end)
+ path
+ end
+
+ describe "command/4" do
+ test "returns {:ok, output} for a successful command" do
+ assert {:ok, output} = Cmd.command("sh", ".", ["-c", "echo hello"])
+ assert output =~ "hello"
+ end
+
+ test "returns {:error, status, output} for a failing command" do
+ assert {:error, status, output} = Cmd.command("sh", ".", ["-c", "echo boom >&2; exit 3"])
+ assert status == 3
+ # stderr is merged into the captured output.
+ assert output =~ "boom"
+ end
+
+ test "passes env vars through to the subprocess" do
+ assert {:ok, output} =
+ Cmd.command("sh", ".", ["-c", "echo $MARKER"], env: [{"MARKER", "from-env"}])
+
+ assert output =~ "from-env"
+ end
+
+ test "runs in the given directory" do
+ dir = tmp_path("cwd")
+ File.mkdir_p!(dir)
+
+ assert {:ok, output} = Cmd.command("pwd", dir, [])
+ assert output |> String.trim() |> Path.expand() == Path.expand(dir)
+ end
+
+ test "writes the captured output to :log_to" do
+ log = tmp_path("out.log")
+
+ assert {:ok, _} = Cmd.command("sh", ".", ["-c", "echo logged"], log_to: log)
+
+ contents = File.read!(log)
+ assert contents =~ "logged"
+ assert contents =~ "exit 0"
+ end
+
+ test "on timeout it returns :timeout AND kills the subprocess" do
+ # The child writes `started`, sleeps, then writes `finished`. We time out during
+ # the sleep. If the subprocess is properly killed, `finished` is never written; if
+ # it leaks (the old Task.shutdown behavior), `finished` appears once the sleep ends.
+ started = tmp_path("started")
+ finished = tmp_path("finished")
+
+ script = "touch #{started}; sleep 2; touch #{finished}"
+
+ assert {:error, :timeout, output} = Cmd.command("sh", ".", ["-c", script], timeout: 1)
+ assert output =~ "killed"
+
+ # The process had time to start before the timeout fired.
+ assert File.exists?(started)
+
+ # Wait past the child's sleep; a killed process never reaches the second `touch`.
+ Process.sleep(2_500)
+ refute File.exists?(finished), "subprocess was not killed on timeout (it leaked)"
+ end
+
+ test "a command that finishes within the timeout is unaffected" do
+ assert {:ok, output} = Cmd.command("sh", ".", ["-c", "echo quick"], timeout: 30)
+ assert output =~ "quick"
+ end
+ end
+end
diff --git a/test/sentry/dev/lockfile_test.exs b/test/sentry/dev/lockfile_test.exs
new file mode 100644
index 00000000..c0f70e7e
--- /dev/null
+++ b/test/sentry/dev/lockfile_test.exs
@@ -0,0 +1,88 @@
+defmodule Sentry.Dev.LockfileTest do
+ use ExUnit.Case, async: true
+
+ alias Sentry.Dev.Lockfile
+
+ @hex_entry {:hex, :plug, "1.16.0", "hash", [:mix], [], "hexpm", "outer"}
+ @git_entry {:git, "https://github.com/example/heroicons.git", "abc123", [tag: "v2.1.1"]}
+
+ defp write_lock(contents) do
+ path =
+ Path.join(System.tmp_dir!(), "lockfile_test_#{System.unique_integer([:positive])}.lock")
+
+ File.write!(path, contents)
+ on_exit(fn -> File.rm(path) end)
+ path
+ end
+
+ describe "read/1" do
+ test "parses a lockfile into a map" do
+ path =
+ write_lock(~s|%{
+ "plug": {:hex, :plug, "1.16.0", "hash", [:mix], [], "hexpm", "outer"},
+ }|)
+
+ lock = Lockfile.read(path)
+ assert {:ok, "1.16.0"} = Lockfile.hex_version(lock["plug"])
+ end
+
+ test "parses git entries with keyword-list options" do
+ path =
+ write_lock(~s|%{
+ "heroicons": {:git, "https://github.com/example/heroicons.git", "abc123", [tag: "v2.1.1"]},
+ }|)
+
+ lock = Lockfile.read(path)
+ assert Lockfile.hex_version(lock["heroicons"]) == :not_hex
+ end
+
+ test "returns an empty map for a missing file" do
+ assert Lockfile.read(Path.join(System.tmp_dir!(), "does_not_exist.lock")) == %{}
+ end
+
+ test "rejects a non-literal lockfile instead of executing it" do
+ # If read/1 evaluated the file, this send/2 would deliver a message to the test
+ # process. It must raise on the non-literal expression and run nothing.
+ path = write_lock(~s|%{"x" => send(self(), :pwned)}|)
+
+ assert_raise ArgumentError, ~r/non-literal/, fn -> Lockfile.read(path) end
+ refute_received :pwned
+ end
+ end
+
+ describe "hex_version/1" do
+ test "extracts the version from hex entries" do
+ assert Lockfile.hex_version(@hex_entry) == {:ok, "1.16.0"}
+ end
+
+ test "returns :not_hex for git entries" do
+ assert Lockfile.hex_version(@git_entry) == :not_hex
+ end
+ end
+
+ describe "diff/2" do
+ test "reports only changed hex versions, sorted by dep, skipping git entries" do
+ old = %{
+ "plug" => {:hex, :plug, "1.16.0", "h", [:mix], [], "hexpm", "o"},
+ "jason" => {:hex, :jason, "1.4.0", "h", [:mix], [], "hexpm", "o"},
+ "heroicons" => @git_entry
+ }
+
+ new = %{
+ "plug" => {:hex, :plug, "1.17.0", "h", [:mix], [], "hexpm", "o"},
+ "jason" => {:hex, :jason, "1.4.0", "h", [:mix], [], "hexpm", "o"},
+ "heroicons" =>
+ {:git, "https://github.com/example/heroicons.git", "def456", [tag: "v2.2.0"]}
+ }
+
+ assert Lockfile.diff(old, new) == [%{dep: "plug", from: "1.16.0", to: "1.17.0"}]
+ end
+
+ test "reports newly added deps with from: nil" do
+ old = %{}
+ new = %{"plug" => {:hex, :plug, "1.17.0", "h", [:mix], [], "hexpm", "o"}}
+
+ assert Lockfile.diff(old, new) == [%{dep: "plug", from: nil, to: "1.17.0"}]
+ end
+ end
+end
diff --git a/test/sentry/dev/report_test.exs b/test/sentry/dev/report_test.exs
new file mode 100644
index 00000000..6325472e
--- /dev/null
+++ b/test/sentry/dev/report_test.exs
@@ -0,0 +1,82 @@
+defmodule Sentry.Dev.ReportTest do
+ use ExUnit.Case, async: true
+
+ alias Sentry.Dev.Report
+
+ defp result do
+ %{
+ optimistic_passed: false,
+ full_validation_passed: true,
+ overall_status: "partial",
+ options: %{allow_major: false},
+ projects: [
+ %{
+ name: "root",
+ dir: ".",
+ mix_env: "test",
+ optimistic_passed: false,
+ status: "partial",
+ bumped: [%{dep: "plug", from: "1.16.0", to: "1.17.0", also_changed: []}],
+ skipped_major: [
+ %{dep: "floki", from: "0.36.0", to: "0.37.0", reason: "0x_minor_breaking"}
+ ],
+ failed: [
+ %{
+ dep: "phoenix_live_view",
+ from: "0.20.0",
+ to: "1.0.0",
+ failure_type: "tests",
+ attributed_to: "root",
+ manifested_in: "phoenix_app",
+ kept_at: "0.20.0",
+ log_snippet: "boom"
+ }
+ ]
+ }
+ ]
+ }
+ end
+
+ describe "build/1" do
+ test "adds schema metadata and a summary with correct counts" do
+ report = Report.build(result())
+
+ assert report.schema_version == 1
+ assert is_binary(report.generated_at)
+ assert report.elixir_version == System.version()
+ assert report.summary.projects == 1
+ assert report.summary.bumped == 1
+ assert report.summary.skipped_major == 1
+ assert report.summary.failed == 1
+ assert report.summary.full_validation_passed == true
+ end
+ end
+
+ describe "encode/1" do
+ test "produces valid JSON that round-trips" do
+ report = Report.build(result())
+ json = Report.encode(report)
+
+ assert {:ok, decoded} = Jason.decode(json)
+ assert decoded["overall_status"] == "partial"
+ assert decoded["summary"]["bumped"] == 1
+ assert [project] = decoded["projects"]
+ assert project["name"] == "root"
+ assert hd(project["failed"])["failure_type"] == "tests"
+ end
+ end
+
+ describe "write/2" do
+ test "writes the encoded report to disk" do
+ path =
+ Path.join(System.tmp_dir!(), "report_test_#{System.unique_integer([:positive])}.json")
+
+ on_exit(fn -> File.rm(path) end)
+
+ Report.write(result() |> Report.build(), path)
+
+ assert File.exists?(path)
+ assert {:ok, _} = path |> File.read!() |> Jason.decode()
+ end
+ end
+end
diff --git a/test/sentry/dev/version_policy_test.exs b/test/sentry/dev/version_policy_test.exs
new file mode 100644
index 00000000..b957df96
--- /dev/null
+++ b/test/sentry/dev/version_policy_test.exs
@@ -0,0 +1,77 @@
+defmodule Sentry.Dev.VersionPolicyTest do
+ use ExUnit.Case, async: true
+
+ alias Sentry.Dev.VersionPolicy
+
+ defp opts(overrides \\ []) do
+ VersionPolicy.opts(
+ Keyword.merge(
+ [allow_major: false, allow_major_for: [], strict_0x: true],
+ overrides
+ )
+ )
+ end
+
+ describe "classify/3" do
+ test "classifies patch, minor, and major bumps for >= 1.0 versions" do
+ assert VersionPolicy.classify("1.2.3", "1.2.4", opts()) == :patch
+ assert VersionPolicy.classify("1.2.3", "1.3.0", opts()) == :minor
+ assert VersionPolicy.classify("1.2.3", "2.0.0", opts()) == :major
+ end
+
+ test "treats 0.x minor bumps as breaking by default" do
+ assert VersionPolicy.classify("0.20.0", "0.21.0", opts()) == :"0x_minor_breaking"
+ assert VersionPolicy.classify("0.20.0", "0.20.1", opts()) == :patch
+ end
+
+ test "treats 0.x minor bumps as a normal minor when strict_0x is disabled" do
+ assert VersionPolicy.classify("0.20.0", "0.21.0", opts(strict_0x: false)) == :minor
+ end
+
+ test "detects downgrades and equal versions" do
+ assert VersionPolicy.classify("1.3.0", "1.2.0", opts()) == :downgrade
+ assert VersionPolicy.classify("1.2.0", "1.2.0", opts()) == :downgrade
+ end
+
+ test "returns :unparseable for non-semver versions" do
+ assert VersionPolicy.classify("not-a-version", "1.0.0", opts()) == :unparseable
+ assert VersionPolicy.classify(nil, "1.0.0", opts()) == :unparseable
+ end
+ end
+
+ describe "breaking?/3" do
+ test "minor and patch bumps are not breaking" do
+ refute VersionPolicy.breaking?("1.2.3", "1.3.0", opts())
+ refute VersionPolicy.breaking?("1.2.3", "1.2.4", opts())
+ end
+
+ test "major and 0.x minor bumps are breaking" do
+ assert VersionPolicy.breaking?("1.0.0", "2.0.0", opts())
+ assert VersionPolicy.breaking?("0.20.0", "0.21.0", opts())
+ end
+
+ test "a new dependency (from nil) is never breaking" do
+ refute VersionPolicy.breaking?(nil, "2.0.0", opts())
+ end
+ end
+
+ describe "allowed?/4" do
+ test "non-breaking bumps are always allowed" do
+ assert VersionPolicy.allowed?("plug", "1.2.3", "1.3.0", opts())
+ end
+
+ test "breaking bumps are rejected by default" do
+ refute VersionPolicy.allowed?("floki", "0.36.0", "0.37.0", opts())
+ end
+
+ test "allow_major permits any breaking bump" do
+ assert VersionPolicy.allowed?("floki", "0.36.0", "0.37.0", opts(allow_major: true))
+ end
+
+ test "allow_major_for permits only the listed deps" do
+ o = opts(allow_major_for: ["opentelemetry"])
+ assert VersionPolicy.allowed?("opentelemetry", "1.0.0", "2.0.0", o)
+ refute VersionPolicy.allowed?("floki", "0.36.0", "0.37.0", o)
+ end
+ end
+end