Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .cli-flags.toml
Original file line numberDiff line numberDiff line change
Expand Up@@ -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."

Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -99,6 +99,7 @@ registry hosts both on S3/Cloudflare R2.
| `zed install --frozen` | Install exactly what `.zpkg.lock` pins (CI/containers) |
| `zed find <query>` | 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 <bin> [args]` | Run an executable a dependency exposes via `[bin]`, with `zed_modules/.bin` on `PATH` (npx-style, no global pollution) |
Expand Down
15 changes: 15 additions & 0 deletions src/cli.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -189,6 +189,11 @@ pub enum Cmd {
#[arg(long, env = "ZED_PKG_PACK_OUT")]
out: Option<PathBuf>,
},
/// 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")]
Expand DownExpand Up@@ -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
Expand Down
1 change: 1 addition & 0 deletions src/lib.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
6 changes: 5 additions & 1 deletion src/main.rs
Original file line numberDiff line numberDiff line change
@@ -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;

Expand DownExpand Up@@ -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,
Expand Down
226 changes: 226 additions & 0 deletions src/release.rs
Original file line numberDiff line numberDiff line change
@@ -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<ZedReleaseArtifact>,
pub native: Vec<NativeReleaseArtifact>,
}

#[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<String>,
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<_>>(),
vec!["nodejs", "repository", "rust"]
);
assert_eq!(
plan.native
.iter()
.map(|artifact| (artifact.registry.as_str(), artifact.package.as_str()))
.collect::<Vec<_>>(),
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"));
}
}
Loading