Current section

Files

Jump to
tinfoil lib mix tasks tinfoil.publish.ex
Raw

lib/mix/tasks/tinfoil.publish.ex

defmodule Mix.Tasks.Tinfoil.Publish do
@shortdoc "Create a GitHub Release and upload archive assets to it"
@moduledoc """
Create a GitHub Release for the current tag and upload every
archive and checksum file found under the input directory.
Designed to run on a single CI runner after all matrix builds
finish and archives are downloaded into one place. The generated
workflow calls it this way:
- name: Publish
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: mix tinfoil.publish --input-dir artifacts
## Auth
Requires `GITHUB_TOKEN` (GitHub Actions provides this automatically)
with `contents: write` permission on the target repository.
## Flags
* `--input-dir` — directory containing archives + sidecars
(default `artifacts`)
* `--tag` — the release tag, e.g. `v1.2.3`. Defaults to
`GITHUB_REF_NAME`, which CI sets for tag pushes.
* `--draft` — create the release as a draft
* `--attach` — upload to the release that already exists for
this tag instead of creating one, leaving its
body, prerelease flag, and draft state alone.
Errors if no release exists. For projects where
something else (release-please, changesets)
already creates the release.
* `--replace` — if a release for this tag already exists,
delete it (and its assets) and create a fresh
one. The git tag itself is untouched. Intended
for development / force-retag iteration loops,
not for released versions.
* `--dry-run` — print what would be published (repo, tag,
draft/prerelease flags, asset list + sizes)
and exit without touching the GitHub API.
`--attach` and `--replace` are mutually exclusive: one preserves the
existing release, the other destroys it.
Pre-release detection is automatic: tags containing `-rc`, `-beta`,
or `-alpha` are marked as prerelease in the created release. In
`--attach` mode the existing release's prerelease flag is left as
whatever the creating tool set.
"""
use Mix.Task
alias Tinfoil.{Config, Publish}
@impl Mix.Task
def run(argv) do
{opts, _, _} =
OptionParser.parse(argv,
switches: [
input_dir: :string,
tag: :string,
draft: :boolean,
replace: :boolean,
attach: :boolean,
dry_run: :boolean
]
)
config =
case Config.load(Mix.Project.config()) do
{:ok, c} -> c
{:error, reason} -> Mix.raise("tinfoil config error: #{inspect(reason)}")
end
publish_opts = Keyword.take(opts, [:input_dir, :tag, :draft, :replace, :attach, :dry_run])
case Publish.publish(config, publish_opts) do
{:ok, %{dry_run: true} = preview} ->
report_preview(preview)
{:ok, result} ->
report(result)
{:error, reason} ->
Mix.raise("tinfoil publish failed: #{format_error(reason)}")
end
end
defp report(result) do
Mix.shell().info([
:green,
"* #{describe_mode(result.mode)} ",
:reset,
result.html_url,
"\n",
:green,
"* uploaded ",
:reset,
"#{length(result.uploaded)} assets"
])
Enum.each(result.uploaded, fn name ->
Mix.shell().info([:green, " - ", :reset, name])
end)
end
defp report_preview(preview) do
Mix.shell().info([:cyan, "tinfoil publish (dry-run)\n", :reset])
Mix.shell().info(" repo: #{preview.repo}")
Mix.shell().info(" tag: #{preview.tag}")
Mix.shell().info(" mode: #{preview.mode}")
Mix.shell().info(" draft: #{release_field(preview, preview.draft)}")
Mix.shell().info(" prerelease: #{release_field(preview, preview.prerelease)}")
Mix.shell().info(" assets:")
Enum.each(preview.assets, fn asset ->
Mix.shell().info(" - #{asset.name} (#{format_size(asset.size)})")
end)
Mix.shell().info("\n no GitHub API calls made")
end
defp describe_mode(:create), do: "created release"
defp describe_mode(:attach), do: "attached to release"
defp describe_mode(:replace), do: "replaced release"
# Attach mode never writes these fields, so reporting a computed value
# would imply tinfoil is about to set something it will not touch.
defp release_field(%{mode: :attach}, _value), do: "(unchanged)"
defp release_field(_preview, value), do: to_string(value)
defp format_size(bytes) when bytes >= 1_048_576, do: "#{div(bytes, 1_048_576)} MB"
defp format_size(bytes) when bytes >= 1024, do: "#{div(bytes, 1024)} KB"
defp format_size(bytes), do: "#{bytes} B"
defp format_error(:missing_github_token),
do: "GITHUB_TOKEN (or GH_TOKEN) environment variable is not set"
defp format_error(:missing_tag),
do: "no tag given (--tag) and GITHUB_REF_NAME is not set"
defp format_error({:missing_input_dir, dir}),
do: "input directory #{inspect(dir)} does not exist"
defp format_error({:create_release_failed, status, body}),
do: "GitHub refused to create the release (HTTP #{status}): #{inspect(body)}"
defp format_error(:release_already_exists_no_replace),
do:
"a release for this tag already exists on GitHub. " <>
"Re-run with --attach to upload the assets to it and leave the " <>
"release itself alone, with --replace to delete and recreate it, " <>
"or push a new tag."
defp format_error(:release_not_found_for_attach),
do:
"--attach was given but no release exists for this tag. " <>
"Attach mode expects another tool (release-please, changesets) to " <>
"have created it. If the workflow triggers on the tag push it can " <>
"outrun that tool; set `trigger: :release_published` in your " <>
":tinfoil config and regenerate so it fires on the release event."
defp format_error(:attach_and_replace),
do:
"--attach and --replace are mutually exclusive: --attach preserves " <>
"the existing release, --replace deletes it. Pick one."
defp format_error({:find_release_failed, status, body}),
do:
"couldn't look up the existing release for replacement (HTTP #{status}): " <>
inspect(body)
defp format_error({:delete_release_failed, status, body}),
do:
"couldn't delete the existing release during --replace (HTTP #{status}): " <>
inspect(body)
defp format_error({:upload_failed, name, status, body}),
do: "upload of #{name} failed (HTTP #{status}): #{inspect(body)}"
defp format_error({:create_release_error, reason}),
do: "network error while creating the release: #{inspect(reason)}"
defp format_error({:find_release_error, reason}),
do:
"network error while looking up the existing release during --replace: " <>
inspect(reason)
defp format_error({:delete_release_error, reason}),
do:
"network error while deleting the existing release during --replace: " <>
inspect(reason)
defp format_error({:upload_error, name, reason}),
do: "network error while uploading #{name}: #{inspect(reason)}"
defp format_error(other), do: inspect(other)
end