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