xtask/cmd/release/
publish.rs

1use 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    /// Packages to include in the check
11    /// by default all packages are included
12    #[arg(long = "package", short = 'p')]
13    packages: Vec<String>,
14    /// Allow the command to execute even if there are uncomitted changes in the workspace
15    #[arg(long)]
16    allow_dirty: bool,
17    /// Concurrency to run at. By default, this is the total number of cpus on the host.
18    #[arg(long, default_value_t = num_cpus::get())]
19    concurrency: usize,
20    /// If we should always run release even if its not from a PR that had the `release` label
21    #[arg(long)]
22    always: bool,
23    /// Do not release anything.
24    #[arg(long)]
25    dry_run: bool,
26    /// Token to use when uploading to crates.io
27    #[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        // get current commit
49        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(&current_commit.stderr)
59            );
60        }
61
62        let current_commit = String::from_utf8_lossy(&current_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}