use crate::commands::pack::{BuiltArchive, build_archive};
use crate::commands::{encode_package_name, ensure_registry_auth};
use aube_manifest::PackageJson;
use aube_registry::client::RegistryClient;
use aube_registry::config::{NpmConfig, normalize_registry_url_pub};
use base64::Engine;
use clap::Args;
use miette::{Context, IntoDiagnostic, miette};
use sha1::Digest as _;
use sha2::Sha512;
use std::path::{Path, PathBuf};
#[derive(Debug, Args)]
pub struct PublishArgs {
#[arg(long, value_name = "LEVEL")]
pub access: Option<String>,
#[arg(long)]
pub dry_run: bool,
#[arg(long)]
pub force: bool,
#[arg(long)]
pub ignore_scripts: bool,
#[arg(long)]
pub json: bool,
#[arg(long)]
pub no_git_checks: bool,
#[arg(long, value_name = "CODE")]
pub otp: Option<String>,
#[arg(long)]
pub provenance: bool,
#[arg(long, value_name = "TAG")]
pub tag: Option<String>,
}
pub async fn run(
args: PublishArgs,
filter: aube_workspace::selector::EffectiveFilter,
registry_override: Option<&str>,
) -> miette::Result<()> {
let cwd = crate::dirs::project_root()?;
if !args.no_git_checks {
enforce_git_checks(&cwd)?;
}
if !filter.is_empty() {
return run_recursive(&cwd, &args, &filter, registry_override).await;
}
let config = super::load_npm_config(&cwd);
let policy = super::resolve_fetch_policy(&cwd);
let client = RegistryClient::from_config_with_policy(config.clone(), policy);
let outcome = publish_one(&cwd, &config, &client, &args, false, registry_override).await?;
emit_outcome(&outcome, args.json)?;
Ok(())
}
fn enforce_git_checks(cwd: &Path) -> miette::Result<()> {
let inside = std::process::Command::new("git")
.args(["rev-parse", "--is-inside-work-tree"])
.current_dir(cwd)
.output();
let Ok(out) = inside else {
return Ok(());
};
if !out.status.success() || String::from_utf8_lossy(&out.stdout).trim() != "true" {
return Ok(());
}
let status = std::process::Command::new("git")
.args(["status", "--porcelain", "--untracked-files=no"])
.current_dir(cwd)
.output()
.map_err(|e| miette!("failed to run `git status`: {e}"))?;
if !status.status.success() {
return Err(miette!(
"git status failed: {}",
String::from_utf8_lossy(&status.stderr).trim()
));
}
let dirty = String::from_utf8_lossy(&status.stdout);
if !dirty.trim().is_empty() {
return Err(miette!(
"aube publish: working tree has uncommitted changes:\n{}\n\
help: commit or stash them, or pass --no-git-checks to override",
dirty.trim_end()
));
}
let branch = std::process::Command::new("git")
.args(["rev-parse", "--abbrev-ref", "HEAD"])
.current_dir(cwd)
.output()
.map_err(|e| miette!("failed to run `git rev-parse`: {e}"))?;
if !branch.status.success() {
return Ok(());
}
let branch = String::from_utf8_lossy(&branch.stdout).trim().to_string();
let is_version_branch = |b: &str, prefix: &str| -> bool {
let Some(rest) = b.strip_prefix(prefix) else {
return false;
};
rest.chars()
.next()
.is_some_and(|c| c.is_ascii_digit() || c == '/' || c == '-' || c == '.')
};
let ok = matches!(branch.as_str(), "master" | "main" | "HEAD")
|| is_version_branch(&branch, "v")
|| is_version_branch(&branch, "release");
if !ok {
return Err(miette!(
"aube publish: current branch `{branch}` is not a release branch\n\
help: switch to main/master or pass --no-git-checks to override"
));
}
Ok(())
}
async fn run_recursive(
source_root: &Path,
args: &PublishArgs,
filter: &aube_workspace::selector::EffectiveFilter,
registry_override: Option<&str>,
) -> miette::Result<()> {
let workspace_pkgs = aube_workspace::find_workspace_packages(source_root)
.map_err(|e| miette!("failed to discover workspace packages: {e}"))?;
if workspace_pkgs.is_empty() {
return Err(miette!(
"aube publish: no workspace packages found. \
`--recursive` / `--filter` requires a workspace root (aube-workspace.yaml, pnpm-workspace.yaml, or package.json with a `workspaces` field) at {}",
source_root.display()
));
}
let selected = select_workspace_packages(source_root, &workspace_pkgs, filter)?;
if selected.is_empty() {
if !filter.is_empty() {
return Err(miette!(
"aube publish: --filter {:?} did not match any workspace package",
filter
));
}
return Err(miette!(
"aube publish: no publishable workspace packages (all private or empty)"
));
}
let config = super::load_npm_config(source_root);
let policy = super::resolve_fetch_policy(source_root);
let client = RegistryClient::from_config_with_policy(config.clone(), policy);
let mut outcomes: Vec<PublishOutcome> = Vec::new();
let mut failures: Vec<(String, miette::Report)> = Vec::new();
for pkg_dir in &selected {
match publish_one(pkg_dir, &config, &client, args, true, registry_override).await {
Ok(outcome) => outcomes.push(outcome),
Err(e) => failures.push((pkg_dir.display().to_string(), e)),
}
}
if args.json {
emit_json_many(&outcomes)?;
} else {
for o in &outcomes {
emit_outcome_line(o);
}
}
if !failures.is_empty() {
let joined = failures
.iter()
.map(|(p, e)| format!(" {p}: {e}"))
.collect::<Vec<_>>()
.join("\n");
return Err(miette!(
"aube publish: {} failed:\n{joined}",
pluralizer::pluralize("package", failures.len() as isize, true)
));
}
Ok(())
}
fn select_workspace_packages(
workspace_root: &Path,
workspace_pkgs: &[PathBuf],
filters: &aube_workspace::selector::EffectiveFilter,
) -> miette::Result<Vec<PathBuf>> {
let selected = aube_workspace::selector::select_workspace_packages(
workspace_root,
workspace_pkgs,
filters,
)
.map_err(|e| miette!("invalid --filter selector: {e}"))?;
let seen_names: Vec<String> = selected.iter().filter_map(|p| p.name.clone()).collect();
let out: Vec<PathBuf> = selected
.into_iter()
.filter(|p| p.name.is_some() && p.version.is_some() && !p.private)
.map(|p| p.dir)
.collect();
if !filters.is_empty() && out.is_empty() && !seen_names.is_empty() {
tracing::debug!("aube publish: known workspace packages: {seen_names:?}");
}
Ok(out)
}
struct PublishOutcome {
name: String,
version: String,
registry_url: String,
archive: Option<BuiltArchive>,
status: PublishStatus,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum PublishStatus {
Published,
DryRun,
AlreadyPublished,
}
async fn publish_one(
pkg_dir: &Path,
config: &NpmConfig,
client: &RegistryClient,
args: &PublishArgs,
fanout: bool,
registry_override: Option<&str>,
) -> miette::Result<PublishOutcome> {
let manifest = PackageJson::from_path(&pkg_dir.join("package.json"))
.map_err(miette::Report::new)
.wrap_err_with(|| format!("failed to read {}/package.json", pkg_dir.display()))?;
let name = manifest
.name
.as_deref()
.ok_or_else(|| miette!("publish: {}/package.json has no `name`", pkg_dir.display()))?
.to_string();
let version = manifest
.version
.as_deref()
.ok_or_else(|| {
miette!(
"publish: {}/package.json has no `version`",
pkg_dir.display()
)
})?
.to_string();
let publish_config = manifest
.extra
.get("publishConfig")
.and_then(|v| v.as_object());
let pc_registry = publish_config
.and_then(|p| p.get("registry"))
.and_then(|v| v.as_str());
let pc_tag = publish_config
.and_then(|p| p.get("tag"))
.and_then(|v| v.as_str());
let registry_url = registry_override
.map(normalize_registry_url_pub)
.or_else(|| pc_registry.map(normalize_registry_url_pub))
.unwrap_or_else(|| config.registry_for(&name).to_string());
let tag = args
.tag
.as_deref()
.or(pc_tag)
.unwrap_or("latest")
.to_string();
if args.dry_run {
run_publish_lifecycle_pre(pkg_dir, &manifest, args.ignore_scripts).await?;
let archive = build_archive(pkg_dir)?;
super::pack::run_pack_lifecycle_post(pkg_dir, args.ignore_scripts).await?;
if args.provenance {
crate::commands::publish_provenance::probe_oidc_available()
.await
.wrap_err("--dry-run --provenance: OIDC probe failed")?;
}
return Ok(PublishOutcome {
name: archive.name.clone(),
version: archive.version.clone(),
registry_url,
archive: Some(archive),
status: PublishStatus::DryRun,
});
}
ensure_registry_auth(client, ®istry_url)?;
if !args.force && version_on_registry(client, ®istry_url, &name, &version).await {
if fanout {
return Ok(PublishOutcome {
name,
version,
registry_url,
archive: None,
status: PublishStatus::AlreadyPublished,
});
}
return Err(miette!(
"aube publish: {name}@{version} is already on {registry_url}\n\
help: pass --force to republish (the registry must allow it; npm's public registry does not)"
));
}
run_publish_lifecycle_pre(pkg_dir, &manifest, args.ignore_scripts).await?;
let archive = build_archive(pkg_dir)?;
super::pack::run_pack_lifecycle_post(pkg_dir, args.ignore_scripts).await?;
let manifest = super::pack::read_root_manifest(pkg_dir)?;
let provenance_bundle = if args.provenance {
Some(
crate::commands::publish_provenance::generate(
&archive.tarball,
&archive.name,
&archive.version,
)
.await
.wrap_err("failed to generate SLSA provenance attestation")?,
)
} else {
None
};
let pc_access = manifest
.extra
.get("publishConfig")
.and_then(|v| v.as_object())
.and_then(|p| p.get("access"))
.and_then(|v| v.as_str());
let effective_access = args.access.as_deref().or(pc_access);
let body = build_publish_body(
&archive,
&manifest,
®istry_url,
&tag,
effective_access,
provenance_bundle.as_deref(),
)?;
let url = put_url(®istry_url, &archive.name);
let mut req = client
.authed_request(reqwest::Method::PUT, &url, ®istry_url)
.header("content-type", "application/json")
.body(serde_json::to_vec(&body).into_diagnostic()?);
if let Some(otp) = &args.otp {
req = req.header("npm-otp", otp);
}
let resp = req
.send()
.await
.into_diagnostic()
.wrap_err_with(|| format!("failed to PUT {url}"))?;
let status = resp.status();
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
return Err(miette!("publish failed: {status}: {}", body.trim()));
}
run_publish_lifecycle_post(pkg_dir, &manifest, args.ignore_scripts).await?;
Ok(PublishOutcome {
name: archive.name.clone(),
version: archive.version.clone(),
registry_url,
archive: Some(archive),
status: PublishStatus::Published,
})
}
async fn run_publish_lifecycle_pre(
pkg_dir: &Path,
manifest: &PackageJson,
ignore_scripts: bool,
) -> miette::Result<()> {
if ignore_scripts {
return Ok(());
}
super::pack::run_root_lifecycle_script(pkg_dir, manifest, "prepublishOnly").await?;
super::pack::run_root_lifecycle_script(pkg_dir, manifest, "prepublish").await?;
super::pack::run_root_lifecycle_script(pkg_dir, manifest, "prepack").await?;
super::pack::run_root_lifecycle_script(pkg_dir, manifest, "prepare").await?;
Ok(())
}
async fn run_publish_lifecycle_post(
pkg_dir: &Path,
manifest: &PackageJson,
ignore_scripts: bool,
) -> miette::Result<()> {
if ignore_scripts {
return Ok(());
}
super::pack::run_root_lifecycle_script(pkg_dir, manifest, "publish").await?;
super::pack::run_root_lifecycle_script(pkg_dir, manifest, "postpublish").await?;
Ok(())
}
async fn version_on_registry(
client: &RegistryClient,
registry_url: &str,
name: &str,
version: &str,
) -> bool {
let url = put_url(registry_url, name);
let Ok(resp) = client
.authed_request(reqwest::Method::GET, &url, registry_url)
.send()
.await
else {
return false;
};
if !resp.status().is_success() {
return false;
}
let Ok(doc) = resp.json::<serde_json::Value>().await else {
return false;
};
doc.get("versions").and_then(|v| v.get(version)).is_some()
}
fn emit_outcome(outcome: &PublishOutcome, as_json: bool) -> miette::Result<()> {
if as_json {
emit_json_many(std::slice::from_ref(outcome))
} else {
emit_outcome_line(outcome);
Ok(())
}
}
fn emit_outcome_line(outcome: &PublishOutcome) {
match outcome.status {
PublishStatus::DryRun => {
println!(
"+ {}@{} (dry run, would PUT to {})",
outcome.name,
outcome.version,
put_url(&outcome.registry_url, &outcome.name)
);
if let Some(archive) = &outcome.archive {
for f in &archive.files {
println!(" {f}");
}
}
}
PublishStatus::Published => {
println!("+ {}@{}", outcome.name, outcome.version);
}
PublishStatus::AlreadyPublished => {
println!(
"= {}@{} (already on registry, skipping)",
outcome.name, outcome.version
);
}
}
}
fn emit_json_many(outcomes: &[PublishOutcome]) -> miette::Result<()> {
let arr: Vec<serde_json::Value> = outcomes
.iter()
.map(|o| {
let status = match o.status {
PublishStatus::Published => "published",
PublishStatus::AlreadyPublished => "skipped",
PublishStatus::DryRun => "dry-run",
};
let mut obj = serde_json::json!({
"name": o.name,
"version": o.version,
"status": status,
});
if let Some(archive) = &o.archive {
let m = obj.as_object_mut().unwrap();
m.insert("filename".into(), archive.filename.clone().into());
m.insert(
"files".into(),
serde_json::Value::Array(
archive
.files
.iter()
.map(|p| serde_json::json!({"path": p}))
.collect(),
),
);
}
obj
})
.collect();
let out = serde_json::to_string_pretty(&arr).into_diagnostic()?;
println!("{out}");
Ok(())
}
fn put_url(registry: &str, name: &str) -> String {
let base = registry.trim_end_matches('/');
format!("{base}/{}", encode_package_name(name))
}
fn build_publish_body(
archive: &BuiltArchive,
manifest: &PackageJson,
registry_url: &str,
tag: &str,
access: Option<&str>,
provenance_bundle_json: Option<&str>,
) -> miette::Result<serde_json::Value> {
let shasum = hex::encode(sha1::Sha1::digest(&archive.tarball));
let integrity = {
let digest = Sha512::digest(&archive.tarball);
format!(
"sha512-{}",
base64::engine::general_purpose::STANDARD.encode(digest)
)
};
let b64_tarball = base64::engine::general_purpose::STANDARD.encode(&archive.tarball);
let tarball_url = format!(
"{}/{}/-/{}",
registry_url.trim_end_matches('/'),
archive.name,
archive.filename
);
let mut version_doc = serde_json::to_value(manifest).into_diagnostic()?;
let obj = version_doc
.as_object_mut()
.ok_or_else(|| miette!("manifest did not serialize to a JSON object"))?;
obj.insert(
"_id".into(),
format!("{}@{}", archive.name, archive.version).into(),
);
obj.insert(
"dist".into(),
serde_json::json!({
"shasum": shasum,
"integrity": integrity,
"tarball": tarball_url,
}),
);
let mut body = serde_json::json!({
"_id": archive.name,
"name": archive.name,
"dist-tags": { tag: archive.version },
"versions": { archive.version.clone(): version_doc },
"_attachments": {
archive.filename.clone(): {
"content_type": "application/octet-stream",
"data": b64_tarball,
"length": archive.tarball.len(),
}
}
});
if let Some(access) = access {
body.as_object_mut()
.unwrap()
.insert("access".into(), access.into());
}
if let Some(bundle_json) = provenance_bundle_json {
let attachment_name = format!("{}-{}.sigstore", archive.name, archive.version);
let length = bundle_json.len();
body.as_object_mut()
.unwrap()
.get_mut("_attachments")
.and_then(|v| v.as_object_mut())
.ok_or_else(|| miette!("publish body missing _attachments object"))?
.insert(
attachment_name,
serde_json::json!({
"content_type": "application/vnd.dev.sigstore.bundle+json;version=0.3",
"data": bundle_json,
"length": length,
}),
);
}
Ok(body)
}
#[cfg(test)]
mod tests {
use super::*;
use aube_registry::config::registry_uri_key_pub;
#[test]
fn put_url_encodes_scoped_slash() {
assert_eq!(
put_url("https://registry.npmjs.org/", "@scope/pkg"),
"https://registry.npmjs.org/@scope%2Fpkg"
);
}
#[test]
fn put_url_plain_name() {
assert_eq!(
put_url("https://registry.npmjs.org", "lodash"),
"https://registry.npmjs.org/lodash"
);
}
fn write_manifest(dir: &Path, body: &str) -> PathBuf {
std::fs::create_dir_all(dir).unwrap();
let p = dir.join("package.json");
std::fs::write(&p, body).unwrap();
dir.to_path_buf()
}
#[test]
fn select_skips_private_packages() {
let tmp = tempfile::tempdir().unwrap();
let a = write_manifest(&tmp.path().join("a"), r#"{"name":"a","version":"1.0.0"}"#);
let b = write_manifest(
&tmp.path().join("b"),
r#"{"name":"b","version":"1.0.0","private":true}"#,
);
let out = select_workspace_packages(
tmp.path(),
&[a.clone(), b],
&aube_workspace::selector::EffectiveFilter::default(),
)
.unwrap();
assert_eq!(out, vec![a]);
}
#[test]
fn select_respects_filter_exact_name() {
let tmp = tempfile::tempdir().unwrap();
let a = write_manifest(
&tmp.path().join("a"),
r#"{"name":"@scope/a","version":"1.0.0"}"#,
);
let b = write_manifest(&tmp.path().join("b"), r#"{"name":"b","version":"1.0.0"}"#);
let out = select_workspace_packages(
tmp.path(),
&[a, b.clone()],
&aube_workspace::selector::EffectiveFilter::from_filters(["b"]),
)
.unwrap();
assert_eq!(out, vec![b]);
}
#[test]
fn select_respects_filter_glob() {
let tmp = tempfile::tempdir().unwrap();
let a = write_manifest(
&tmp.path().join("a"),
r#"{"name":"@scope/a","version":"1.0.0"}"#,
);
let b = write_manifest(
&tmp.path().join("b"),
r#"{"name":"@scope/b","version":"1.0.0"}"#,
);
let c = write_manifest(
&tmp.path().join("c"),
r#"{"name":"other","version":"1.0.0"}"#,
);
let out = select_workspace_packages(
tmp.path(),
&[a.clone(), b.clone(), c],
&aube_workspace::selector::EffectiveFilter::from_filters(["@scope/*"]),
)
.unwrap();
assert_eq!(out, vec![a, b]);
}
#[test]
fn select_skips_manifest_without_version() {
let tmp = tempfile::tempdir().unwrap();
let a = write_manifest(&tmp.path().join("a"), r#"{"name":"a"}"#);
assert!(
select_workspace_packages(
tmp.path(),
&[a],
&aube_workspace::selector::EffectiveFilter::default(),
)
.unwrap()
.is_empty()
);
}
#[test]
fn uri_key_matches_registry_helper() {
assert_eq!(
registry_uri_key_pub("https://registry.npmjs.org/"),
"//registry.npmjs.org/"
);
}
}