a73x

49dbd7b9

Add the release publish, list and delete CLI

a73x   2026-08-08 15:43

Commit message
Add the release publish, list and delete CLI

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 }