use crate::action::{Action, ActionRunner};
use crate::commands::Outcome;
use crate::fs_util;
use crate::git::Git;
use crate::github::{GitHub, Release};
use crate::manifest::{Manifest, ManifestKind};
use crate::npm;
use crate::package::Package;
use crate::registry::Registries;
use crate::release_record::ReleaseRecord;
use crate::workspace::Workspace;
use anyhow::{Context, Result, anyhow, ensure};
use clap::Args;
use semver::Version;
use std::collections::{BTreeMap, BTreeSet};
use std::path::PathBuf;
use tokio::task::JoinSet;
#[derive(Args, Default)]
pub struct ReleaseArgs {
pub id: Option<String>,
#[arg(long)]
pub dist_tag: Option<String>,
#[arg(long)]
pub dry_run: bool,
#[arg(long, env = "GITHUB_TOKEN", hide_env_values = true)]
pub github_token: Option<String>,
}
type PublishKey = (ManifestKind, String, Version);
fn publish_key(manifest: &Manifest) -> PublishKey {
(
manifest.kind(),
manifest.name().to_string(),
manifest.version().clone(),
)
}
pub async fn release(workspace: &Workspace, args: ReleaseArgs) -> Result<Outcome> {
let git = &workspace.git;
let packages = &workspace.packages;
let branch = git.branch().ok();
let (id, record) = ReleaseRecord::select(
&workspace.root,
branch.as_deref(),
args.id.as_deref(),
packages,
)?;
println!("Releasing {id}");
let selected = record
.resolve(packages)?
.into_iter()
.map(|(package, _)| package);
let order = workspace.topo_sort(selected);
let assets = resolve_all_assets(workspace, &order, args.dry_run)?;
let github = connect_github(workspace, &args, !assets.is_empty())?;
let registries = Registries::new(&workspace.config.registry, &workspace.config.http)?;
let published = published_versions(&order, ®istries, args.dry_run).await;
let releaser = Releaser {
workspace,
git,
runner: ActionRunner::new(&workspace.root, args.dry_run)
.with_registries(®istries)
.with_github(github.as_ref()),
args: &args,
published,
assets,
};
let mut failed = BTreeSet::new();
let mut report = vec![];
for package in order {
let blocked_by = workspace
.dependencies_of(package)
.into_iter()
.filter(|dependency| failed.contains(dependency))
.map(Package::name)
.collect::<Vec<_>>();
let result = match blocked_by.is_empty() {
true => releaser.release(package).await,
false => Err(anyhow!("Blocked by failed {}", blocked_by.join(", "))),
};
if let Err(error) = &result {
eprintln!("[{}] {error:#}", package.name());
failed.insert(package);
}
report.push((package, result.is_ok()));
}
println!();
for (package, succeeded) in report {
let status = if succeeded { "released" } else { "failed" };
println!("{status}: {} v{}", package.name(), package.version());
}
ensure!(failed.is_empty(), "{} package(s) failed", failed.len());
Ok(Outcome::Done)
}
fn connect_github(
workspace: &Workspace,
args: &ReleaseArgs,
needed: bool,
) -> Result<Option<GitHub>> {
if args.dry_run || !needed {
return Ok(None);
}
let config = &workspace.config;
let token = args.github_token.as_deref();
GitHub::new(&workspace.root, &config.github, &config.http, token).map(Some)
}
fn uses_github_release(workspace: &Workspace, package: &Package) -> bool {
!workspace.config.github.skip_release && !package.config().skip_github_release
}
fn resolve_all_assets<'a>(
workspace: &Workspace,
order: &[&'a Package],
dry_run: bool,
) -> Result<BTreeMap<&'a Package, Vec<PathBuf>>> {
let mut all_assets = BTreeMap::new();
for &package in order {
if !uses_github_release(workspace, package) {
continue;
}
let mut assets = vec![];
for pattern in &package.config().assets {
let found = fs_util::find_glob_files(package.path(), pattern)?;
ensure!(
dry_run || !found.is_empty(),
"Asset pattern \"{pattern}\" of package \"{}\" matches no file",
package.name()
);
assets.extend(found);
}
all_assets.insert(package, assets);
}
Ok(all_assets)
}
async fn published_versions(
order: &[&Package],
registries: &Registries,
dry_run: bool,
) -> BTreeSet<PublishKey> {
let mut published = BTreeSet::new();
if dry_run {
return published;
}
let mut lookups = JoinSet::new();
for manifest in order
.iter()
.flat_map(|package| package.publishable_manifests())
{
let registries = registries.clone();
let key = publish_key(manifest);
lookups.spawn(async move {
let (kind, name, version) = &key;
let exists = registries.exists(*kind, name, version).await;
(key, exists)
});
}
while let Some(lookup) = lookups.join_next().await {
match lookup {
Ok((key, Ok(true))) => {
published.insert(key);
}
Ok((_, Ok(false))) => {}
Ok(((_, name, version), Err(error))) => {
eprintln!("warning: cannot look up {name}@{version}: {error:#}");
}
Err(error) => eprintln!("warning: a registry lookup did not finish: {error}"),
}
}
published
}
struct Releaser<'a> {
workspace: &'a Workspace,
git: &'a Git,
runner: ActionRunner<'a>,
args: &'a ReleaseArgs,
published: BTreeSet<PublishKey>,
assets: BTreeMap<&'a Package, Vec<PathBuf>>,
}
impl Releaser<'_> {
async fn release(&self, package: &Package) -> Result<()> {
let tag = package.tag(&self.workspace.config.git, package.version());
let mut actions = self.publish_actions(package)?;
actions.extend(self.tag_actions(&tag));
if let Some(assets) = self.assets.get(package) {
actions.push(Action::EnsureRelease(Release {
title: format!("{} v{}", package.name(), package.version()),
body: package.release_notes(&self.workspace.root, &self.workspace.config.changelog)?,
prerelease: !package.version().pre.is_empty(),
assets: assets.clone(),
tag,
}));
}
self.runner.run(&actions).await
}
fn publish_actions(&self, package: &Package) -> Result<Vec<Action>> {
let pending = package
.publishable_manifests()
.filter(|manifest| !self.published.contains(&publish_key(manifest)))
.collect::<Vec<_>>();
if pending.is_empty() {
return Ok(vec![]);
}
let mut actions = package
.config()
.before_publish_scripts
.iter()
.map(|script| {
let mut words = shell_words::split(&script.command)
.with_context(|| format!("Invalid script: {}", script.command))?;
ensure!(!words.is_empty(), "Script of {} is empty", package.name());
let cmd = words.remove(0);
words.extend(script.args.iter().cloned());
Ok(Action::RunProcess {
cmd,
args: words,
cwd: package.path().to_path_buf(),
})
})
.collect::<Result<Vec<_>>>()?;
actions.extend(pending.into_iter().map(|manifest| Action::Publish {
kind: manifest.kind(),
name: manifest.name().to_string(),
version: manifest.version().clone(),
dir: manifest.dir().to_path_buf(),
args: self.publish_args(manifest),
}));
Ok(actions)
}
fn publish_args(&self, manifest: &Manifest) -> Vec<String> {
if manifest.kind() != ManifestKind::PackageJson {
return vec![];
}
npm::dist_tag(manifest.version())
.or(self.args.dist_tag.as_deref())
.map_or(vec![], |tag| vec!["--tag".to_string(), tag.to_string()])
}
fn tag_actions(&self, tag: &str) -> Vec<Action> {
let root = self.workspace.root.as_path();
let mut actions = vec![];
if !self.git.tag_exists(tag) {
actions.push(Action::process("git", &["tag", tag], root));
}
let tag_ref = format!("refs/tags/{tag}");
actions.push(Action::process(
"git",
&["push", "--quiet", "origin", &tag_ref],
root,
));
actions
}
}