xtask/cmd/release/
publish.rs1use std::collections::HashSet;
2
3use anyhow::Context;
4
5use super::utils::{GitReleaseArtifact, Package};
6use crate::utils::{self, Command, cargo_cmd, concurrently, git_workdir_clean};
7
8#[derive(Debug, Clone, clap::Parser)]
9pub struct Publish {
10 #[arg(long = "package", short = 'p')]
13 packages: Vec<String>,
14 #[arg(long)]
16 allow_dirty: bool,
17 #[arg(long, default_value_t = num_cpus::get())]
19 concurrency: usize,
20 #[arg(long)]
22 always: bool,
23 #[arg(long)]
25 dry_run: bool,
26 #[arg(long)]
28 crates_io_token: Option<String>,
29}
30
31#[derive(serde_derive::Deserialize)]
32struct PrOutput {
33 merge_commit_sha: Option<String>,
34 labels: Vec<PrLabel>,
35}
36
37#[derive(serde_derive::Deserialize)]
38struct PrLabel {
39 name: String,
40}
41
42impl Publish {
43 pub fn run(self) -> anyhow::Result<()> {
44 if !self.allow_dirty {
45 git_workdir_clean()?;
46 }
47
48 let current_commit = Command::new("git")
50 .arg("rev-parse")
51 .arg("HEAD")
52 .output()
53 .context("git rev-parse HEAD")?;
54
55 if !current_commit.status.success() {
56 anyhow::bail!(
57 "failed to get current commit sha: {}",
58 String::from_utf8_lossy(¤t_commit.stderr)
59 );
60 }
61
62 let current_commit = String::from_utf8_lossy(¤t_commit.stdout);
63 let current_commit = current_commit.trim();
64
65 if !self.always {
66 let gh_pulls = Command::new("gh")
67 .arg("api")
68 .arg(format!("repos/scufflecloud/scuffle/commits/{current_commit}/pulls"))
69 .output()
70 .context("gh api")?;
71
72 let mut skip_release = true;
73
74 if !gh_pulls.status.success() {
75 let stderr = String::from_utf8_lossy(&gh_pulls.stderr);
76 if !stderr.contains("No commit found for SHA") {
77 anyhow::bail!("failed to get pulls related to commit: {stderr}");
78 }
79 } else {
80 let gh_pulls: Vec<PrOutput> = serde_json::from_slice(&gh_pulls.stdout).context("gh pulls")?;
81 if gh_pulls
82 .iter()
83 .find(|pr| pr.merge_commit_sha.as_ref().is_some_and(|commit| commit == current_commit))
84 .is_some_and(|pr| pr.labels.iter().any(|label| label.name.eq_ignore_ascii_case("release")))
85 {
86 skip_release = false;
87 }
88 }
89
90 if skip_release {
91 tracing::info!("not releasing because commit isnt from a pull request with the `release` label.");
92 return Ok(());
93 }
94 }
95
96 let metadata = utils::metadata().context("metadata")?;
97
98 let packages = {
99 let members = metadata.workspace_members.iter().collect::<HashSet<_>>();
100 metadata
101 .packages
102 .iter()
103 .filter(|p| members.contains(&p.id))
104 .filter(|p| self.packages.contains(&p.name) || self.packages.is_empty())
105 .map(|p| Package::new(p.clone()))
106 .collect::<anyhow::Result<Vec<_>>>()?
107 };
108
109 concurrently::<_, _, anyhow::Result<()>>(self.concurrency, packages.iter(), |p| p.fetch_published())?;
110
111 let mut crates_io_publish = Vec::new();
112 let mut git_releases = Vec::new();
113
114 for package in &packages {
115 if package.last_published_version().is_some_and(|p| p.vers == package.version) {
116 tracing::info!("{}@{} has already been released on crates.io", package.name, package.version);
117 } else if package.should_publish() {
118 tracing::info!("{}@{} has not yet been published", package.name, package.version);
119 crates_io_publish.push(&package.name);
120 }
121
122 if let Some(git) = package.git_release().context("git release")? {
123 git_releases.push((package, git));
124 }
125 }
126
127 if !crates_io_publish.is_empty() {
128 let mut release_cmd = cargo_cmd();
129
130 release_cmd
131 .env("RUSTC_BOOTSTRAP", "1")
132 .arg("-Zunstable-options")
133 .arg("-Zpackage-workspace")
134 .arg("publish")
135 .arg("--no-verify");
136
137 if self.dry_run {
138 release_cmd.arg("--dry-run");
139 }
140
141 for package in &crates_io_publish {
142 release_cmd.arg("-p").arg(package.as_ref());
143 }
144
145 if let Some(token) = &self.crates_io_token {
146 release_cmd.arg("--token").arg(token);
147 }
148
149 if !release_cmd.status().context("crates io release")?.success() {
150 anyhow::bail!("failed to publish crates");
151 }
152 }
153
154 for (package, release) in &git_releases {
155 let gh_release_view = Command::new("gh")
156 .arg("release")
157 .arg("view")
158 .arg(release.tag_name.trim())
159 .arg("--json")
160 .arg("url")
161 .output()
162 .context("gh release view")?;
163
164 if gh_release_view.status.success() {
165 tracing::info!("{} is already released", release.tag_name.trim());
166 continue;
167 }
168
169 let mut gh_release_create = Command::new("gh");
170
171 gh_release_create
172 .arg("release")
173 .arg("create")
174 .arg(release.tag_name.trim())
175 .arg("--target")
176 .arg(current_commit)
177 .arg("--title")
178 .arg(release.name.trim())
179 .arg("--notes")
180 .arg(release.body.trim());
181
182 for artifact in &release.artifacts {
183 match artifact {
184 GitReleaseArtifact::File { path, name } => {
185 let artifact = package.manifest_path.parent().unwrap().join(path);
186 let name = name.as_deref().or_else(|| artifact.file_name());
187 gh_release_create.arg(if let Some(name) = name {
188 format!("{artifact}#{name}")
189 } else {
190 artifact.to_string()
191 });
192 }
193 }
194 }
195
196 if !self.dry_run {
197 if !gh_release_create.status().context("gh release create")?.success() {
198 anyhow::bail!("failed to create gh release");
199 }
200 } else {
201 tracing::info!("skipping running: {gh_release_create}")
202 }
203 }
204
205 tracing::info!("released packages");
206
207 Ok(())
208 }
209}