Releaser.Publisher (releaser v0.0.6)

Copy Markdown View Source

Orchestrates publishing multiple apps to Hex in topological order.

For each app (in dependency order):

  1. Backs up the original mix.exs
  2. Replaces path: deps with their published Hex versions (~> X.Y)
  3. Injects package/0 metadata if missing
  4. Runs mix hex.publish --yes
  5. Restores the original mix.exs (always, even on failure)

Blocking detection

An app marked releaser: [publish: true] cannot actually be published to Hex if any of its direct or transitive internal deps is publish: false — the resulting Hex package would reference a path dep that has no Hex counterpart.

plan/1 detects this and excludes blocked apps from the publish plan, surfacing them in the :skipped list with reason: :blocked_by_deps and a :blocked_by field listing the IMMEDIATE non-publishable causes.

Two public helpers are exposed for external consumers (e.g. mix releaser.graph):

Both run an iterative worklist (fixed-point over the blocked set) so cycles among publishable apps terminate, and transitive blocking is discovered level by level. Worst case is O(apps * deps) per iteration with at most length(apps) iterations — i.e. cubic in app count, which is fine for monorepos with hundreds of apps.

Summary

Functions

Returns the set of publishable app names that cannot be published because at least one direct or transitive internal dep is non-publishable (publish: false).

Returns a map %{blocked_app_name => [immediate_blocking_dep_name, ...]}.

Executes the publish flow.

Plans the publish order and returns a list of levels with app info.

Restores backed-up mix.exs files.

Functions

blocked_names(apps)

@spec blocked_names([Releaser.App.t()]) :: MapSet.t(String.t())

Returns the set of publishable app names that cannot be published because at least one direct or transitive internal dep is non-publishable (publish: false).

Apps with publish: false are NEVER members of the returned set — they are causes, not effects. Cycles among publishable apps terminate via fixed-point iteration (the blocked set is monotone-growing and bounded by length(apps)).

blocked_with_reasons(apps)

@spec blocked_with_reasons([Releaser.App.t()]) :: %{
  required(String.t()) => [String.t()]
}

Returns a map %{blocked_app_name => [immediate_blocking_dep_name, ...]}.

Same algorithm as blocked_names/1, but exposes WHY each blocked app is blocked. The list contains only deps from the app's own :deps field that are themselves either publish: false OR already known to be blocked. It is the IMMEDIATE cause, NOT the transitive root.

ensure_package_config(content, pkg_defaults)

execute(opts \\ [])

Executes the publish flow.

plan(opts \\ [])

Plans the publish order and returns a list of levels with app info.

Does not modify anything. Filters out apps whose local version is already on Hex (status == :published), so publish is idempotent.

Returns a map with:

  • :levels — topological levels after filtering (only apps that need publishing)
  • :apps — publishable apps that survived blocking and Hex status checks
  • :graph — dep graph
  • :skipped — apps filtered out, each entry %{app: name, local: v, hex: v, reason: r} where r is one of:
    • :already_published / :prerelease — Hex-status driven
    • :blocked_by_deps — at least one direct or transitive internal dep is non-publishable. Carries an extra :blocked_by field with the IMMEDIATE blocking dep names.

replace_path_dep(content, dep_name, dep_version)

restore(backups)

Restores backed-up mix.exs files.