Current section
Files
Jump to
Current section
Files
lib/mix/tasks/verify.release_publish.ex
defmodule Mix.Tasks.Verify.ReleasePublish do
use Mix.Task
@shortdoc "Verifies the live Hex publish for a released Scrypath version"
@moduledoc """
Verifies the live publish contract for a released Scrypath version.
This task is intended for the real post-publish workflow, not the always-on CI
gate. It polls Hex package availability, compiles a clean consumer app against
the published Hex version, and confirms the versioned HexDocs URL responds.
"""
@default_attempts 10
@default_sleep_ms 15_000
@impl true
def run(args) do
Mix.Task.run("app.start")
version = parse_version!(args)
attempts = env_integer("SCRYPATH_RELEASE_VERIFY_ATTEMPTS", @default_attempts)
sleep_ms = env_integer("SCRYPATH_RELEASE_VERIFY_SLEEP_MS", @default_sleep_ms)
hexdocs_url = "https://hexdocs.pm/scrypath/#{version}"
Mix.shell().info("==> Verifying live Hex publish for Scrypath #{version}")
retry_until!(
"Hex package availability",
attempts,
sleep_ms,
fn -> published_package_available?(version) end
)
retry_until!(
"published package consumer smoke",
attempts,
sleep_ms,
fn -> published_consumer_smoke_passes?(version) end
)
retry_until!(
"versioned HexDocs availability",
attempts,
sleep_ms,
fn -> hexdocs_available?(hexdocs_url) end
)
end
defp parse_version!([version]) when version != "", do: version
defp parse_version!(_args) do
Mix.raise(
"verify.release_publish expects exactly one version argument, e.g. mix verify.release_publish 0.1.0"
)
end
defp 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(reason)
Process.sleep(sleep_ms)
{:cont, nil}
{:error, reason} ->
Mix.raise("#{label} failed after #{attempts} attempts\n\n#{reason}")
end
end)
end
defp published_package_available?(version) do
case System.cmd("mix", ["hex.info", "scrypath"], stderr_to_stdout: true) do
{output, 0} ->
if String.contains?(output, version) do
:ok
else
{:error, "mix hex.info scrypath does not list #{version} yet\n\n#{output}"}
end
{output, _status} ->
{:error, "mix hex.info scrypath failed\n\n#{output}"}
end
end
defp published_consumer_smoke_passes?(version) do
tmp_root = unique_tmp_dir!()
app_dir = Path.join(tmp_root, "scrypath_consumer")
try do
run_command!("mix", ["new", "scrypath_consumer", "--module", "ScrypathConsumer"],
cd: tmp_root
)
File.write!(Path.join(app_dir, "mix.exs"), consumer_mix_exs(version))
write_consumer_schema!(app_dir)
run_command!("mix", ["deps.get"], cd: app_dir)
run_command!("mix", ["compile"], cd: app_dir)
beam_path =
Path.join(
app_dir,
"_build/dev/lib/scrypath_consumer/ebin/Elixir.ScrypathConsumer.Post.beam"
)
if File.exists?(beam_path) do
:ok
else
{:error, "consumer smoke compile succeeded but #{beam_path} was not created"}
end
after
File.rm_rf(tmp_root)
end
rescue
error in Mix.Error -> {:error, Exception.message(error)}
end
defp hexdocs_available?(url) do
case System.cmd("curl", ["-IfsS", url], stderr_to_stdout: true) do
{_output, 0} -> :ok
{output, _status} -> {:error, "curl could not reach #{url}\n\n#{output}"}
end
end
defp consumer_mix_exs(version) do
"""
defmodule ScrypathConsumer.MixProject do
use Mix.Project
def project do
[
app: :scrypath_consumer,
version: "0.1.0",
elixir: "~> 1.17",
start_permanent: Mix.env() == :prod,
deps: deps()
]
end
def application do
[extra_applications: [:logger]]
end
defp deps do
[
{:ecto, "~> 3.13"},
{:scrypath, "~> #{version}"}
]
end
end
"""
end
defp write_consumer_schema!(app_dir) do
schema_dir = Path.join(app_dir, "lib/scrypath_consumer")
File.mkdir_p!(schema_dir)
File.write!(Path.join(schema_dir, "post.ex"), consumer_schema())
end
defp consumer_schema do
"""
defmodule ScrypathConsumer.Post do
use Ecto.Schema
use Scrypath,
fields: [:title, :body],
filterable: [:status],
sortable: [:inserted_at]
schema "posts" do
field :title, :string
field :body, :string
field :status, Ecto.Enum, values: [:draft, :published]
timestamps()
end
end
"""
end
defp run_command!(command, args, opts) do
{output, exit_status} =
System.cmd(command, args, Keyword.merge([stderr_to_stdout: true], opts))
if exit_status == 0 do
output
else
raise Mix.Error,
"""
command failed: #{command} #{Enum.join(args, " ")}
#{output}
"""
end
end
defp unique_tmp_dir! do
path =
Path.join(
System.tmp_dir!(),
"scrypath-release-publish-#{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