diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 1c35b1a5f99..a44b83730b8 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -7,6 +7,18 @@ +# Rollback safety impact + + + # Expected complexity level and risk ") { + remaining = &remaining[end + 3..]; + in_comment = false; + } else { + break; + } + } else if let Some(start) = remaining.find("\n"; + assert_eq!(rollback_dependencies("", REPO, None).unwrap(), None); + assert!( + rollback_dependencies("# Rollback safety impact\nN/A", REPO, Some(template)) + .unwrap() + .unwrap() + .is_empty() + ); + assert_eq!( + rollback_dependencies("# Rollback safety impact\n#12", REPO, Some(template)).unwrap(), + Some(BTreeSet::from([PullRequestRef { + repo: REPO.into(), + number: 12, + }])) + ); + } + + #[test] + fn recognizes_references_and_ignores_comments_and_fences() { + let section = + "#12 SpacetimeDBPrivate#13 other/repo#14 https://github.com/x/y/pull/15\n\n```\n#17\n```"; + let refs = references(section, "clockworklabs/SpacetimeDB").unwrap(); + assert_eq!(refs.len(), 4); + assert!(refs.contains(&PullRequestRef { + repo: "x/y".into(), + number: 15 + })); + } +} diff --git a/tools/rollback-coordination/src/release.rs b/tools/rollback-coordination/src/release.rs new file mode 100644 index 00000000000..39498c675cb --- /dev/null +++ b/tools/rollback-coordination/src/release.rs @@ -0,0 +1,92 @@ +use anyhow::{bail, Context, Result}; +use semver::Version; +use std::cmp::Ordering; +use std::fmt; + +/// A canonical SpacetimeDB release tag. +/// +/// Releases are either `vMAJOR.MINOR.PATCH` or +/// `vMAJOR.MINOR.PATCH-hotfixN`. Their tag is derived from the parsed version; +/// there is no independent string representation. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Release { + version: Version, + hotfix: Option, +} + +impl Release { + pub fn from_tag(tag: &str) -> Result> { + let Some(version) = tag.strip_prefix('v') else { + tracing::debug!("Not parsing tag that does not start with `v`"); + return Ok(None); + }; + let Ok(version) = Version::parse(version) else { + tracing::debug!("Not parsing tag that is not a semver"); + return Ok(None); + }; + + // Ignore releases that are too old. + // This cutoff shortly predates the rollback-safety coordination system, so + // ignoring older tags avoids needless history traversal. It is also after the + // public and private repositories began using the same release tags. + // We compare only the core version here. SemVer orders prereleases before + // their base release, whereas our hotfix tags come after it. + if Version::new(version.major, version.minor, version.patch) < Version::new(2, 7, 0) { + tracing::debug!("Not parsing tag that is too old"); + return Ok(None); + } + + if !version.build.is_empty() { + bail!("release versions may not contain build metadata"); + } + + let hotfix = if version.pre.is_empty() { + None + } else { + let prerelease = version.pre.as_str(); + let number = prerelease + .strip_prefix("hotfix") + .and_then(|number| number.parse::().ok()) + .with_context(|| format!("unsupported release suffix `-{prerelease}`; expected `-hotfixN`"))?; + // Reject things that start with 0, include separators, etc. + if format!("hotfix{number}") != prerelease { + bail!("release suffix `-{prerelease}` is not a canonical form"); + } + Some(number) + }; + + Ok(Some(Self { version, hotfix })) + } + + pub fn major(&self) -> u64 { + self.version.major + } + + pub fn minor(&self) -> u64 { + self.version.minor + } + + pub fn patch(&self) -> u64 { + self.version.patch + } +} + +impl fmt::Display for Release { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "v{}", self.version) + } +} + +impl Ord for Release { + fn cmp(&self, other: &Self) -> Ordering { + (self.version.major, self.version.minor, self.version.patch) + .cmp(&(other.version.major, other.version.minor, other.version.patch)) + .then_with(|| self.hotfix.cmp(&other.hotfix)) + } +} + +impl PartialOrd for Release { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +}