49dbd7b9
Add the release publish, list and delete CLI
a73x 2026-08-08 15:43
Commit message
src/cli.rs
| Old | New | ||
|---|---|---|---|
| @@ -67,6 +67,10 @@ pub enum Commands { | |||
| 67 | #[command(subcommand)] | 67 | #[command(subcommand)] |
| 68 | Patch(PatchCmd), | 68 | Patch(PatchCmd), |
| 69 | 69 | ||
| 70 | /// Manage release artifacts on the server | ||
| 71 | #[command(subcommand)] | ||
| 72 | Release(ReleaseCmd), | ||
| 73 | |||
| 70 | /// Show project status overview | 74 | /// Show project status overview |
| 71 | Status, | 75 | Status, |
| 72 | 76 | ||
| @@ -376,6 +380,43 @@ pub enum PatchCmd { | |||
| 376 | } | 380 | } |
| 377 | 381 | ||
| 378 | #[derive(Subcommand)] | 382 | #[derive(Subcommand)] |
| 383 | pub enum ReleaseCmd { | ||
| 384 | /// Upload files to a release version on the server | ||
| 385 | Publish { | ||
| 386 | /// Release version (e.g. v1.2.0) | ||
| 387 | version: String, | ||
| 388 | /// Files to upload | ||
| 389 | #[arg(required = true)] | ||
| 390 | files: Vec<std::path::PathBuf>, | ||
| 391 | /// Replace files that already exist in this version | ||
| 392 | #[arg(long)] | ||
| 393 | force: bool, | ||
| 394 | /// Remote name | ||
| 395 | #[arg(long, default_value = "origin")] | ||
| 396 | remote: String, | ||
| 397 | }, | ||
| 398 | /// List releases on the server | ||
| 399 | List { | ||
| 400 | /// Output as JSON | ||
| 401 | #[arg(long)] | ||
| 402 | json: bool, | ||
| 403 | /// Remote name | ||
| 404 | #[arg(long, default_value = "origin")] | ||
| 405 | remote: String, | ||
| 406 | }, | ||
| 407 | /// Delete a release version, or a single file from it | ||
| 408 | Delete { | ||
| 409 | /// Release version | ||
| 410 | version: String, | ||
| 411 | /// Filename (omit to delete the whole version) | ||
| 412 | filename: Option<String>, | ||
| 413 | /// Remote name | ||
| 414 | #[arg(long, default_value = "origin")] | ||
| 415 | remote: String, | ||
| 416 | }, | ||
| 417 | } | ||
| 418 | |||
| 419 | #[derive(Subcommand)] | ||
| 379 | pub enum IdentityCmd { | 420 | pub enum IdentityCmd { |
| 380 | /// Link another email to your current identity | 421 | /// Link another email to your current identity |
| 381 | Alias { | 422 | Alias { |
src/lib.rs
| Old | New | ||
|---|---|---|---|
| @@ -18,7 +18,7 @@ pub mod trust; | |||
| 18 | pub mod tui; | 18 | pub mod tui; |
| 19 | 19 | ||
| 20 | use base64::Engine; | 20 | use base64::Engine; |
| 21 | use cli::{Commands, IdentityCmd, IssueCmd, KeyCmd, PatchCmd}; | 21 | use cli::{Commands, IdentityCmd, IssueCmd, KeyCmd, PatchCmd, ReleaseCmd}; |
| 22 | use event::ReviewVerdict; | 22 | use event::ReviewVerdict; |
| 23 | use git2::Repository; | 23 | use git2::Repository; |
| 24 | 24 | ||
| @@ -422,6 +422,20 @@ pub fn run(cli: cli::Cli, repo: &Repository) -> Result<(), error::Error> { | |||
| 422 | Ok(()) | 422 | Ok(()) |
| 423 | } | 423 | } |
| 424 | }, | 424 | }, |
| 425 | Commands::Release(cmd) => match cmd { | ||
| 426 | ReleaseCmd::Publish { | ||
| 427 | version, | ||
| 428 | files, | ||
| 429 | force, | ||
| 430 | remote, | ||
| 431 | } => release::publish(repo, &remote, &version, &files, force), | ||
| 432 | ReleaseCmd::List { json, remote } => release::list(repo, &remote, json), | ||
| 433 | ReleaseCmd::Delete { | ||
| 434 | version, | ||
| 435 | filename, | ||
| 436 | remote, | ||
| 437 | } => release::delete(repo, &remote, &version, filename.as_deref()), | ||
| 438 | }, | ||
| 425 | Commands::Status => { | 439 | Commands::Status => { |
| 426 | let project_status = status::compute(repo)?; | 440 | let project_status = status::compute(repo)?; |
| 427 | print!("{}", project_status); | 441 | print!("{}", project_status); |
src/release.rs
| Old | New | ||
|---|---|---|---|
| @@ -1,5 +1,12 @@ | |||
| 1 | //! Client-side release commands and shared release name validation. | 1 | //! Client-side release commands and shared release name validation. |
| 2 | 2 | ||
| 3 | use std::path::PathBuf; | ||
| 4 | use std::process::{Command, Output, Stdio}; | ||
| 5 | |||
| 6 | use git2::Repository; | ||
| 7 | |||
| 8 | use crate::error::Error; | ||
| 9 | |||
| 3 | /// Maximum length in bytes for a release version or filename. | 10 | /// Maximum length in bytes for a release version or filename. |
| 4 | pub const MAX_NAME_LEN: usize = 128; | 11 | pub const MAX_NAME_LEN: usize = 128; |
| 5 | 12 | ||
| @@ -17,6 +24,201 @@ pub fn validate_name(name: &str) -> bool { | |||
| 17 | chars.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-') | 24 | chars.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-') |
| 18 | } | 25 | } |
| 19 | 26 | ||
| 27 | #[derive(Debug, PartialEq, Eq)] | ||
| 28 | pub struct SshRemote { | ||
| 29 | pub user: Option<String>, | ||
| 30 | pub host: String, | ||
| 31 | pub port: Option<u16>, | ||
| 32 | pub path: String, | ||
| 33 | } | ||
| 34 | |||
| 35 | /// Parse `ssh://[user@]host[:port]/path` or scp-style `[user@]host:path`. | ||
| 36 | pub fn parse_ssh_remote(url: &str) -> Option<SshRemote> { | ||
| 37 | if let Some(rest) = url.strip_prefix("ssh://") { | ||
| 38 | let (authority, path) = rest.split_once('/')?; | ||
| 39 | let (user, hostport) = split_user(authority); | ||
| 40 | let (host, port) = match hostport.rsplit_once(':') { | ||
| 41 | Some((h, p)) => (h.to_string(), Some(p.parse().ok()?)), | ||
| 42 | None => (hostport.to_string(), None), | ||
| 43 | }; | ||
| 44 | if host.is_empty() || path.is_empty() { | ||
| 45 | return None; | ||
| 46 | } | ||
| 47 | return Some(SshRemote { | ||
| 48 | user, | ||
| 49 | host, | ||
| 50 | port, | ||
| 51 | path: path.to_string(), | ||
| 52 | }); | ||
| 53 | } | ||
| 54 | if url.contains("://") { | ||
| 55 | return None; | ||
| 56 | } | ||
| 57 | // scp-style: [user@]host:path — but not a local path | ||
| 58 | let (authority, path) = url.split_once(':')?; | ||
| 59 | if authority.is_empty() || path.is_empty() || authority.contains('/') { | ||
| 60 | return None; | ||
| 61 | } | ||
| 62 | let (user, host) = split_user(authority); | ||
| 63 | Some(SshRemote { | ||
| 64 | user, | ||
| 65 | host: host.to_string(), | ||
| 66 | port: None, | ||
| 67 | path: path.to_string(), | ||
| 68 | }) | ||
| 69 | } | ||
| 70 | |||
| 71 | fn split_user(authority: &str) -> (Option<String>, &str) { | ||
| 72 | match authority.split_once('@') { | ||
| 73 | Some((user, host)) => (Some(user.to_string()), host), | ||
| 74 | None => (None, authority), | ||
| 75 | } | ||
| 76 | } | ||
| 77 | |||
| 78 | fn ssh_remote(repo: &Repository, remote_name: &str) -> Result<SshRemote, Error> { | ||
| 79 | let remote = repo | ||
| 80 | .find_remote(remote_name) | ||
| 81 | .map_err(|_| Error::Cmd(format!("remote '{}' not found", remote_name)))?; | ||
| 82 | let url = remote | ||
| 83 | .url() | ||
| 84 | .ok_or_else(|| Error::Cmd(format!("remote '{}' has no URL", remote_name)))?; | ||
| 85 | parse_ssh_remote(url).ok_or_else(|| { | ||
| 86 | Error::Cmd(format!( | ||
| 87 | "remote '{}' ({}) is not an SSH remote — releases need an ssh:// or user@host: remote", | ||
| 88 | remote_name, url | ||
| 89 | )) | ||
| 90 | }) | ||
| 91 | } | ||
| 92 | |||
| 93 | /// Build the ssh invocation for a remote, honoring GIT_COLLAB_SSH_COMMAND | ||
| 94 | /// (like git's GIT_SSH_COMMAND: extra words become leading arguments). | ||
| 95 | fn ssh_command(remote: &SshRemote) -> Command { | ||
| 96 | let base = std::env::var("GIT_COLLAB_SSH_COMMAND").unwrap_or_else(|_| "ssh".to_string()); | ||
| 97 | let mut parts = base.split_whitespace(); | ||
| 98 | let mut cmd = Command::new(parts.next().unwrap_or("ssh")); | ||
| 99 | for part in parts { | ||
| 100 | cmd.arg(part); | ||
| 101 | } | ||
| 102 | if let Some(port) = remote.port { | ||
| 103 | cmd.arg("-p").arg(port.to_string()); | ||
| 104 | } | ||
| 105 | match &remote.user { | ||
| 106 | Some(user) => cmd.arg(format!("{}@{}", user, remote.host)), | ||
| 107 | None => cmd.arg(&remote.host), | ||
| 108 | }; | ||
| 109 | cmd | ||
| 110 | } | ||
| 111 | |||
| 112 | fn run_remote(remote: &SshRemote, remote_cmd: &str, stdin: Stdio) -> Result<Output, Error> { | ||
| 113 | let output = ssh_command(remote) | ||
| 114 | .arg(remote_cmd) | ||
| 115 | .stdin(stdin) | ||
| 116 | .output() | ||
| 117 | .map_err(|e| Error::Cmd(format!("failed to run ssh: {}", e)))?; | ||
| 118 | if !output.status.success() { | ||
| 119 | let msg = format!( | ||
| 120 | "{}{}", | ||
| 121 | String::from_utf8_lossy(&output.stdout).trim(), | ||
| 122 | String::from_utf8_lossy(&output.stderr).trim() | ||
| 123 | ); | ||
| 124 | return Err(Error::Cmd(if msg.is_empty() { | ||
| 125 | "server rejected the command".to_string() | ||
| 126 | } else { | ||
| 127 | msg | ||
| 128 | })); | ||
| 129 | } | ||
| 130 | Ok(output) | ||
| 131 | } | ||
| 132 | |||
| 133 | pub fn publish( | ||
| 134 | repo: &Repository, | ||
| 135 | remote_name: &str, | ||
| 136 | version: &str, | ||
| 137 | files: &[PathBuf], | ||
| 138 | force: bool, | ||
| 139 | ) -> Result<(), Error> { | ||
| 140 | if !validate_name(version) { | ||
| 141 | return Err(Error::Cmd(format!("invalid version name: {}", version))); | ||
| 142 | } | ||
| 143 | let remote = ssh_remote(repo, remote_name)?; | ||
| 144 | for file in files { | ||
| 145 | let filename = file | ||
| 146 | .file_name() | ||
| 147 | .and_then(|n| n.to_str()) | ||
| 148 | .ok_or_else(|| Error::Cmd(format!("invalid file path: {}", file.display())))? | ||
| 149 | .to_string(); | ||
| 150 | if !validate_name(&filename) { | ||
| 151 | return Err(Error::Cmd(format!("invalid filename: {}", filename))); | ||
| 152 | } | ||
| 153 | let handle = std::fs::File::open(file) | ||
| 154 | .map_err(|e| Error::Cmd(format!("cannot open {}: {}", file.display(), e)))?; | ||
| 155 | let mut remote_cmd = format!( | ||
| 156 | "collab-release upload '{}' '{}' '{}'", | ||
| 157 | remote.path, version, filename | ||
| 158 | ); | ||
| 159 | if force { | ||
| 160 | remote_cmd.push_str(" --force"); | ||
| 161 | } | ||
| 162 | let output = run_remote(&remote, &remote_cmd, Stdio::from(handle))?; | ||
| 163 | let stdout = String::from_utf8_lossy(&output.stdout); | ||
| 164 | let sha = stdout.trim().strip_prefix("ok ").unwrap_or("").to_string(); | ||
| 165 | println!("Published {}/{} (sha256 {})", version, filename, sha); | ||
| 166 | } | ||
| 167 | Ok(()) | ||
| 168 | } | ||
| 169 | |||
| 170 | pub fn list(repo: &Repository, remote_name: &str, json: bool) -> Result<(), Error> { | ||
| 171 | let remote = ssh_remote(repo, remote_name)?; | ||
| 172 | let remote_cmd = format!("collab-release list '{}'", remote.path); | ||
| 173 | let output = run_remote(&remote, &remote_cmd, Stdio::null())?; | ||
| 174 | let stdout = String::from_utf8_lossy(&output.stdout); | ||
| 175 | if json { | ||
| 176 | print!("{}", stdout); | ||
| 177 | return Ok(()); | ||
| 178 | } | ||
| 179 | let index: serde_json::Value = serde_json::from_str(stdout.trim())?; | ||
| 180 | let versions = index["versions"].as_array().cloned().unwrap_or_default(); | ||
| 181 | if versions.is_empty() { | ||
| 182 | println!("No releases."); | ||
| 183 | return Ok(()); | ||
| 184 | } | ||
| 185 | for v in &versions { | ||
| 186 | println!( | ||
| 187 | "{} ({})", | ||
| 188 | v["version"].as_str().unwrap_or("?"), | ||
| 189 | v["published"].as_str().unwrap_or("?") | ||
| 190 | ); | ||
| 191 | for f in v["files"].as_array().cloned().unwrap_or_default() { | ||
| 192 | println!( | ||
| 193 | " {} {} bytes sha256:{}", | ||
| 194 | f["name"].as_str().unwrap_or("?"), | ||
| 195 | f["size"].as_u64().unwrap_or(0), | ||
| 196 | f["sha256"].as_str().unwrap_or("?") | ||
| 197 | ); | ||
| 198 | } | ||
| 199 | } | ||
| 200 | Ok(()) | ||
| 201 | } | ||
| 202 | |||
| 203 | pub fn delete( | ||
| 204 | repo: &Repository, | ||
| 205 | remote_name: &str, | ||
| 206 | version: &str, | ||
| 207 | filename: Option<&str>, | ||
| 208 | ) -> Result<(), Error> { | ||
| 209 | let remote = ssh_remote(repo, remote_name)?; | ||
| 210 | let mut remote_cmd = format!("collab-release delete '{}' '{}'", remote.path, version); | ||
| 211 | if let Some(name) = filename { | ||
| 212 | remote_cmd.push_str(&format!(" '{}'", name)); | ||
| 213 | } | ||
| 214 | run_remote(&remote, &remote_cmd, Stdio::null())?; | ||
| 215 | match filename { | ||
| 216 | Some(name) => println!("Deleted {}/{}", version, name), | ||
| 217 | None => println!("Deleted {}", version), | ||
| 218 | } | ||
| 219 | Ok(()) | ||
| 220 | } | ||
| 221 | |||
| 20 | #[cfg(test)] | 222 | #[cfg(test)] |
| 21 | mod tests { | 223 | mod tests { |
| 22 | use super::*; | 224 | use super::*; |
| @@ -47,4 +249,38 @@ mod tests { | |||
| 47 | assert!(!validate_name(&"a".repeat(129))); | 249 | assert!(!validate_name(&"a".repeat(129))); |
| 48 | assert!(validate_name(&"a".repeat(128))); | 250 | assert!(validate_name(&"a".repeat(128))); |
| 49 | } | 251 | } |
| 252 | |||
| 253 | #[test] | ||
| 254 | fn parse_ssh_url_full() { | ||
| 255 | let r = parse_ssh_remote("ssh://git@example.com:2222/myrepo.git").unwrap(); | ||
| 256 | assert_eq!(r.user.as_deref(), Some("git")); | ||
| 257 | assert_eq!(r.host, "example.com"); | ||
| 258 | assert_eq!(r.port, Some(2222)); | ||
| 259 | assert_eq!(r.path, "myrepo.git"); | ||
| 260 | } | ||
| 261 | |||
| 262 | #[test] | ||
| 263 | fn parse_ssh_url_minimal() { | ||
| 264 | let r = parse_ssh_remote("ssh://example.com/org/repo.git").unwrap(); | ||
| 265 | assert_eq!(r.user, None); | ||
| 266 | assert_eq!(r.port, None); | ||
| 267 | assert_eq!(r.path, "org/repo.git"); | ||
| 268 | } | ||
| 269 | |||
| 270 | #[test] | ||
| 271 | fn parse_scp_style() { | ||
| 272 | let r = parse_ssh_remote("git@example.com:myrepo.git").unwrap(); | ||
| 273 | assert_eq!(r.user.as_deref(), Some("git")); | ||
| 274 | assert_eq!(r.host, "example.com"); | ||
| 275 | assert_eq!(r.port, None); | ||
| 276 | assert_eq!(r.path, "myrepo.git"); | ||
| 277 | } | ||
| 278 | |||
| 279 | #[test] | ||
| 280 | fn parse_rejects_non_ssh() { | ||
| 281 | assert!(parse_ssh_remote("https://example.com/repo.git").is_none()); | ||
| 282 | assert!(parse_ssh_remote("/srv/git/repo.git").is_none()); | ||
| 283 | assert!(parse_ssh_remote("../relative/path").is_none()); | ||
| 284 | assert!(parse_ssh_remote("file:///srv/git/repo.git").is_none()); | ||
| 285 | } | ||
| 50 | } | 286 | } |
src/server/http/repo/releases.rs
| Old | New | ||
|---|---|---|---|
| @@ -28,9 +28,17 @@ pub async fn releases( | |||
| 28 | Err(resp) => return resp, | 28 | Err(resp) => return resp, |
| 29 | }; | 29 | }; |
| 30 | let (open_patches, open_issues) = collab_counts(&repo); | 30 | let (open_patches, open_issues) = collab_counts(&repo); |
| 31 | let versions = list_releases(&releases_dir(&entry)) | 31 | let versions = match list_releases(&releases_dir(&entry)) { |
| 32 | .map(|index| index.versions) | 32 | Ok(index) => index.versions, |
| 33 | .unwrap_or_default(); | 33 | Err(error) => { |
| 34 | tracing::warn!( | ||
| 35 | "failed to list releases for {:?}: {}; showing empty list", | ||
| 36 | releases_dir(&entry), | ||
| 37 | error | ||
| 38 | ); | ||
| 39 | Vec::new() | ||
| 40 | } | ||
| 41 | }; | ||
| 34 | 42 | ||
| 35 | ReleasesTemplate { | 43 | ReleasesTemplate { |
| 36 | site_title: state.site_title.clone(), | 44 | site_title: state.site_title.clone(), |
src/server/releases.rs
| Old | New | ||
|---|---|---|---|
| @@ -10,6 +10,10 @@ | |||
| 10 | //! same time; the last `finish()` to persist wins and the `.sha256` | 10 | //! same time; the last `finish()` to persist wins and the `.sha256` |
| 11 | //! companion may transiently mismatch between the two racing writers. This | 11 | //! companion may transiently mismatch between the two racing writers. This |
| 12 | //! case is accepted and unguarded — there is no locking across sessions. | 12 | //! case is accepted and unguarded — there is no locking across sessions. |
| 13 | //! Both the artifact and its `.sha256` companion are written via | ||
| 14 | //! temp-file-then-rename, so a reader (e.g. an HTTP download) never | ||
| 15 | //! observes a partially-written or truncated file for either — only a | ||
| 16 | //! fully-old or fully-new version, whichever `finish()` happened to win. | ||
| 13 | 17 | ||
| 14 | use std::io::Write; | 18 | use std::io::Write; |
| 15 | use std::path::{Path, PathBuf}; | 19 | use std::path::{Path, PathBuf}; |
| @@ -151,17 +155,35 @@ impl ReleaseUpload { | |||
| 151 | } | 155 | } |
| 152 | })?; | 156 | })?; |
| 153 | } | 157 | } |
| 154 | std::fs::write(&self.sha_dest, format!("{} {}\n", hex, self.filename))?; | 158 | // Write the companion via temp-file-then-rename too: std::fs::write() |
| 155 | // std::fs::write() creates the file honoring the process umask, so | 159 | // truncates the destination in place, so a download racing a |
| 156 | // under a restrictive umask (e.g. 077) the companion could end up | 160 | // --force re-upload could observe the companion mid-truncation |
| 157 | // 0600 while the artifact above is explicitly forced to 0644. | 161 | // (empty or partially written). A NamedTempFile + persist makes the |
| 158 | // Force an explicit mode so the two are consistent regardless of | 162 | // companion update atomic from a reader's point of view, just like |
| 159 | // umask. | 163 | // the artifact rename above. |
| 164 | let version_dir = self | ||
| 165 | .sha_dest | ||
| 166 | .parent() | ||
| 167 | .expect("sha_dest always has a parent (the version dir)"); | ||
| 168 | let mut sha_temp = NamedTempFile::new_in(version_dir)?; | ||
| 169 | sha_temp.write_all(format!("{} {}\n", hex, self.filename).as_bytes())?; | ||
| 170 | sha_temp.as_file().sync_all()?; | ||
| 171 | // std::fs::write() (the previous implementation) created the file | ||
| 172 | // honoring the process umask, so under a restrictive umask (e.g. | ||
| 173 | // 077) the companion could end up 0600 while the artifact above is | ||
| 174 | // explicitly forced to 0644. NamedTempFile also defaults to 0600; | ||
| 175 | // force an explicit mode so the two are consistent regardless of | ||
| 176 | // umask, before persisting. | ||
| 160 | #[cfg(unix)] | 177 | #[cfg(unix)] |
| 161 | { | 178 | { |
| 162 | use std::os::unix::fs::PermissionsExt; | 179 | use std::os::unix::fs::PermissionsExt; |
| 163 | std::fs::set_permissions(&self.sha_dest, std::fs::Permissions::from_mode(0o644))?; | 180 | sha_temp |
| 181 | .as_file() | ||
| 182 | .set_permissions(std::fs::Permissions::from_mode(0o644))?; | ||
| 164 | } | 183 | } |
| 184 | sha_temp | ||
| 185 | .persist(&self.sha_dest) | ||
| 186 | .map_err(|e| ReleaseError::Io(e.error))?; | ||
| 165 | Ok(hex) | 187 | Ok(hex) |
| 166 | } | 188 | } |
| 167 | } | 189 | } |
tests/release_cli_test.rs
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,109 @@ | |||
| 1 | mod common; | ||
| 2 | |||
| 3 | use std::process::Output; | ||
| 4 | |||
| 5 | use common::ServerHarness; | ||
| 6 | |||
| 7 | /// Run `git-collab release …` in the harness work repo against the harness SSH server. | ||
| 8 | fn release_cmd(harness: &ServerHarness, args: &[&str]) -> Output { | ||
| 9 | let mut cmd = harness.work_repo().cli_command(); | ||
| 10 | cmd.env("GIT_COLLAB_SSH_COMMAND", harness.ssh_command_string()); | ||
| 11 | cmd.args(["release"]).args(args); | ||
| 12 | cmd.output().expect("failed to run git-collab release") | ||
| 13 | } | ||
| 14 | |||
| 15 | fn setup(name: &str) -> ServerHarness { | ||
| 16 | let harness = ServerHarness::new(name); | ||
| 17 | harness.push_head(); | ||
| 18 | let url = harness.repo_ssh_url(); | ||
| 19 | harness.work_repo().git(&["remote", "add", "srv", &url]); | ||
| 20 | harness | ||
| 21 | } | ||
| 22 | |||
| 23 | #[test] | ||
| 24 | fn publish_list_delete_roundtrip() { | ||
| 25 | let harness = setup("cli-roundtrip"); | ||
| 26 | let tarball = harness.work_repo().dir.path().join("app.tar.gz"); | ||
| 27 | std::fs::write(&tarball, b"cli release bytes").unwrap(); | ||
| 28 | |||
| 29 | let publish = release_cmd( | ||
| 30 | &harness, | ||
| 31 | &["publish", "v1.0.0", tarball.to_str().unwrap(), "--remote", "srv"], | ||
| 32 | ); | ||
| 33 | assert!( | ||
| 34 | publish.status.success(), | ||
| 35 | "publish failed: {}{}", | ||
| 36 | String::from_utf8_lossy(&publish.stdout), | ||
| 37 | String::from_utf8_lossy(&publish.stderr) | ||
| 38 | ); | ||
| 39 | let out = String::from_utf8_lossy(&publish.stdout); | ||
| 40 | assert!(out.contains("Published v1.0.0/app.tar.gz")); | ||
| 41 | |||
| 42 | let list = release_cmd(&harness, &["list", "--remote", "srv"]); | ||
| 43 | assert!(list.status.success()); | ||
| 44 | assert!(String::from_utf8_lossy(&list.stdout).contains("v1.0.0")); | ||
| 45 | |||
| 46 | let list_json = release_cmd(&harness, &["list", "--json", "--remote", "srv"]); | ||
| 47 | let index: serde_json::Value = | ||
| 48 | serde_json::from_slice(&list_json.stdout).expect("list --json not valid JSON"); | ||
| 49 | assert_eq!(index["versions"][0]["files"][0]["name"], "app.tar.gz"); | ||
| 50 | |||
| 51 | let delete = release_cmd(&harness, &["delete", "v1.0.0", "--remote", "srv"]); | ||
| 52 | assert!(delete.status.success()); | ||
| 53 | let after: serde_json::Value = | ||
| 54 | serde_json::from_slice(&release_cmd(&harness, &["list", "--json", "--remote", "srv"]).stdout) | ||
| 55 | .unwrap(); | ||
| 56 | assert_eq!(after["versions"].as_array().unwrap().len(), 0); | ||
| 57 | } | ||
| 58 | |||
| 59 | #[test] | ||
| 60 | fn duplicate_publish_needs_force_flag() { | ||
| 61 | let harness = setup("cli-force"); | ||
| 62 | let tarball = harness.work_repo().dir.path().join("a.tar.gz"); | ||
| 63 | std::fs::write(&tarball, b"one").unwrap(); | ||
| 64 | let path = tarball.to_str().unwrap(); | ||
| 65 | |||
| 66 | assert!(release_cmd(&harness, &["publish", "v1", path, "--remote", "srv"]) | ||
| 67 | .status | ||
| 68 | .success()); | ||
| 69 | |||
| 70 | let dup = release_cmd(&harness, &["publish", "v1", path, "--remote", "srv"]); | ||
| 71 | assert!(!dup.status.success()); | ||
| 72 | assert!(String::from_utf8_lossy(&dup.stderr).contains("already exists")); | ||
| 73 | |||
| 74 | std::fs::write(&tarball, b"two").unwrap(); | ||
| 75 | let forced = release_cmd( | ||
| 76 | &harness, | ||
| 77 | &["publish", "v1", path, "--force", "--remote", "srv"], | ||
| 78 | ); | ||
| 79 | assert!(forced.status.success()); | ||
| 80 | } | ||
| 81 | |||
| 82 | #[test] | ||
| 83 | fn non_ssh_remote_is_a_clear_error() { | ||
| 84 | let harness = setup("cli-bad-remote"); | ||
| 85 | let tarball = harness.work_repo().dir.path().join("a.tar.gz"); | ||
| 86 | std::fs::write(&tarball, b"x").unwrap(); | ||
| 87 | |||
| 88 | // "origin" is a local filesystem path in the harness | ||
| 89 | let output = release_cmd( | ||
| 90 | &harness, | ||
| 91 | &["publish", "v1", tarball.to_str().unwrap(), "--remote", "origin"], | ||
| 92 | ); | ||
| 93 | assert!(!output.status.success()); | ||
| 94 | assert!(String::from_utf8_lossy(&output.stderr).contains("not an SSH remote")); | ||
| 95 | } | ||
| 96 | |||
| 97 | #[test] | ||
| 98 | fn invalid_version_rejected_client_side() { | ||
| 99 | let harness = setup("cli-bad-version"); | ||
| 100 | let tarball = harness.work_repo().dir.path().join("a.tar.gz"); | ||
| 101 | std::fs::write(&tarball, b"x").unwrap(); | ||
| 102 | |||
| 103 | let output = release_cmd( | ||
| 104 | &harness, | ||
| 105 | &["publish", "../evil", tarball.to_str().unwrap(), "--remote", "srv"], | ||
| 106 | ); | ||
| 107 | assert!(!output.status.success()); | ||
| 108 | assert!(String::from_utf8_lossy(&output.stderr).contains("invalid version")); | ||
| 109 | } | ||
tests/release_server_test.rs
| Old | New | ||
|---|---|---|---|
| @@ -374,3 +374,33 @@ fn http_releases_respect_repo_policy() { | |||
| 374 | head | 374 | head |
| 375 | ); | 375 | ); |
| 376 | } | 376 | } |
| 377 | |||
| 378 | /// The releases page is gated by allows_anonymous_ui; artifact downloads are | ||
| 379 | /// gated by allows_anonymous_http. These are separate gates: a policy that | ||
| 380 | /// allows the UI page but disables anonymous clone/download must still let | ||
| 381 | /// the page render (listing versions) while 404ing the actual download. This | ||
| 382 | /// pins the distinction so `release_download` can't accidentally be changed | ||
| 383 | /// to use `allows_anonymous_ui` instead of `allows_anonymous_http`. | ||
| 384 | #[test] | ||
| 385 | fn http_release_page_and_download_gates_are_independent() { | ||
| 386 | let harness = ServerHarness::new("release-http-gates"); | ||
| 387 | harness.push_head(); | ||
| 388 | harness.ssh_exec_with_stdin( | ||
| 389 | "collab-release upload 'release-http-gates.git' 'v1' 'a.tar.gz'", | ||
| 390 | b"gated", | ||
| 391 | ); | ||
| 392 | harness.write_repo_server_policy( | ||
| 393 | "visibility = \"public\"\n[ui]\nanonymous = true\n[http]\nanonymous_clone = false\n[access]\nread = [\"*\"]\nwrite = [\"*\"]\n", | ||
| 394 | ); | ||
| 395 | |||
| 396 | let page = harness.get_ok("/release-http-gates/releases"); | ||
| 397 | assert!(page.body.contains("v1")); | ||
| 398 | assert!(page.body.contains("a.tar.gz")); | ||
| 399 | |||
| 400 | let (head, _) = harness.get_bytes("/release-http-gates/releases/v1/a.tar.gz"); | ||
| 401 | assert!( | ||
| 402 | head.contains("404"), | ||
| 403 | "download must stay gated by allows_anonymous_http: {}", | ||
| 404 | head | ||
| 405 | ); | ||
| 406 | } | ||