use std::fmt::Display;
use axoproject::PackageIdx;
use axotag::{parse_tag, Package, PartialAnnouncementTag, ReleaseType};
use cargo_dist_schema::{DistManifest, GithubHosting, TripleName, TripleNameRef};
use itertools::Itertools;
use semver::Version;
use tracing::info;
use crate::{
config::LibraryStyle,
errors::{DistError, DistResult},
platform::triple_to_display_name,
DistGraphBuilder, SortedMap,
};
pub(crate) struct AnnouncementTag {
pub tag: String,
pub version: Option<Version>,
pub package: Option<PackageIdx>,
pub prerelease: bool,
pub rust_releases: Vec<ReleaseArtifacts>,
}
#[derive(Debug, PartialEq)]
pub(crate) struct ReleaseArtifacts {
pub package_idx: PackageIdx,
pub executables: Vec<String>,
pub cdylibs: Vec<String>,
pub cstaticlibs: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct TagSettings {
pub needs_coherence: bool,
pub tag: TagMode,
}
#[derive(Debug, Clone)]
pub enum TagMode {
Infer,
Select(String),
Force(String),
ForceMaxAndTimestamp,
}
impl<'a> DistGraphBuilder<'a> {
pub(crate) fn compute_announcement_info(&mut self, announcing: &AnnouncementTag) {
self.manifest.announcement_title = Some(announcing.tag.clone());
self.manifest.announcement_tag = Some(announcing.tag.clone());
self.manifest.announcement_is_prerelease = announcing.prerelease;
self.compute_announcement_changelog(announcing);
self.compute_announcement_github();
}
pub fn compute_announcement_changelog(&mut self, announcing: &AnnouncementTag) {
let info = if let Some(announcing_version) = &announcing.version {
let version = axoproject::Version::Cargo(announcing_version.clone());
let Ok(Some(info)) = self
.workspaces
.root_workspace()
.changelog_for_version(&version)
else {
info!(
"failed to find {version} in workspace changelogs, skipping changelog generation"
);
return;
};
info
} else if let Some(announcing_package) = announcing.package {
let package = self.workspaces.package(announcing_package);
let package_name = &package.name;
let version = package
.version
.as_ref()
.expect("cargo package without a version!?");
let Ok(Some(info)) = self
.workspaces
.package(announcing_package)
.changelog_for_version(version)
else {
info!(
"failed to find {version} in {package_name} changelogs, skipping changelog generation"
);
return;
};
info
} else {
unreachable!("you're neither announcing a version or a package!?");
};
info!("successfully parsed changelog!");
self.manifest.announcement_title = Some(info.title);
let clean_notes = newline_converter::dos2unix(&info.body);
self.manifest.announcement_changelog = Some(clean_notes.into_owned());
}
fn compute_announcement_github(&mut self) {
announcement_github(&mut self.manifest);
}
}
enum DisabledReason {
DistFalse,
NoArtifacts { kinds: Vec<String> },
PublishFalse,
TagNotMatched { tag: String },
}
impl Display for DisabledReason {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::DistFalse => write!(f, "dist = false"),
Self::PublishFalse => write!(f, "publish = false"),
Self::NoArtifacts { kinds } => write!(f, "no {}", kinds.join(" ")),
Self::TagNotMatched { tag } => write!(f, "didn't match tag {}", tag),
}
}
}
fn check_dist_package(
graph: &DistGraphBuilder,
pkg_id: PackageIdx,
pkg: &axoproject::PackageInfo,
announcing: &PartialAnnouncementTag,
) -> Option<DisabledReason> {
let config = graph.package_config(pkg_id).clone();
let mut package_empty = pkg.binaries.is_empty();
let mut missing_categories = vec!["binaries".to_owned()];
if config
.artifacts
.archives
.package_libraries
.contains(&LibraryStyle::CDynamic)
{
package_empty &= pkg.cdylibs.is_empty();
missing_categories.push("cdylibs".to_owned());
}
if config
.artifacts
.archives
.package_libraries
.contains(&LibraryStyle::CStatic)
{
package_empty &= pkg.cstaticlibs.is_empty();
missing_categories.push("cstaticlibs".to_owned());
}
if package_empty {
return Some(DisabledReason::NoArtifacts {
kinds: missing_categories,
});
}
let do_dist = pkg.dist.or(config.dist);
let override_publish = if let Some(do_dist) = do_dist {
if !do_dist {
return Some(DisabledReason::DistFalse);
} else {
true
}
} else {
false
};
if !pkg.publish && !override_publish {
return Some(DisabledReason::PublishFalse);
}
match &announcing.release {
ReleaseType::Package { idx, version: _ } => {
if pkg_id != PackageIdx(*idx) {
return Some(DisabledReason::TagNotMatched {
tag: announcing.tag.to_owned(),
});
}
}
ReleaseType::Version(ver) => {
if pkg.version.as_ref().unwrap().semver() != *ver {
return Some(DisabledReason::TagNotMatched {
tag: announcing.tag.to_owned(),
});
}
}
ReleaseType::None => {}
}
None
}
pub(crate) fn select_tag(
graph: &mut DistGraphBuilder,
settings: &TagSettings,
) -> DistResult<AnnouncementTag> {
let mut announcing = match &settings.tag {
TagMode::Select(tag) => {
parse_tag_for_all_packages(graph, tag)?
}
TagMode::Infer | TagMode::ForceMaxAndTimestamp | TagMode::Force(_) => {
PartialAnnouncementTag::default()
}
};
let releases = select_packages(graph, &announcing);
require_releases(graph, &releases)?;
ensure_tag(graph, &releases, &mut announcing, settings)?;
require_axotag_consistency(graph, &announcing, settings)?;
let mut version = None;
let mut package = None;
match &announcing.release {
ReleaseType::Package { idx, version: _ } => package = Some(PackageIdx(*idx)),
ReleaseType::Version(ver) => version = Some(ver.clone()),
ReleaseType::None => {
unreachable!("internal dist error: failed to ensure a release tag")
}
}
let prerelease = if graph.manifest.force_latest {
false
} else {
announcing.prerelease
};
Ok(AnnouncementTag {
tag: announcing.tag,
version,
package,
prerelease,
rust_releases: releases,
})
}
fn require_axotag_consistency(
graph: &mut DistGraphBuilder,
announcing: &PartialAnnouncementTag,
settings: &TagSettings,
) -> DistResult<()> {
if !settings.needs_coherence {
return Ok(());
}
let expected = announcing;
let computed = parse_tag_for_all_packages(graph, &announcing.tag)?;
match (&computed.release, &expected.release) {
(ReleaseType::Version(computed), ReleaseType::Version(expected)) => {
assert_eq!(
computed, expected,
"internal dist error: axotag parsed a different version from tag"
);
}
(
ReleaseType::Package {
version: computed_ver,
..
},
ReleaseType::Package {
version: expected_ver,
..
},
) => {
assert_eq!(
computed_ver, expected_ver,
"internal dist error: axotag parsed a different version from tag"
);
}
(ReleaseType::None, _) | (_, ReleaseType::None) => {
unreachable!("internal dist error: failed to ensure a release tag")
}
_ => {
unreachable!("internal dist error: axotag parsed tag as different class of tag");
}
}
assert_eq!(
computed.tag, expected.tag,
"internal dist error: axotag parsed a different version from tag"
);
assert_eq!(
computed.prerelease, expected.prerelease,
"internal dist error: axotag disagreed on prerelease status"
);
Ok(())
}
fn select_packages(
graph: &DistGraphBuilder,
announcing: &PartialAnnouncementTag,
) -> Vec<ReleaseArtifacts> {
info!("");
info!("selecting packages from workspace: ");
let disabled_sty = console::Style::new().dim();
let enabled_sty = console::Style::new();
let mut releases = vec![];
for (pkg_id, pkg) in graph.workspaces.all_packages() {
let pkg_name = &pkg.name;
let disabled_reason = check_dist_package(graph, pkg_id, pkg, announcing);
let sty;
if let Some(reason) = &disabled_reason {
sty = &disabled_sty;
info!(" {}", sty.apply_to(format!("{pkg_name} ({reason})")));
} else {
sty = &enabled_sty;
info!(" {}", sty.apply_to(pkg_name));
}
let mut binaries = vec![];
for binary in &pkg.binaries {
info!(" {}", sty.apply_to(format!("[bin] {}", binary)));
if disabled_reason.is_none() {
binaries.push(binary.to_owned());
}
}
let mut cdylibs = vec![];
for library in &pkg.cdylibs {
info!(" {}", sty.apply_to(format!("[cdylib] {}", library)));
if disabled_reason.is_none() {
cdylibs.push(library.to_owned());
}
}
let mut cstaticlibs = vec![];
for library in &pkg.cstaticlibs {
info!(" {}", sty.apply_to(format!("[cstaticlib] {}", library)));
if disabled_reason.is_none() {
cstaticlibs.push(library.to_owned());
}
}
if !binaries.is_empty() || !cdylibs.is_empty() || !cstaticlibs.is_empty() {
let release = ReleaseArtifacts {
package_idx: pkg_id,
executables: binaries,
cdylibs,
cstaticlibs,
};
releases.push(release);
}
}
info!("");
if releases.is_empty() {
if let ReleaseType::Package { idx, version: _ } = announcing.release {
let pkg_idx = PackageIdx(idx);
let pkg = graph.workspaces.package(pkg_idx);
let config = graph.package_config(pkg_idx);
let do_dist = pkg.dist.or(config.dist);
if do_dist != Some(false) {
releases.push(ReleaseArtifacts {
package_idx: PackageIdx(idx),
executables: vec![],
cdylibs: vec![],
cstaticlibs: vec![],
});
}
}
}
releases
}
fn require_releases(graph: &DistGraphBuilder, releases: &[ReleaseArtifacts]) -> DistResult<()> {
if !releases.is_empty() {
return Ok(());
}
let announcing = PartialAnnouncementTag::default();
let rust_releases = select_packages(graph, &announcing);
let versions = possible_tags(
graph,
rust_releases.iter().map(|release| release.package_idx),
);
let help = tag_help(graph, versions, "You may need to pass the current version as --tag, or need to give all your packages the same version");
Err(DistError::NothingToRelease { help })
}
fn ensure_tag(
graph: &mut DistGraphBuilder,
releases: &[ReleaseArtifacts],
announcing: &mut PartialAnnouncementTag,
settings: &TagSettings,
) -> DistResult<()> {
if !matches!(announcing.release, ReleaseType::None) {
return Ok(());
}
match &settings.tag {
TagMode::Select(_) => {
unreachable!("internal dist error: tag selection should have picked a tag");
}
TagMode::Infer => {
let versions = possible_tags(graph, releases.iter().map(|release| release.package_idx));
if versions.len() == 1 {
let version = versions.first_key_value().as_ref().unwrap().0;
let tag = format!("v{version}");
info!("inferred Announcement tag: {}", tag);
*announcing = parse_tag_for_all_packages(graph, &tag)?;
} else if settings.needs_coherence {
let help = tag_help(
graph,
versions,
"Please either specify --tag, or give them all the same version",
);
return Err(DistError::TooManyUnrelatedApps { help });
} else {
"v1.0.0-FAKEVER".clone_into(&mut announcing.tag);
announcing.prerelease = true;
announcing.release = ReleaseType::Version("1.0.0-FAKEVER".parse().unwrap());
}
}
TagMode::Force(tag) => {
*announcing = parse_tag_for_all_packages(graph, tag)?;
match &announcing.release {
ReleaseType::None => {
unreachable!("internal dist error: tag selection should have picked a tag")
}
ReleaseType::Version(version) => {
let packages = releases.iter().map(|release| release.package_idx);
overwrite_package_versions(graph, packages.clone(), version);
}
ReleaseType::Package { idx, version } => {
overwrite_package_versions(graph, Some(PackageIdx(*idx)), version);
}
}
}
TagMode::ForceMaxAndTimestamp => {
let packages = releases.iter().map(|release| release.package_idx);
let mut forced_version = maximum_version(graph, packages.clone()).unwrap();
timestamp_version(&mut forced_version);
overwrite_package_versions(graph, packages.clone(), &forced_version);
let tag = format!("v{forced_version}");
*announcing = parse_tag_for_all_packages(graph, &tag)?;
}
}
Ok(())
}
fn timestamp_version(version: &mut Version) {
if version.pre.is_empty() {
version.pre = semver::Prerelease::new("alpha").unwrap();
}
let now = std::time::SystemTime::now();
let secs = now.duration_since(std::time::UNIX_EPOCH).unwrap().as_secs();
version.pre = semver::Prerelease::new(&format!("{}.{}", version.pre, secs)).unwrap();
}
fn maximum_version(
graph: &DistGraphBuilder,
packages: impl IntoIterator<Item = PackageIdx>,
) -> Option<Version> {
packages
.into_iter()
.filter_map(|pkg_idx| graph.workspaces.package(pkg_idx).version.as_ref())
.map(|v| v.semver())
.max()
.clone()
}
fn overwrite_package_versions(
graph: &mut DistGraphBuilder,
packages: impl IntoIterator<Item = PackageIdx>,
version: &Version,
) {
for pkg_idx in packages {
graph.workspaces.package_mut(pkg_idx).version =
Some(axoproject::Version::Cargo(version.clone()));
}
}
fn parse_tag_for_all_packages(
graph: &DistGraphBuilder,
tag: &str,
) -> DistResult<PartialAnnouncementTag> {
let packages: Vec<Package> = graph
.workspaces
.all_packages()
.map(|(_, info)| Package {
name: info.name.clone(),
version: info.version.clone().map(|v| v.semver().clone()),
})
.collect();
let announcing = parse_tag(&packages, tag)?;
Ok(announcing)
}
fn possible_tags(
graph: &DistGraphBuilder,
rust_releases: impl IntoIterator<Item = PackageIdx>,
) -> SortedMap<Version, Vec<PackageIdx>> {
let mut versions = SortedMap::<Version, Vec<PackageIdx>>::new();
for pkg_idx in rust_releases {
let info = graph.workspaces.package(pkg_idx);
let version = info.version.as_ref().unwrap().semver();
versions.entry(version).or_default().push(pkg_idx);
}
versions
}
fn tag_help(
graph: &DistGraphBuilder,
versions: SortedMap<Version, Vec<PackageIdx>>,
base_suggestion: &str,
) -> String {
use std::fmt::Write;
let mut help = String::new();
let Some(some_pkg) = versions
.first_key_value()
.and_then(|(_, packages)| packages.first())
else {
return r#"It appears that you have no packages in your workspace with distable binaries. You can rerun with "--verbose=info" to see what dist thinks is in your workspace. Here are some typical issues:
If you're trying to use dist to announce libraries, we require you explicitly select the library with e.g. "--tag=my-library-v1.0.0", as this mode is experimental.
If you have binaries in your workspace, `publish = false` could be hiding them and adding "dist = true" to [package.metadata.dist] in your Cargo.toml may help."#.to_owned();
};
help.push_str(base_suggestion);
help.push_str("\n\n");
help.push_str("Here are some options:\n\n");
for (version, packages) in &versions {
write!(help, "--tag=v{version} will Announce: ").unwrap();
let mut multi_package = false;
for &pkg_id in packages {
let info = graph.workspaces.package(pkg_id);
if multi_package {
write!(help, ", ").unwrap();
} else {
multi_package = true;
}
write!(help, "{}", info.name).unwrap();
}
writeln!(help).unwrap();
}
help.push('\n');
let info = graph.workspaces.package(*some_pkg);
let some_tag = format!(
"--tag={}-v{}",
info.name,
info.version.as_ref().unwrap().semver()
);
writeln!(
help,
"you can also request any single package with {some_tag}"
)
.unwrap();
help
}
pub fn announcement_axodotdev(manifest: &DistManifest) -> String {
let title = manifest.announcement_title.clone().unwrap_or_default();
let body = manifest.announcement_changelog.clone().unwrap_or_default();
format!("# {title}\n\n{body}")
}
pub fn announcement_github(manifest: &mut DistManifest) {
use std::fmt::Write;
let mut gh_body = String::new();
if let Some(changelog) = manifest.announcement_changelog.as_ref() {
gh_body.push_str("## Release Notes\n\n");
gh_body.push_str(changelog);
gh_body.push_str("\n\n");
}
let mut announcing_github = false;
for release in &manifest.releases {
if release.hosting.github.is_none() {
continue;
}
if !release.display.unwrap_or(true) {
continue;
}
announcing_github = true;
let display_name = release.display_name.as_ref().unwrap_or(&release.app_name);
let heading_suffix = format!("{} {}", display_name, release.app_version);
if manifest.releases.len() > 1 {
writeln!(gh_body, "# {heading_suffix}\n").unwrap();
}
let mut global_installers = vec![];
let mut local_installers = vec![];
let mut bundles = vec![];
let mut symbols = vec![];
for (_name, artifact) in manifest.artifacts_for_release(release) {
match artifact.kind {
cargo_dist_schema::ArtifactKind::ExecutableZip => bundles.push(artifact),
cargo_dist_schema::ArtifactKind::Symbols => symbols.push(artifact),
cargo_dist_schema::ArtifactKind::Installer => {
if let (Some(desc), Some(hint)) =
(&artifact.description, &artifact.install_hint)
{
global_installers.push((desc, hint));
} else {
local_installers.push(artifact);
}
}
cargo_dist_schema::ArtifactKind::Checksum => {
}
cargo_dist_schema::ArtifactKind::Unknown => {
}
_ => {
}
}
}
if !global_installers.is_empty() {
writeln!(gh_body, "## Install {heading_suffix}\n").unwrap();
for (desc, hint) in global_installers {
writeln!(&mut gh_body, "### {}\n", desc).unwrap();
writeln!(&mut gh_body, "```sh\n{}\n```\n", hint).unwrap();
}
}
let mut other_artifacts: Vec<_> = bundles
.into_iter()
.chain(local_installers)
.chain(symbols)
.collect();
other_artifacts.sort_by_cached_key(|a| sortable_triples(&a.target_triples));
let download_url = release
.artifact_download_urls()
.unwrap_or_default()
.into_iter()
.next();
if !other_artifacts.is_empty() {
if let Some(download_url) = download_url {
writeln!(gh_body, "## Download {heading_suffix}\n",).unwrap();
gh_body.push_str("| File | Platform | Checksum |\n");
gh_body.push_str("|--------|----------|----------|\n");
for artifact in &other_artifacts {
let Some(name) = &artifact.name else {
continue;
};
let artifact_download_url = format!("{download_url}/{name}");
let download = format!("[{name}]({artifact_download_url})");
let checksum = if let Some(checksum_name) = &artifact.checksum {
let checksum_download_url = format!("{download_url}/{checksum_name}");
format!("[checksum]({checksum_download_url})")
} else {
String::new()
};
let mut triple = artifact
.target_triples
.iter()
.map(|t| triple_to_display_name(t).unwrap_or_else(|| t.as_str()))
.join(", ");
if triple.is_empty() {
triple = "Unknown".to_string();
}
writeln!(&mut gh_body, "| {download} | {triple} | {checksum} |").unwrap();
}
writeln!(&mut gh_body).unwrap();
}
}
if !other_artifacts.is_empty() && manifest.github_attestations {
if let Some(GithubHosting { owner, repo, .. }) = &release.hosting.github {
writeln!(&mut gh_body, "## Verifying GitHub Artifact Attestations\n",).unwrap();
writeln!(&mut gh_body, "The artifacts in this release have attestations generated with GitHub Artifact Attestations. These can be verified by using the [GitHub CLI](https://cli.github.com/manual/gh_attestation_verify):").unwrap();
writeln!(
&mut gh_body,
"```sh\ngh attestation verify <file-path of downloaded artifact> --repo {owner}/{repo}\n```\n",
).unwrap();
writeln!(&mut gh_body, "You can also download the attestation from [GitHub](https://github.com/{owner}/{repo}/attestations) and verify against that directly:").unwrap();
writeln!(
&mut gh_body,
"```sh\ngh attestation verify <file-path of downloaded artifact> --bundle <file-path of downloaded attestation>\n```\n",
).unwrap();
}
}
}
if announcing_github {
info!("successfully generated github release body!");
manifest.announcement_github_body = Some(gh_body);
}
}
fn sortable_triples(triples: &[TripleName]) -> Vec<Vec<String>> {
let mut output: Vec<Vec<String>> = triples.iter().map(|t| sortable_triple(t)).collect();
output.sort();
output
}
fn sortable_triple(triple: &TripleNameRef) -> Vec<String> {
let mut parts = triple.as_str().split('-');
let arch = parts.next();
let order = parts.chain(arch);
order.map(|s| s.to_owned()).collect()
}
#[cfg(test)]
mod tests {
use cargo_dist_schema::TripleNameRef;
use super::sortable_triple;
#[test]
fn sort_platforms() {
let mut targets = vec![
"aarch64-unknown-linux-gnu",
"x86_64-unknown-linux-gnu",
"i686-unknown-linux-gnu",
"aarch64-apple-darwin",
"x86_64-apple-darwin",
"aarch64-unknown-linux-musl",
"x86_64-unknown-linux-musl",
"i686-unknown-linux-musl",
"aarch64-pc-windows-msvc",
"x86_64-pc-windows-msvc",
"i686-pc-windows-msvc",
"armv7-unknown-linux-gnueabihf",
"powerpc64-unknown-linux-gnu",
"powerpc64le-unknown-linux-gnu",
"s390x-unknown-linux-gnu",
"aarch64-fuschsia",
"x86_64-fuschsia",
"universal2-apple-darwin",
"x86_64-unknown-linux-gnu.2.31",
"x86_64-unknown-linux-musl-static",
];
targets.sort_by_cached_key(|t| sortable_triple(TripleNameRef::from_str(t)));
assert_eq!(
targets,
vec![
"aarch64-apple-darwin",
"universal2-apple-darwin",
"x86_64-apple-darwin",
"aarch64-fuschsia",
"x86_64-fuschsia",
"aarch64-pc-windows-msvc",
"i686-pc-windows-msvc",
"x86_64-pc-windows-msvc",
"aarch64-unknown-linux-gnu",
"i686-unknown-linux-gnu",
"powerpc64-unknown-linux-gnu",
"powerpc64le-unknown-linux-gnu",
"s390x-unknown-linux-gnu",
"x86_64-unknown-linux-gnu",
"x86_64-unknown-linux-gnu.2.31",
"armv7-unknown-linux-gnueabihf",
"aarch64-unknown-linux-musl",
"i686-unknown-linux-musl",
"x86_64-unknown-linux-musl-static",
"x86_64-unknown-linux-musl",
]
);
}
}