diff --git a/.cli-flags.toml b/.cli-flags.toml index 323ca83d..f3e9cab5 100644 --- a/.cli-flags.toml +++ b/.cli-flags.toml @@ -255,6 +255,19 @@ help = "Search the registry." [commands.pack] help = "Pack an artifact." +[commands.release] +help = "Coordinate Zed and native-registry releases." + +[commands.release.commands.plan] +help = "Print a credential-free deterministic release plan." + +[commands.release.commands.plan.flags.release_json] +env = "ZED_PKG_RELEASE_JSON" +aliases = ["json"] +type = "bool" +default = "false" +help = "Emit the release plan as JSON." + [commands.publish] help = "Publish an artifact." diff --git a/README.md b/README.md index 119ef84d..0bf63d35 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,7 @@ registry hosts both on S3/Cloudflare R2. | `zed install --frozen` | Install exactly what `.zpkg.lock` pins (CI/containers) | | `zed find ` | Search the registry | | `zed pack` | Build the pruned, deterministic `tar.gz` artifact | +| `zed release plan [--json]` | Print the credential-free Zed + native-registry release set derived from `.zpkg.toml` | | `zed publish` | Verify clean tree + matching VCS tag at HEAD, pack, upload | | `zed r2g` (`zed test-local`) | Roundtrip-test your artifact: install it into a mock consumer under `~/.zed-pkg/r2g` and run `publish.smoke_test`, optionally inside an OCI container (`--docker`) | | `zed run [args]` | Run an executable a dependency exposes via `[bin]`, with `zed_modules/.bin` on `PATH` (npx-style, no global pollution) | diff --git a/src/cli.rs b/src/cli.rs index 30362d28..d27327c3 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -189,6 +189,11 @@ pub enum Cmd { #[arg(long, env = "ZED_PKG_PACK_OUT")] out: Option, }, + /// Plan a coordinated Zed + native-registry release without credentials or uploads + Release { + #[command(subcommand)] + cmd: ReleaseCmd, + }, /// Pack, verify VCS tag provenance, and upload to the registry Publish { #[arg(long, env = "ZED_PKG_DRY_RUN")] @@ -310,6 +315,16 @@ pub enum Cmd { }, } +#[derive(Debug, Subcommand)] +pub enum ReleaseCmd { + /// Print the deterministic release set derived from `.zpkg.toml` + Plan { + /// Emit machine-readable JSON rather than the human summary + #[arg(long, env = "ZED_PKG_RELEASE_JSON")] + json: bool, + }, +} + #[derive(Debug, Subcommand)] pub enum AuthCmd { /// Sign in and save a refreshable local session diff --git a/src/lib.rs b/src/lib.rs index 08072f9b..04fb1b8e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -9,6 +9,7 @@ pub mod ops; pub mod pack; pub mod r2g; pub mod registry; +pub mod release; pub mod store; pub mod update; pub mod vcs; diff --git a/src/main.rs b/src/main.rs index 184b0610..15d47ac6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,9 +1,10 @@ use clap::Parser; use zed_cli::auth; -use zed_cli::cli::{AuthCmd, CacheCmd, Cli, Cmd, OrgCmd, StoreCmd}; +use zed_cli::cli::{AuthCmd, CacheCmd, Cli, Cmd, OrgCmd, ReleaseCmd, StoreCmd}; use zed_cli::config::Config; use zed_cli::ops; use zed_cli::r2g::{self, R2gOptions}; +use zed_cli::release; use zed_cli::store::Store; use zed_cli::update; @@ -50,6 +51,9 @@ fn run(cli: Cli) -> anyhow::Result<()> { } => ops::gc(&cfg, &older_than, dry_run), Cmd::Find { query } => ops::find(&cfg, &query), Cmd::Pack { out } => ops::pack_cmd(&cwd, out.as_deref()).map(|_| ()), + Cmd::Release { cmd } => match cmd { + ReleaseCmd::Plan { json } => release::plan(&cwd, json), + }, Cmd::Publish { dry_run, allow_dirty, diff --git a/src/release.rs b/src/release.rs new file mode 100644 index 00000000..513cb7de --- /dev/null +++ b/src/release.rs @@ -0,0 +1,226 @@ +use std::path::Path; + +use anyhow::Result; +use serde::Serialize; +use zed_interfaces::manifest::Manifest; + +use crate::config::read_manifest; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ReleasePlan { + pub release_set: String, + pub source: ReleaseSource, + pub zed: Vec, + pub native: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ReleaseSource { + pub package: String, + pub version: String, + pub vcs_tag: String, + pub repository: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ZedReleaseArtifact { + pub target: Option, + pub package: String, + pub version: String, + pub dir: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct NativeReleaseArtifact { + pub target: String, + pub registry: String, + pub package: String, + pub version: String, + pub dir: String, +} + +pub fn build_plan(manifest: &Manifest) -> ReleasePlan { + let source_package = manifest.full_name(); + let version = manifest.package.version.clone(); + let vcs_tag = manifest.vcs_tag(); + + let zed = if manifest.targets.is_empty() { + vec![ZedReleaseArtifact { + target: None, + package: source_package.clone(), + version: version.clone(), + dir: ".".to_string(), + }] + } else { + manifest + .target_package_names() + .into_iter() + .map(|(target, package_name)| { + let section = manifest + .targets + .get(&target) + .expect("target_package_names only returns declared targets"); + ZedReleaseArtifact { + target: Some(target), + package: format!("{}/{}", manifest.package.org, package_name), + version: version.clone(), + dir: section.dir.clone(), + } + }) + .collect() + }; + + let native = manifest + .native_release_routes() + .into_iter() + .map(|route| NativeReleaseArtifact { + target: route.target, + registry: route.registry.as_str().to_string(), + package: route.package, + version: version.clone(), + dir: route.dir, + }) + .collect(); + + ReleasePlan { + release_set: format!("{source_package}@{version}#{vcs_tag}"), + source: ReleaseSource { + package: source_package, + version, + vcs_tag, + repository: manifest.package.repository.url.clone(), + }, + zed, + native, + } +} + +pub fn render_human(plan: &ReleasePlan) -> String { + let mut output = String::new(); + output.push_str(&format!("release set {}\n", plan.release_set)); + output.push_str(&format!( + "source: {} @ {} ({})\n", + plan.source.repository, plan.source.vcs_tag, plan.source.package + )); + output.push_str("zed artifacts:\n"); + for artifact in &plan.zed { + let target = artifact.target.as_deref().unwrap_or("repository"); + output.push_str(&format!( + " - {}/{} <- {} [target: {}]\n", + artifact.package, artifact.version, artifact.dir, target + )); + } + output.push_str("native artifacts:\n"); + if plan.native.is_empty() { + output.push_str(" - none declared\n"); + } else { + for artifact in &plan.native { + output.push_str(&format!( + " - {} {}@{} <- {} [target: {}]\n", + artifact.registry, + artifact.package, + artifact.version, + artifact.dir, + artifact.target + )); + } + } + output +} + +pub fn plan(project: &Path, json: bool) -> Result<()> { + let manifest = read_manifest(project)?; + let plan = build_plan(&manifest); + if json { + println!("{}", serde_json::to_string_pretty(&plan)?); + } else { + print!("{}", render_human(&plan)); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn polyglot_plan_is_deterministic_and_includes_native_routes() { + let manifest = Manifest::parse( + r#" +[package] +org = "acme" +name = "clients" +version = "1.2.3" + +[package.repository] +url = "https://github.com/acme/clients" + +[targets.rust] +dir = "clients/rust" + +[targets.rust.native] +registry = "crates-io" +package = "acme-client" + +[targets.repository] +dir = "." +name = "clients-repository" + +[targets.nodejs] +dir = "clients/typescript" +adapter = "node" + +[targets.nodejs.native] +registry = "npm" +package = "@acme/client" +"#, + ) + .unwrap(); + + let plan = build_plan(&manifest); + assert_eq!(plan.release_set, "acme/clients@1.2.3#v1.2.3"); + assert_eq!( + plan.zed + .iter() + .map(|artifact| artifact.target.as_deref().unwrap()) + .collect::>(), + vec!["nodejs", "repository", "rust"] + ); + assert_eq!( + plan.native + .iter() + .map(|artifact| (artifact.registry.as_str(), artifact.package.as_str())) + .collect::>(), + vec![("npm", "@acme/client"), ("crates-io", "acme-client")] + ); + + let json = serde_json::to_string(&plan).unwrap(); + assert!(json.contains("\"release_set\":\"acme/clients@1.2.3#v1.2.3\"")); + let human = render_human(&plan); + assert!(human.contains("native artifacts:")); + assert!(human.contains("npm @acme/client@1.2.3")); + } + + #[test] + fn single_language_plan_keeps_the_root_package() { + let manifest = Manifest::parse( + r#" +[package] +org = "acme" +name = "http-kit" +version = "0.4.0" + +[package.repository] +url = "https://github.com/acme/http-kit" +"#, + ) + .unwrap(); + + let plan = build_plan(&manifest); + assert_eq!(plan.zed.len(), 1); + assert_eq!(plan.zed[0].target, None); + assert_eq!(plan.zed[0].package, "acme/http-kit"); + assert!(plan.native.is_empty()); + assert!(render_human(&plan).contains("none declared")); + } +}