Current section
Files
Jump to
Current section
Files
lib/mix/tasks/verify.release_parity.ex
defmodule Mix.Tasks.Verify.ReleaseParity do
@moduledoc """
Verifies that the file list inside the published Hex tarball for `X.Y.Z`
matches the file list at git tag `vX.Y.Z` over Relyra's full `package.files`
scope (lib/, priv/, docs/, guides/, and root artifacts).
## Usage
mix verify.release_parity 1.4.0
mix verify.release_parity 1.4.0 --json
## Exit codes
* `0` — parity (no drift)
* `2` — drift detected or `test_support` path in published tarball
* `1` — runtime error (network failure, missing tag, tarball fetch failure)
## Retry behavior
`RELYRA_RELEASE_VERIFY_ATTEMPTS` (default 10) and
`RELYRA_RELEASE_VERIFY_SLEEP_MS` (default 15_000) allow CDN propagation
retries after a freshly-cut release.
## Implementation notes
Path-set equality is the primary gate — outer `.tar` SHA256 may differ while
extracted contents match. Any published path containing `test_support` fails
closed before the path-set diff (TD-02 defense-in-depth).
"""
@shortdoc "Compare Hex tarball paths to git tag for a released version"
use Mix.Task
@default_attempts 10
@default_sleep_ms 15_000
@version_regex ~r/^\d+\.\d+\.\d+([.-][A-Za-z0-9.-]+)?$/
@dir_prefixes ~w(lib/ priv/ docs/ guides/)
@root_files ~w(
.formatter.exs
mix.exs
README.md
CONFORMANCE.md
CHANGELOG.md
LICENSE
SECURITY.md
SECURITY_REVIEW.md
SECURITY_REVIEW_EVIDENCE.md
BATTERIES_INCLUDED.md
)
@hex_metadata_file "hex_metadata.config"
@impl true
def run(args) do
Mix.Task.run("app.start")
{opts, argv, _invalid} = OptionParser.parse(args, strict: [json: :boolean])
version = parse_version!(argv)
attempts = env_integer("RELYRA_RELEASE_VERIFY_ATTEMPTS", @default_attempts)
sleep_ms = env_integer("RELYRA_RELEASE_VERIFY_SLEEP_MS", @default_sleep_ms)
tmp_root = unique_tmp_dir!()
try do
hex_paths_raw = fetch_hex_paths!(version, tmp_root, attempts, sleep_ms)
assert_no_test_support!(hex_paths_raw)
hex_paths = drop_hex_metadata(hex_paths_raw)
git_paths = fetch_git_paths!(version)
case compute(git_paths, hex_paths) do
:parity ->
emit_parity!(version, opts)
:ok
{:drift, only_in_git, only_in_hex} ->
emit_drift_and_halt!(version, only_in_git, only_in_hex, opts)
end
after
File.rm_rf(tmp_root)
end
end
@doc """
Pure path-set comparison. Returns `:parity` when the two MapSets are equal,
otherwise `{:drift, only_in_git_sorted, only_in_hex_sorted}`.
"""
@spec compute(MapSet.t(String.t()), MapSet.t(String.t())) ::
:parity | {:drift, [String.t()], [String.t()]}
def compute(%MapSet{} = git_paths, %MapSet{} = hex_paths) do
only_in_git =
git_paths |> MapSet.difference(hex_paths) |> MapSet.to_list() |> Enum.sort()
only_in_hex =
hex_paths |> MapSet.difference(git_paths) |> MapSet.to_list() |> Enum.sort()
case {only_in_git, only_in_hex} do
{[], []} -> :parity
{og, oh} -> {:drift, og, oh}
end
end
@doc """
Renders the `--json` output envelope. Parity renders as `"ok"`.
"""
@spec render_json(String.t(), :parity | :drift, [String.t()], [String.t()]) ::
String.t()
def render_json(version, status, only_in_git, only_in_hex)
when status in [:parity, :drift] do
status_str = if status == :parity, do: "ok", else: "drift"
Jason.encode!(%{
"version" => version,
"status" => status_str,
"only_in_git" => Enum.sort(only_in_git),
"only_in_hex" => Enum.sort(only_in_hex)
})
end
@doc """
Retry loop with env-configurable attempts and sleep between transient failures.
"""
@spec retry_until!(String.t(), pos_integer(), non_neg_integer(), (-> :ok | {:error, term()})) ::
:ok | no_return()
def retry_until!(label, attempts, sleep_ms, fun) do
Enum.reduce_while(1..attempts, nil, fn attempt, _acc ->
Mix.shell().info("==> #{label} (attempt #{attempt}/#{attempts})")
case fun.() do
:ok ->
{:halt, :ok}
{:error, reason} when attempt < attempts ->
Mix.shell().info(to_string(reason))
Process.sleep(sleep_ms)
{:cont, nil}
{:error, reason} ->
Mix.raise("#{label} failed after #{attempts} attempts\n\n#{reason}")
end
end)
end
@doc """
Validates the version argument against canonical semver before any subprocess.
"""
@spec parse_version!([String.t()]) :: String.t() | no_return()
def parse_version!([version]) when is_binary(version) and version != "" do
if Regex.match?(@version_regex, version) do
version
else
Mix.raise(
"verify.release_parity expects a semver version (e.g. 1.4.0 or 1.0.0-rc.1), got: #{inspect(version)}"
)
end
end
def parse_version!(_argv) do
Mix.raise(
"verify.release_parity expects exactly one version argument, e.g. mix verify.release_parity 1.4.0"
)
end
@doc """
Returns sorted path prefixes and root files matching `mix.exs` `package.files`.
"""
@spec expected_relative_paths() :: [String.t()]
def expected_relative_paths do
(@dir_prefixes ++ @root_files) |> Enum.sort()
end
@doc """
Filters relative paths to the `package.files` scope and excludes `test_support`.
"""
@spec filter_package_paths([String.t()]) :: [String.t()]
def filter_package_paths(paths) when is_list(paths) do
paths
|> Enum.reject(&test_support_path?/1)
|> Enum.filter(&package_path?/1)
|> Enum.sort()
end
@doc """
Returns true when any path contains `test_support`.
"""
@spec paths_contain_test_support?([String.t()]) :: boolean()
def paths_contain_test_support?(paths) when is_list(paths) do
Enum.any?(paths, &test_support_path?/1)
end
defp test_support_path?(path) when is_binary(path) do
String.contains?(path, "test_support")
end
@doc """
Hard-fails with exit 2 when published paths include `test_support`.
"""
@spec assert_no_test_support!([String.t()]) :: :ok | no_return()
def assert_no_test_support!(paths) when is_list(paths) do
if paths_contain_test_support?(paths) do
offenders =
paths
|> Enum.filter(&String.contains?(&1, "test_support"))
|> Enum.sort()
Mix.shell().error("""
Published tarball contains test_support paths (TD-02 violation):
#{Enum.map_join(offenders, "\n", &" #{&1}")}
""")
System.halt(2)
else
:ok
end
end
defp fetch_hex_paths!(version, tmp_root, attempts, sleep_ms) do
retry_until!("hex.package fetch relyra #{version}", attempts, sleep_ms, fn ->
do_fetch_hex(version, tmp_root)
end)
list_regular_files_recursive(tmp_root)
|> Enum.map(&Path.relative_to(&1, tmp_root))
|> filter_package_paths()
end
defp list_regular_files_recursive(root) do
case File.ls(root) do
{:ok, entries} ->
Enum.flat_map(entries, fn entry ->
path = Path.join(root, entry)
cond do
File.regular?(path) -> [path]
File.dir?(path) -> list_regular_files_recursive(path)
true -> []
end
end)
{:error, _} ->
[]
end
end
defp do_fetch_hex(version, tmp_root) do
{output, exit_status} =
System.cmd(
"mix",
["hex.package", "fetch", "relyra", version, "--unpack", "-o", tmp_root],
stderr_to_stdout: true
)
case exit_status do
0 ->
if File.exists?(Path.join(tmp_root, "mix.exs")) do
:ok
else
{:error,
"hex.package fetch succeeded but tmp_dir lacks mix.exs (empty tarball?):\n\n#{output}"}
end
_ ->
{:error, "mix hex.package fetch failed:\n\n#{output}"}
end
end
defp fetch_git_paths!(version) do
tag = "v#{version}"
{output, exit_status} =
System.cmd("git", ["ls-tree", "-r", "--name-only", tag], stderr_to_stdout: true)
case exit_status do
0 ->
paths =
output
|> String.split("\n", trim: true)
|> filter_package_paths()
|> MapSet.new()
if MapSet.size(paths) == 0 do
Mix.raise(
"git ls-tree for tag #{tag} returned no package paths. Does the tag exist?\n\nRun: git fetch --tags"
)
end
paths
_ ->
Mix.raise("git ls-tree failed for tag #{tag}:\n\n#{output}")
end
end
defp drop_hex_metadata(paths) do
paths
|> Enum.reject(&(&1 == @hex_metadata_file))
|> MapSet.new()
end
defp package_path?(path) do
Enum.any?(@dir_prefixes, &String.starts_with?(path, &1)) or path in @root_files
end
defp emit_parity!(version, opts) do
output =
if opts[:json] do
render_json(version, :parity, [], [])
else
"Release parity OK for relyra #{version}: tag v#{version} and Hex tarball agree on package.files scope."
end
Mix.shell().info(output)
end
@spec emit_drift_and_halt!(String.t(), [String.t()], [String.t()], keyword()) :: no_return()
defp emit_drift_and_halt!(version, only_in_git, only_in_hex, opts) do
output =
if opts[:json] do
render_json(version, :drift, only_in_git, only_in_hex)
else
human_diff(version, only_in_git, only_in_hex)
end
Mix.shell().info(output)
System.halt(2)
end
defp human_diff(version, only_in_git, only_in_hex) do
"""
Release parity drift detected for relyra #{version}:
#{length(only_in_git)} files only in git tag (missing from Hex tarball)
#{length(only_in_hex)} files only in Hex tarball (not in git tag)
Only in git tag (missing from Hex tarball):
#{format_paths(only_in_git)}
Only in Hex tarball (not in git tag):
#{format_paths(only_in_hex)}
"""
end
defp format_paths([]), do: " (none)"
defp format_paths(paths) do
Enum.map_join(paths, "\n", &" #{&1}")
end
defp unique_tmp_dir! do
path =
Path.join(
System.tmp_dir!(),
"relyra-release-parity-#{System.unique_integer([:positive])}"
)
File.mkdir_p!(path)
path
end
defp env_integer(name, default) do
case System.get_env(name) do
nil ->
default
value ->
case Integer.parse(value) do
{parsed, ""} when parsed > 0 -> parsed
_ -> Mix.raise("#{name} must be a positive integer, got: #{inspect(value)}")
end
end
end
end