Skip to main content

src/version_bump/engine.gleam

//// The release pipeline orchestrator.
////
//// `run` wires the leaf modules (git, branches, commit_parser), the plugin
//// registry, and the hook runners into semantic-release's lifecycle:
////
////   1. resolve the current branch and build the shared `Context`
////   2. resolve each configured plugin against the registry
////   3. verify_conditions
////   4. find the last release from the git tags
////   5. read & parse the commits since that release
////   6. analyze_commits -> a release type (or stop: "no release")
////   7. compute the next version and build the `NextRelease`
////   8. verify_release
////   9. generate_notes -> attach to the next release
////   10. (dry-run) report and stop
////   11. prepare, then create & push the git tag
////   12. publish -> collect the produced releases
////   13. success
////
//// Any error after `verify_conditions` triggers the plugins' `fail` hooks
//// before the error is returned to the caller.

import gleam/dict.{type Dict}
import gleam/int
import gleam/list
import gleam/option.{type Option, None, Some}
import gleam/result
import gleam/string
import version_bump/branch
import version_bump/commit_parser
import version_bump/config.{type Config}
import version_bump/context.{type Context, Context}
import version_bump/error.{type ReleaseError, ConfigError}
import version_bump/git
import version_bump/logging
import version_bump/registry
import version_bump/release.{type LastRelease, type Release, NextRelease}
import version_bump/runner.{type ResolvedPlugin}
import version_bump/semver.{type ReleaseType}
import version_bump/task.{type Task}

/// The outcome of a pipeline run.
///
/// `released` is `False` when nothing was published — either no commits
/// warranted a release, or the run was a dry-run. `version`/`notes` carry the
/// computed next release details when one was determined (even in a dry-run, so
/// callers can preview them); `releases` holds the artifacts published by the
/// `publish` hooks.
pub type Summary {
  Summary(
    released: Bool,
    version: Option(String),
    notes: Option(String),
    releases: List(Release),
  )
}

/// The default remote releases are pushed to.
const remote = "origin"

/// Run the full release pipeline for the project rooted at `cwd`.
///
/// Returns a `Task` because the `publish` stage is asynchronous: on Erlang the
/// task is synchronous; on JavaScript it is a promise. Callers run it with
/// `task.run`.
pub fn run(
  config: Config,
  cwd: String,
  env: Dict(String, String),
) -> Task(Result(Summary, ReleaseError)) {
  // Steps 1 & 2 happen before any plugin runs, so their failures are returned
  // directly without invoking `fail` hooks.
  case build_context(config, cwd, env) {
    Error(err) -> task.resolve(Error(err))
    Ok(ctx0) ->
      case resolve_plugins(config, ctx0) {
        Error(err) -> task.resolve(Error(err))
        // Everything from verify_conditions onward runs `fail` hooks on error.
        Ok(plugins) -> run_pipeline(config, ctx0, plugins)
      }
  }
}

// --- Setup (pre-plugin) -----------------------------------------------------

/// Resolve branches against the repository and build the initial context.
fn build_context(
  config: Config,
  cwd: String,
  env: Dict(String, String),
) -> Result(Context, ReleaseError) {
  use git_branches <- result.try(git.list_branches(cwd))
  use current <- result.try(git.current_branch(cwd))
  use #(branch, all_branches) <- result.try(branch.resolve(
    config,
    git_branches,
    current,
  ))
  logging.info("Running on branch '" <> branch.name <> "'")
  Ok(context.new(
    cwd: cwd,
    env: env,
    config: config,
    branch: branch,
    branches: all_branches,
  ))
}

