# `Tinfoil.Publish`
[🔗](https://github.com/joshrotenberg/tinfoil/blob/v0.2.22/lib/tinfoil/publish.ex#L1)

Create a GitHub Release and upload archive assets to it.

Tinfoil's own replacement for `softprops/action-gh-release` in the
generated workflow. It uses [`Req`](https://hex.pm/packages/req) to
talk to GitHub's REST API directly, so the release lifecycle
(create → upload assets → handle existing releases) happens inside
the tool rather than inside CI-specific third-party actions.

The generated workflow calls this module via `mix tinfoil.publish`
once, after the build matrix finishes, on a single `ubuntu-latest`
runner.

Homebrew formula publishing is handled separately by
`Tinfoil.Homebrew` -- the generated workflow runs
`mix tinfoil.homebrew` in a follow-up job once the GitHub Release
exists.

# `error`

```elixir
@type error() ::
  :missing_github_token
  | :missing_tag
  | :release_already_exists_no_replace
  | :release_not_found_for_attach
  | :attach_and_replace
  | {:missing_input_dir, Path.t()}
  | {:create_release_failed, non_neg_integer(), term()}
  | {:find_release_failed, non_neg_integer(), term()}
  | {:delete_release_failed, non_neg_integer(), term()}
  | {:upload_failed, String.t(), non_neg_integer(), term()}
  | {:create_release_error, term()}
  | {:find_release_error, term()}
  | {:delete_release_error, term()}
  | {:upload_error, String.t(), term()}
  | String.t()
```

Known error atoms returned by `publish/2`. Callers can pattern-match
on these; the catch-all is `{:error, term()}` for unexpected failures.

  * `:missing_github_token` -- no `GITHUB_TOKEN` / `GH_TOKEN` env var
  * `:missing_tag` -- no tag given and `GITHUB_REF_NAME` is unset
  * `:release_already_exists_no_replace` -- release for the tag
    already exists and neither `attach: true` nor `replace: true`
    was passed
  * `:release_not_found_for_attach` -- `attach: true` was passed but
    no release exists for the tag
  * `:attach_and_replace` -- both `attach: true` and `replace: true`
    were passed; they are mutually exclusive
  * `{:missing_input_dir, dir}` -- input directory doesn't exist
  * `{:create_release_failed, status, body}` -- GitHub API refused
    the release create (non-201, non-422)
  * `{:find_release_failed, status, body}` -- lookup for existing
    release during `--replace` failed
  * `{:delete_release_failed, status, body}` -- delete during
    `--replace` failed
  * `{:upload_failed, name, status, body}` -- asset upload returned
    non-2xx
  * `{:create_release_error, reason}` -- transport failure (no HTTP
    response) while creating the release
  * `{:find_release_error, reason}` -- transport failure while
    looking up the existing release during `--replace`
  * `{:delete_release_error, reason}` -- transport failure while
    deleting the existing release during `--replace`
  * `{:upload_error, name, reason}` -- asset upload transport failure
  * `"... :github :repo is unresolved ..."` (string) -- the tinfoil
    `github.repo` config isn't set and couldn't be inferred from git

# `mode`

```elixir
@type mode() :: :create | :attach | :replace
```

How `publish/2` obtains the release it uploads to.

  * `:create` -- create a new release for the tag (default)
  * `:attach` -- upload to a release something else already created
  * `:replace` -- delete the existing release and create a fresh one

# `opts`

```elixir
@type opts() :: [
  input_dir: Path.t(),
  tag: String.t() | nil,
  draft: boolean() | nil,
  replace: boolean() | nil,
  attach: boolean() | nil,
  dry_run: boolean() | nil,
  req: Req.Request.t() | nil
]
```

# `preview`

```elixir
@type preview() :: %{
  dry_run: true,
  repo: String.t(),
  tag: String.t(),
  mode: mode(),
  draft: boolean(),
  prerelease: boolean(),
  replace: boolean(),
  attach: boolean(),
  assets: [%{name: String.t(), path: Path.t(), size: non_neg_integer()}]
}
```

# `result`

```elixir
@type result() :: %{
  mode: mode(),
  release_id: integer(),
  html_url: String.t(),
  uploaded: [String.t()]
}
```

# `publish`

```elixir
@spec publish(Tinfoil.Config.t(), opts()) ::
  {:ok, result()} | {:ok, preview()} | {:error, error() | term()}
```

Publish release archives from `input_dir` (default `"artifacts"`) to
a new GitHub Release on the repo configured in the tinfoil config.

The set of assets uploaded is every `*.tar.gz` or `*.zip` in
`input_dir`, plus the combined `checksums-sha256.txt` produced from
their `.sha256` sidecars.

## Authentication

Requires a `GITHUB_TOKEN` environment variable (or a `GH_TOKEN`
fallback) with `contents: write` permission on the target repo.

## Tag

The tag to release against is taken from `opts[:tag]` if given,
otherwise from the `GITHUB_REF_NAME` environment variable (which
CI sets automatically on tag pushes). Versions matching `-rc`,
`-beta`, or `-alpha` are auto-marked as prerelease.

## Existing releases

By default, if a release for `tag` already exists, `publish/2`
returns `{:error, :release_already_exists_no_replace}` without
touching the existing release or its assets — failing fast is
safer than silently clobbering something a user already shipped.

Two escape hatches, which are mutually exclusive:

`attach: true` (or `--attach`) uploads to the release that already
exists and changes nothing about it — body, prerelease flag, and
draft state are left exactly as the other tool wrote them. This is
the mode for projects where something else owns release creation:
release-please tags the version, creates the release, and writes the
curated changelog as the body, and tinfoil only adds the binaries.
It errors with `:release_not_found_for_attach` when no release
exists, since attaching to nothing is always a mistake.

`replace: true` (or `--replace`) deletes and recreates the existing
release, which discards whatever body it had. The git tag itself is
never touched; only the release object and its attached assets are
removed before the new release is created. Use this for development
and force-retag iteration loops, not for published versions.

Attach mode only makes sense when the workflow runs *after* the
release exists. Set `trigger: :release_published` in the tinfoil
config so the generated workflow fires on the release event rather
than racing it on the tag push.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
