Skip to main content

verctl/
publish.rs

1//! Publish the versions already on HEAD, then open a GitHub Release.
2//!
3//! How each package is published comes from `[publishers.*]`. Cargo and
4//! bun are stock recipes, not the only stacks. How tags are named comes
5//! from `[tags].template` — see [`tags_for`].
6
7use crate::config::{Config, NAME_PLACEHOLDER};
8use crate::git;
9use crate::github;
10use crate::process;
11use crate::publisher;
12use crate::release;
13use anyhow::{Context, Result, bail, ensure};
14use ctl_core::formatdoc;
15use serde::Serialize;
16use std::collections::BTreeMap;
17use std::path::Path;
18use std::time::Duration;
19
20pub const PUBLISH_TIMEOUT: Duration = Duration::from_mins(10);
21
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct PublishPlan {
24    pub packages: Vec<PublishEntry>,
25    pub tags: Vec<TagPlan>,
26}
27
28/// One tag the release will create, and the packages whose notes fill it.
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct TagPlan {
31    pub tag: String,
32    pub title: String,
33    pub packages: Vec<PublishEntry>,
34}
35
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct PublishEntry {
38    pub name: String,
39    pub version: String,
40    pub noun: String,
41    pub label: String,
42    pub argv: Vec<String>,
43}
44
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct PublishOutcome {
47    pub packages: Vec<PublishLine>,
48    pub releases: Vec<String>,
49}
50
51#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
52pub struct PublishLine {
53    pub name: String,
54    pub version: String,
55    pub via: String,
56    pub note: Option<String>,
57}
58
59pub fn plan(config: &Config, root: &Path) -> Result<PublishPlan> {
60    let mut packages = Vec::new();
61    for spec in &config.packages {
62        let path = root.join(&spec.path);
63        let driver = spec.resolve(config, root)?;
64        let raw = std::fs::read_to_string(&path).with_context(|| path.display().to_string())?;
65        let version = driver.read(&raw)?.trim().to_owned();
66        let (label, noun, argv) = publisher::resolve(config, spec, &path)?;
67        let label = match spec.registry.as_deref() {
68            Some(registry) if !registry.is_empty() => format!("{label} {registry}"),
69            _ => label,
70        };
71        packages.push(PublishEntry {
72            name: spec.name.clone(),
73            version,
74            noun,
75            label,
76            argv,
77        });
78    }
79    let tags = tags_for(config, &packages)?;
80    Ok(PublishPlan { packages, tags })
81}
82
83pub fn run(config: &Config, root: &Path, dry_run: bool) -> Result<PublishOutcome> {
84    let planned = plan(config, root)?;
85    if dry_run {
86        let releases = planned
87            .tags
88            .iter()
89            .map(|tag| formatdoc!("would create {tag}", tag = tag.tag))
90            .collect();
91        return Ok(PublishOutcome {
92            packages: planned.packages.iter().map(PublishLine::from).collect(),
93            releases,
94        });
95    }
96    prove_version_commit(config, root, &planned)?;
97    let token = release::resolve_token()?;
98    let mut packages = Vec::new();
99    for entry in &planned.packages {
100        let note = match publish_package(entry)? {
101            status if status.ends_with(" (already)") => Some("already".into()),
102            _ => None,
103        };
104        packages.push(PublishLine {
105            name: entry.name.clone(),
106            version: entry.version.clone(),
107            via: entry.label.clone(),
108            note,
109        });
110    }
111    let repo = github::repo(root)?;
112    let sha = git::head_sha(root)?;
113    let mut releases = Vec::new();
114    for tag in &planned.tags {
115        let shipped = tag.packages.iter().any(|entry| {
116            packages
117                .iter()
118                .any(|line| line.name == entry.name && line.note.is_none())
119        });
120        let notes = release::notes_for(
121            config,
122            root,
123            tag.packages
124                .iter()
125                .map(|entry| (entry.name.as_str(), entry.version.as_str())),
126        );
127        let url =
128            github::ensure_release(&token, &repo, &tag.tag, &tag.title, &notes, &sha, shipped)?;
129        releases.push(url);
130    }
131    Ok(PublishOutcome { packages, releases })
132}
133
134fn publish_package(entry: &PublishEntry) -> Result<String> {
135    let coord = format!("{}@{}", entry.name, entry.version);
136    match process::run_inherit(&entry.argv, PUBLISH_TIMEOUT) {
137        Ok(()) => Ok(coord),
138        Err(error) if already_published(&format!("{error:#}")) => Ok(format!("{coord} (already)")),
139        Err(error) => Err(error).context(coord),
140    }
141}
142
143impl From<&PublishEntry> for PublishLine {
144    fn from(entry: &PublishEntry) -> Self {
145        Self {
146            name: entry.name.clone(),
147            version: entry.version.clone(),
148            via: entry.label.clone(),
149            note: None,
150        }
151    }
152}
153
154fn prove_version_commit(config: &Config, root: &Path, planned: &PublishPlan) -> Result<()> {
155    release::require_changelog_versions(
156        config,
157        root,
158        planned
159            .packages
160            .iter()
161            .map(|entry| (entry.name.as_str(), entry.version.as_str())),
162    )?;
163    if let Some(branch) = git::configured_default_branch() {
164        git::ensure_origin_head(root, &branch, &git::FetchDefault::from_env())?;
165    }
166    git::require_on_default_history(root)
167}
168
169fn already_published(message: &str) -> bool {
170    let lower = message.to_ascii_lowercase();
171    lower.contains("already uploaded")
172        || lower.contains("already exists")
173        || lower.contains("cannot be republished")
174        || lower.contains("previously published")
175        || lower.contains("tolerate-republish")
176        || lower.contains("version already exists")
177}
178
179/// Render `[tags].template` over the packages that will ship.
180///
181/// A template without `{name}` names one tag for the whole release and refuses
182/// when the packages do not share one version. A template with `{name}` names
183/// one tag per package and refuses when two packages would render the same tag.
184pub fn tags_for(config: &Config, packages: &[PublishEntry]) -> Result<Vec<TagPlan>> {
185    ensure!(
186        !packages.is_empty(),
187        "the config declares no packages to publish"
188    );
189    if config.tags.uses_name() {
190        return per_package_tags(config, packages);
191    }
192    one_shared_tag(config, packages)
193}
194
195fn one_shared_tag(config: &Config, packages: &[PublishEntry]) -> Result<Vec<TagPlan>> {
196    let template = &config.tags.template;
197    let mut by_version: BTreeMap<&str, Vec<&PublishEntry>> = BTreeMap::new();
198    for entry in packages {
199        by_version
200            .entry(entry.version.as_str())
201            .or_default()
202            .push(entry);
203    }
204    if by_version.len() > 1 {
205        let detail = packages
206            .iter()
207            .map(|entry| format!("{}@{}", entry.name, entry.version))
208            .collect::<Vec<_>>()
209            .join(", ");
210        bail!(
211            "tags.template {template:?} has no {NAME_PLACEHOLDER}, so it can only name one tag, but this release has more than one version: {detail}"
212        );
213    }
214    let version = packages[0].version.as_str();
215    let tag = config.tags.render(version, None)?;
216    let title = packages.first().map_or_else(
217        || tag.clone(),
218        |entry| format!("{} {}", entry.name, entry.version),
219    );
220    Ok(vec![TagPlan {
221        tag,
222        title,
223        packages: packages.to_vec(),
224    }])
225}
226
227fn per_package_tags(config: &Config, packages: &[PublishEntry]) -> Result<Vec<TagPlan>> {
228    let template = &config.tags.template;
229    let mut seen: BTreeMap<String, String> = BTreeMap::new();
230    let mut tags = Vec::with_capacity(packages.len());
231    for entry in packages {
232        let tag = config.tags.render(&entry.version, Some(&entry.name))?;
233        if let Some(earlier) = seen.insert(tag.clone(), entry.name.clone()) {
234            bail!(
235                "tags.template {template:?} renders the same tag {tag:?} for {earlier} and {}",
236                entry.name
237            );
238        }
239        tags.push(TagPlan {
240            tag,
241            title: format!("{} {}", entry.name, entry.version),
242            packages: vec![entry.clone()],
243        });
244    }
245    Ok(tags)
246}
247
248#[cfg(test)]
249mod tests {
250    use super::{PublishEntry, already_published, tags_for};
251    use crate::config::Config;
252    use indoc::indoc;
253
254    fn entry(name: &str, version: &str) -> PublishEntry {
255        PublishEntry {
256            name: name.into(),
257            version: version.into(),
258            noun: "crate".into(),
259            label: "cargo".into(),
260            argv: vec!["true".into()],
261        }
262    }
263
264    fn config(tags: &str) -> Config {
265        let body = if tags.is_empty() {
266            indoc! {"
267                packages:
268                  - name: demo
269                    path: Cargo.toml
270            "}
271            .to_owned()
272        } else {
273            formatdoc_config(tags)
274        };
275        Config::parse(&body).unwrap_or_else(|error| panic!("{error:#}"))
276    }
277
278    fn formatdoc_config(tags: &str) -> String {
279        use ctl_core::formatdoc;
280        formatdoc! {"
281            packages:
282              - name: demo
283                path: Cargo.toml
284
285            tags:
286              template: {tags:?}
287        "}
288    }
289
290    #[test]
291    fn already_uploaded_is_ok() {
292        assert!(already_published(
293            "crate ctl-core@0.0.1 already exists on crates.io"
294        ));
295        assert!(already_published("error: crate already uploaded"));
296        assert!(already_published("version already exists"));
297        assert!(!already_published("token is required"));
298    }
299
300    #[test]
301    fn default_template_is_one_v_version_tag() {
302        let planned = tags_for(&config(""), &[entry("demo", "1.2.3")]).unwrap();
303        assert_eq!(planned.len(), 1);
304        assert_eq!(planned[0].tag, "v1.2.3");
305        assert_eq!(planned[0].packages.len(), 1);
306    }
307
308    #[test]
309    fn shared_version_across_packages_is_one_tag() {
310        let planned = tags_for(&config(""), &[entry("a", "1.0.0"), entry("b", "1.0.0")]).unwrap();
311        assert_eq!(planned.len(), 1);
312        assert_eq!(planned[0].tag, "v1.0.0");
313        assert_eq!(planned[0].packages.len(), 2);
314    }
315
316    #[test]
317    fn differing_versions_without_name_refuse() {
318        let error = tags_for(&config(""), &[entry("a", "1.0.0"), entry("b", "2.0.0")])
319            .expect_err("must refuse");
320        let message = format!("{error:#}");
321        assert!(message.contains("{name}"), "{message}");
322        assert!(message.contains("a@1.0.0"), "{message}");
323        assert!(message.contains("b@2.0.0"), "{message}");
324    }
325
326    #[test]
327    fn per_package_template_names_each_tag() {
328        let planned = tags_for(
329            &config("{name}@{version}"),
330            &[entry("a", "1.0.0"), entry("b", "2.0.0")],
331        )
332        .unwrap();
333        assert_eq!(
334            planned
335                .iter()
336                .map(|tag| tag.tag.as_str())
337                .collect::<Vec<_>>(),
338            ["a@1.0.0", "b@2.0.0"]
339        );
340        assert!(planned.iter().all(|tag| tag.packages.len() == 1));
341    }
342
343    #[test]
344    fn same_version_with_name_still_one_tag_each() {
345        let planned = tags_for(
346            &config("{name}@{version}"),
347            &[entry("a", "1.0.0"), entry("b", "1.0.0")],
348        )
349        .unwrap();
350        assert_eq!(
351            planned
352                .iter()
353                .map(|tag| tag.tag.as_str())
354                .collect::<Vec<_>>(),
355            ["a@1.0.0", "b@1.0.0"]
356        );
357    }
358
359    #[test]
360    fn colliding_per_package_template_refuses() {
361        let error = tags_for(
362            &config("{name}@{version}"),
363            &[entry("dup", "1.0.0"), entry("dup", "1.0.0")],
364        )
365        .expect_err("same rendered tag");
366        assert!(format!("{error:#}").contains("same tag"), "{error:#}");
367    }
368}