/// Zip each configured `PluginSpec` with its registry `Plugin`, failing with a
/// `ConfigError` for any spec whose name is not a known built-in plugin.
fn resolve_plugins(
  config: Config,
  _ctx: Context,
) -> Result(List(ResolvedPlugin), ReleaseError) {
  let known = registry.default()
  list.try_map(config.plugins, fn(spec) {
    case dict.get(known, spec.name) {
      Ok(plugin) -> Ok(#(spec, plugin))
      Error(_) -> Error(ConfigError("Unknown plugin '" <> spec.name <> "'"))
    }
  })
}

// --- Pipeline (post-verify_conditions runs `fail` on error) -----------------

/// Drive the verify -> analyze -> prepare -> publish -> success pipeline. On
/// any error from `verify_conditions` onward, the plugins' `fail` hooks run
/// before the error is propagated. Asynchronous (publish), hence a `Task`.
fn run_pipeline(
  config: Config,
  context: Context,
  plugins: List(ResolvedPlugin),
) -> Task(Result(Summary, ReleaseError)) {
  use result <- task.map(pipeline(config, context, plugins))
  case result {
    Ok(summary) -> Ok(summary)
    Error(err) -> {
      logging.error(error.to_string(err))
      // Run fail hooks for effect; their own failure must not mask the original.
      let _ = runner.run_fail(plugins, context)
      Error(err)
    }
  }
}

/// The synchronous decision of whether (and what) to release. Either the run is
/// complete (`Halt`, e.g. no release or a dry-run) or it is ready to perform the
/// asynchronous publish tail (`Ready`).
type SyncOutcome {
  Halt(summary: Summary)
  Ready(context: Context, version: String, git_tag: String, notes: String)
}

/// The pipeline body. The synchronous stages (3-10) run in `sync_pipeline`;
/// only the publish tail is asynchronous, so it is lifted into a `Task` here.
fn pipeline(
  config: Config,
  context: Context,
  plugins: List(ResolvedPlugin),
) -> Task(Result(Summary, ReleaseError)) {
  case sync_pipeline(config, context, plugins) {
    Error(err) -> task.resolve(Error(err))
    Ok(Halt(summary)) -> task.resolve(Ok(summary))
    Ok(Ready(context, version, git_tag, notes)) ->
      finalize_release(context, plugins, version, git_tag, notes)
  }
}

/// The fully synchronous part of the pipeline: verify_conditions, find the last
/// release, read & analyze commits, compute the next version, verify_release,
/// generate notes, and apply the dry-run short-circuit.
fn sync_pipeline(
  config: Config,
  context: Context,
  plugins: List(ResolvedPlugin),
) -> Result(SyncOutcome, ReleaseError) {
  // 3) verify_conditions
  logging.info("Verifying conditions")
  use _ <- result.try(runner.run_verify_conditions(plugins, context))

  // 4) last release from the tags
  use last_release <- result.try(resolve_last_release(config, context))
  let context = Context(..context, last_release: last_release)
  log_last_release(last_release)

  // 5) commits since the last release
  use context <- result.try(load_commits(context, last_release))

  // 6) analyze commits
  logging.info("Analyzing commits")
  use release_type <- result.try(runner.run_analyze_commits(plugins, context))

  case release_type {
    None -> {
      logging.info(
        "There are no relevant changes, so no new version is released",
      )
      Ok(
        Halt(Summary(released: False, version: None, notes: None, releases: [])),
      )
    }
    Some(rtype) -> sync_continue(config, context, plugins, last_release, rtype)
  }
}

/// Synchronous continuation once a release is warranted: compute the version,
/// verify the release, generate notes, then decide between a dry-run halt and a
/// ready-to-publish outcome.
fn sync_continue(
  config: Config,
  context: Context,
  plugins: List(ResolvedPlugin),
  last_release: Option(LastRelease),
  rtype: ReleaseType,
) -> Result(SyncOutcome, ReleaseError) {
  // 7) next version & NextRelease
  use version <- result.try(branch.next_version(
    last_release,
    rtype,
    context.branch,
    config.versioning_mode,
  ))
  use head <- result.try(git.head_sha(context.cwd))
  let git_tag = render_tag(config.tag_format, version)
  let next =
    NextRelease(
      version: version,
      type_: rtype,
      git_tag: git_tag,
      git_head: head,
      channel: context.branch.channel,
      notes: "",
    )
  let context = Context(..context, next_release: Some(next))
  logging.info(
    "The next release version is "
    <> version
    <> " ("
    <> semver.release_type_to_string(rtype)
    <> ")",
  )

  // 8) verify_release
  logging.info("Verifying release")
  use _ <- result.try(runner.run_verify_release(plugins, context))

  // 9) generate notes
  logging.info("Generating release notes")
  use notes <- result.try(runner.run_generate_notes(plugins, context))
  let next = NextRelease(..next, notes: notes)
  let context = Context(..context, next_release: Some(next))

  // 10) dry-run short-circuit
  case config.dry_run {
    True -> {
      logging.warn("Dry-run: skipping prepare, tag, publish and success")
      logging.info("Release note for version " <> version <> ":")
      logging.info(notes)
      Ok(
        Halt(
          Summary(
            released: False,
            version: Some(version),
            notes: Some(notes),
            releases: [],
          ),
        ),
      )
    }
    False ->
      Ok(Ready(
        context: context,
        version: version,
        git_tag: git_tag,
        notes: notes,
      ))
  }
}

/// The asynchronous effecting tail: prepare & tag (synchronous), then publish
/// (asynchronous) and success.
fn finalize_release(
  context: Context,
  plugins: List(ResolvedPlugin),
  version: String,
  git_tag: String,
  notes: String,
) -> Task(Result(Summary, ReleaseError)) {
  // 11) prepare, then create & push the tag (all synchronous)
  case prepare_and_tag(context, plugins, version, git_tag) {
    Error(err) -> task.resolve(Error(err))
    Ok(Nil) -> {
      // 12) publish (asynchronous)
      logging.info("Publishing release")
      use published <- task.map(runner.run_publish(plugins, context))
      case published {
        Error(err) -> Error(err)
        Ok(releases) -> {
          let context = Context(..context, releases: releases)
          // 13) success
          logging.info("Running success hooks")
          case runner.run_success(plugins, context) {
            Error(err) -> Error(err)
            Ok(Nil) -> {
              logging.success("Published release " <> version)
              Ok(Summary(
                released: True,
                version: Some(version),
                notes: Some(notes),
                releases: releases,
              ))
            }
          }
        }
      }
    }
  }
}

/// Run `prepare`, then create and push the git tag. All synchronous.
fn prepare_and_tag(
  context: Context,
  plugins: List(ResolvedPlugin),
  version: String,
  git_tag: String,
) -> Result(Nil, ReleaseError) {
  logging.info("Preparing release")
  use _ <- result.try(runner.run_prepare(plugins, context))

  logging.info("Creating git tag " <> git_tag)
  use _ <- result.try(git.create_tag(context.cwd, git_tag, version))

  // Push the branch first (so a release commit made by the `git` plugin lands on
  // it), then the tag — which now points at that commit. With no `git` plugin
  // configured the branch push is a harmless no-op (HEAD is unchanged).
  use _ <- result.try(git.push(
    context.cwd,
    remote,
    "HEAD:" <> context.branch.name,
  ))
  git.push(context.cwd, remote, git_tag)
}

// --- helpers ----------------------------------------------------------------

/// Read the repository's tags and pick the last release for the current branch.
fn resolve_last_release(
  config: Config,
  context: Context,
) -> Result(Option(LastRelease), ReleaseError) {
  use tags <- result.map(git.get_tags(context.cwd))
  branch.last_release(tags, context.branch, config.tag_format)
}

/// Load the commits since the last release into the context, parsing each into
/// a `ConventionalCommit`.
fn load_commits(
  context: Context,
  last_release: Option(LastRelease),
) -> Result(Context, ReleaseError) {
  let from = case last_release {
    Some(release) ->
      case string.trim(release.git_head) {
        "" -> option_from_tag(release)
        head -> Some(head)
      }
    None -> None
  }
  use commits <- result.map(git.log_since(context.cwd, from))
  let parsed = list.map(commits, commit_parser.parse)
  logging.info("Found " <> int.to_string(list.length(parsed)) <> " commit(s)")
  Context(..context, commits: parsed)
}

/// The git_head to range from, defaulting to the tag when no SHA was recorded.
fn option_from_tag(release: LastRelease) -> Option(String) {
  case string.trim(release.git_tag) {
    "" -> None
    tag -> Some(tag)
  }
}

/// Render a tag from a `tag_format` by substituting the version placeholder.
fn render_tag(tag_format: String, version: String) -> String {
  string.replace(tag_format, "${version}", version)
}

/// Log the discovered last release, or note that this is the first one.
fn log_last_release(last_release: Option(LastRelease)) -> Nil {
  case last_release {
    Some(release) -> logging.info("Found previous release " <> release.version)
    None -> logging.info("No previous release found")
  }
}