Skip to main content

semifold_core/
release_context.rs

1use std::collections::BTreeMap;
2
3use semver::Version;
4use serde::Serialize;
5use sha2::{Digest, Sha256};
6
7use crate::{
8    BumpLevel, ChangesetId, EcosystemId, PackageId, PackageSnapshot, ReleasePlan, ReleaseReason,
9};
10
11/// Immutable, serializable facts shared by one workspace release.
12#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
13pub struct ReleaseContext {
14    pub plan: ReleasePlanContext,
15    pub repository: Option<RepositoryContext>,
16    pub ci: Option<CiContext>,
17}
18
19impl ReleaseContext {
20    #[must_use]
21    pub fn from_plan(plan: &ReleasePlan) -> Self {
22        Self {
23            plan: ReleasePlanContext::from(plan),
24            repository: None,
25            ci: None,
26        }
27    }
28
29    #[must_use]
30    pub fn with_repository(mut self, repository: RepositoryContext) -> Self {
31        self.repository = Some(repository);
32        self
33    }
34
35    #[must_use]
36    pub fn with_ci(mut self, ci: CiContext) -> Self {
37        self.ci = Some(ci);
38        self
39    }
40}
41
42/// Template-safe projection of a validated release plan.
43#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
44pub struct ReleasePlanContext {
45    pub packages: BTreeMap<PackageId, PackageReleaseContext>,
46    pub changesets: Vec<ChangesetId>,
47    pub common_version: Option<Version>,
48    pub fingerprint: String,
49}
50
51impl From<&ReleasePlan> for ReleasePlanContext {
52    fn from(plan: &ReleasePlan) -> Self {
53        let packages = plan
54            .packages()
55            .iter()
56            .map(|package| {
57                (
58                    package.id.clone(),
59                    PackageReleaseContext {
60                        id: package.id.clone(),
61                        ecosystem: package.ecosystem.clone(),
62                        current_version: package.current_version.clone(),
63                        next_version: package.next_version.clone(),
64                        bump: package.bump,
65                        reasons: package
66                            .reasons
67                            .iter()
68                            .map(ReleaseReasonContext::from)
69                            .collect(),
70                    },
71                )
72            })
73            .collect::<BTreeMap<_, _>>();
74        let changesets = plan.consumed_changesets().to_vec();
75        let common_version = common_version(&packages);
76        let fingerprint = plan_fingerprint(&packages, &changesets);
77
78        Self {
79            packages,
80            changesets,
81            common_version,
82            fingerprint,
83        }
84    }
85}
86
87#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
88pub struct PackageReleaseContext {
89    pub id: PackageId,
90    pub ecosystem: EcosystemId,
91    pub current_version: Version,
92    pub next_version: Version,
93    pub bump: BumpLevel,
94    pub reasons: Vec<ReleaseReasonContext>,
95}
96
97#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
98#[serde(tag = "kind", rename_all = "snake_case")]
99pub enum ReleaseReasonContext {
100    Changeset {
101        changeset: ChangesetId,
102    },
103    DependencyPropagation {
104        dependency: PackageId,
105        next_version: Version,
106    },
107    SharedVersionPropagation {
108        source: crate::VersionSourceId,
109    },
110}
111
112impl From<&ReleaseReason> for ReleaseReasonContext {
113    fn from(reason: &ReleaseReason) -> Self {
114        match reason {
115            ReleaseReason::Changeset { changeset } => Self::Changeset {
116                changeset: changeset.clone(),
117            },
118            ReleaseReason::DependencyPropagation {
119                dependency,
120                next_version,
121            } => Self::DependencyPropagation {
122                dependency: dependency.clone(),
123                next_version: next_version.clone(),
124            },
125            ReleaseReason::SharedVersionPropagation { source } => Self::SharedVersionPropagation {
126                source: source.clone(),
127            },
128        }
129    }
130}
131
132#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
133pub struct RepositoryContext {
134    pub host: String,
135    pub owner: String,
136    pub name: String,
137    pub web_url: String,
138    pub commit: Option<CommitContext>,
139}
140
141#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
142pub struct CommitContext {
143    pub sha: String,
144    pub short_sha: String,
145    pub author: Option<String>,
146    pub web_url: Option<String>,
147}
148
149#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
150pub struct PullRequestContext {
151    pub number: u64,
152    pub author: Option<String>,
153    pub web_url: Option<String>,
154}
155
156#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
157pub struct CiContext {
158    pub provider: CiProvider,
159    pub run_id: Option<String>,
160    pub run_url: Option<String>,
161    pub ref_name: Option<String>,
162}
163
164/// Stable provider name without coupling the domain to one CI vendor.
165#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
166#[serde(transparent)]
167pub struct CiProvider(String);
168
169impl CiProvider {
170    #[must_use]
171    pub fn new(value: impl Into<String>) -> Self {
172        Self(value.into())
173    }
174
175    #[must_use]
176    pub fn as_str(&self) -> &str {
177        &self.0
178    }
179}
180
181#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
182pub struct ReleasePackageContext<'release> {
183    pub release: &'release ReleaseContext,
184    pub package: ReleasePackageTemplateContext,
185}
186
187#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
188pub struct ReleasePackageTemplateContext {
189    pub id: PackageId,
190    pub name: String,
191    pub ecosystem: EcosystemId,
192    pub current_version: Version,
193    pub next_version: Version,
194    pub version: Version,
195    pub tag: String,
196    pub path: camino::Utf8PathBuf,
197    pub private: bool,
198}
199
200impl<'release> ReleasePackageContext<'release> {
201    pub fn from_snapshot(
202        release: &'release ReleaseContext,
203        snapshot: &PackageSnapshot,
204    ) -> Result<Self, ReleasePackageContextError> {
205        let planned = release.plan.packages.get(&snapshot.id).ok_or_else(|| {
206            ReleasePackageContextError::PackageNotReleased {
207                package: snapshot.id.clone(),
208            }
209        })?;
210        if snapshot.version != planned.current_version {
211            return Err(ReleasePackageContextError::CurrentVersionMismatch {
212                package: snapshot.id.clone(),
213                snapshot: snapshot.version.clone(),
214                planned: planned.current_version.clone(),
215            });
216        }
217        if snapshot.ecosystem != planned.ecosystem {
218            return Err(ReleasePackageContextError::EcosystemMismatch {
219                package: snapshot.id.clone(),
220                snapshot: snapshot.ecosystem.clone(),
221                planned: planned.ecosystem.clone(),
222            });
223        }
224        let next_version = planned.next_version.clone();
225        Ok(Self {
226            release,
227            package: ReleasePackageTemplateContext {
228                id: snapshot.id.clone(),
229                name: snapshot.manifest_name.clone(),
230                ecosystem: snapshot.ecosystem.clone(),
231                current_version: snapshot.version.clone(),
232                version: next_version.clone(),
233                tag: format!("{}-v{next_version}", snapshot.manifest_name),
234                next_version,
235                path: snapshot.path.clone(),
236                private: !snapshot.publishable,
237            },
238        })
239    }
240}
241
242#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
243pub enum ReleasePackageContextError {
244    #[error("package is not part of the release plan: {package}")]
245    PackageNotReleased { package: PackageId },
246    #[error(
247        "package {package} has current version {snapshot}, but the release plan expects {planned}"
248    )]
249    CurrentVersionMismatch {
250        package: PackageId,
251        snapshot: Version,
252        planned: Version,
253    },
254    #[error(
255        "package {package} has ecosystem {snapshot_name}, but the release plan expects {planned_name}",
256        snapshot_name = .snapshot.display_name(),
257        planned_name = .planned.display_name()
258    )]
259    EcosystemMismatch {
260        package: PackageId,
261        snapshot: EcosystemId,
262        planned: EcosystemId,
263    },
264}
265
266#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
267pub struct ChangesetContext {
268    pub id: ChangesetId,
269    pub summary: String,
270    pub summary_paragraphs: Vec<Vec<String>>,
271    pub commit: Option<CommitContext>,
272    pub pull_request: Option<PullRequestContext>,
273}
274
275#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
276pub struct PackageChangesetContext {
277    pub changeset: ChangesetContext,
278    pub section: String,
279}
280
281#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
282pub struct DependencyUpdateContext {
283    pub package: PackageId,
284    pub next_version: Version,
285}
286
287#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
288pub struct ChangelogContext<'release> {
289    pub package: ReleasePackageContext<'release>,
290    pub changesets: Vec<PackageChangesetContext>,
291    pub dependency_updates: Vec<DependencyUpdateContext>,
292}
293
294fn common_version(packages: &BTreeMap<PackageId, PackageReleaseContext>) -> Option<Version> {
295    let mut versions = packages.values().map(|package| &package.next_version);
296    let first = versions.next()?;
297    versions
298        .all(|version| version == first)
299        .then(|| first.clone())
300}
301
302fn plan_fingerprint(
303    packages: &BTreeMap<PackageId, PackageReleaseContext>,
304    changesets: &[ChangesetId],
305) -> String {
306    let mut hasher = Sha256::new();
307    for (id, package) in packages {
308        hash_field(&mut hasher, b"package", id.as_str());
309        hash_field(&mut hasher, b"version", &package.next_version.to_string());
310    }
311    for changeset in changesets {
312        hash_field(&mut hasher, b"changeset", changeset.as_str());
313    }
314    let digest = hasher.finalize();
315    digest
316        .iter()
317        .take(6)
318        .map(|byte| format!("{byte:02x}"))
319        .collect()
320}
321
322fn hash_field(hasher: &mut Sha256, kind: &[u8], value: &str) {
323    hasher.update(kind);
324    hasher.update([0]);
325    hasher.update(value.len().to_string().as_bytes());
326    hasher.update(b":");
327    hasher.update(value.as_bytes());
328}
329
330#[cfg(test)]
331mod tests {
332    use super::*;
333    use crate::{PackageRelease, VersionMap, VersionSource};
334
335    fn package(id: &str, next: Version) -> PackageRelease {
336        PackageRelease {
337            id: PackageId::new(id),
338            ecosystem: EcosystemId::RUST,
339            current_version: Version::new(1, 0, 0),
340            next_version: next,
341            bump: BumpLevel::Minor,
342            reasons: vec![ReleaseReason::Changeset {
343                changeset: ChangesetId::new(format!("{id}-change")),
344            }],
345        }
346    }
347
348    fn plan(packages: Vec<PackageRelease>, changesets: Vec<&str>) -> ReleasePlan {
349        let versions = packages
350            .iter()
351            .map(|package| (package.id.clone(), package.next_version.clone()))
352            .collect::<VersionMap>();
353        let order = packages.iter().map(|package| package.id.clone()).collect();
354        ReleasePlan::new(
355            packages,
356            versions,
357            order,
358            changesets.into_iter().map(ChangesetId::new).collect(),
359            Vec::new(),
360            Vec::new(),
361        )
362        .unwrap()
363    }
364
365    #[test]
366    fn single_package_has_common_version_and_stable_projection() {
367        let plan = plan(
368            vec![package("core", Version::new(1, 1, 0))],
369            vec!["core-change"],
370        );
371        let context = ReleasePlanContext::from(&plan);
372
373        assert_eq!(context.common_version, Some(Version::new(1, 1, 0)));
374        assert_eq!(context.changesets, vec![ChangesetId::new("core-change")]);
375        assert_eq!(
376            context.packages.keys().next(),
377            Some(&PackageId::new("core"))
378        );
379        assert_eq!(context.fingerprint.len(), 12);
380        assert!(
381            context
382                .fingerprint
383                .bytes()
384                .all(|byte| byte.is_ascii_hexdigit())
385        );
386    }
387
388    #[test]
389    fn multiple_packages_only_have_common_version_when_versions_match() {
390        let matching = plan(
391            vec![
392                package("b", Version::new(2, 0, 0)),
393                package("a", Version::new(2, 0, 0)),
394            ],
395            vec!["b-change", "a-change"],
396        );
397        let different = plan(
398            vec![
399                package("a", Version::new(2, 0, 0)),
400                package("b", Version::new(1, 1, 0)),
401            ],
402            vec!["a-change", "b-change"],
403        );
404
405        assert_eq!(
406            ReleasePlanContext::from(&matching).common_version,
407            Some(Version::new(2, 0, 0))
408        );
409        assert_eq!(ReleasePlanContext::from(&different).common_version, None);
410    }
411
412    #[test]
413    fn empty_plan_has_no_common_version() {
414        let context = ReleasePlanContext::from(&plan(Vec::new(), Vec::new()));
415
416        assert_eq!(context.common_version, None);
417        assert!(context.packages.is_empty());
418    }
419
420    #[test]
421    fn fingerprint_is_independent_of_input_order() {
422        let first = plan(
423            vec![
424                package("b", Version::new(1, 2, 0)),
425                package("a", Version::new(2, 0, 0)),
426            ],
427            vec!["z-change", "a-change"],
428        );
429        let second = plan(
430            vec![
431                package("a", Version::new(2, 0, 0)),
432                package("b", Version::new(1, 2, 0)),
433            ],
434            vec!["a-change", "z-change"],
435        );
436
437        assert_eq!(
438            ReleasePlanContext::from(&first).fingerprint,
439            ReleasePlanContext::from(&second).fingerprint
440        );
441    }
442
443    #[test]
444    fn fingerprint_changes_with_version_or_changeset_identity() {
445        let baseline = ReleasePlanContext::from(&plan(
446            vec![package("core", Version::new(1, 1, 0))],
447            vec!["change-a"],
448        ));
449        let version_changed = ReleasePlanContext::from(&plan(
450            vec![package("core", Version::new(1, 2, 0))],
451            vec!["change-a"],
452        ));
453        let changeset_changed = ReleasePlanContext::from(&plan(
454            vec![package("core", Version::new(1, 1, 0))],
455            vec!["change-b"],
456        ));
457
458        assert_ne!(baseline.fingerprint, version_changed.fingerprint);
459        assert_ne!(baseline.fingerprint, changeset_changed.fingerprint);
460    }
461
462    #[test]
463    fn release_package_context_projects_manifest_and_planned_facts() {
464        let plan = plan(
465            vec![package("configured-id", Version::new(1, 1, 0))],
466            vec!["release-change"],
467        );
468        let release = ReleaseContext::from_plan(&plan);
469        let snapshot = PackageSnapshot {
470            id: PackageId::new("configured-id"),
471            manifest_name: "manifest-name".to_string(),
472            version: Version::new(1, 0, 0),
473            version_source: VersionSource::PackageManifest,
474            ecosystem: EcosystemId::RUST,
475            path: "crates/package".into(),
476            publishable: false,
477            dependencies: Vec::new(),
478        };
479
480        let context = ReleasePackageContext::from_snapshot(&release, &snapshot).unwrap();
481
482        assert_eq!(context.package.id, PackageId::new("configured-id"));
483        assert_eq!(context.package.name, "manifest-name");
484        assert_eq!(context.package.current_version, Version::new(1, 0, 0));
485        assert_eq!(context.package.next_version, Version::new(1, 1, 0));
486        assert_eq!(context.package.version, Version::new(1, 1, 0));
487        assert_eq!(context.package.tag, "manifest-name-v1.1.0");
488        assert!(context.package.private);
489    }
490
491    #[test]
492    fn release_package_context_rejects_non_released_or_changed_snapshots() {
493        let plan = plan(
494            vec![package("configured-id", Version::new(1, 1, 0))],
495            vec!["release-change"],
496        );
497        let release = ReleaseContext::from_plan(&plan);
498        let mut snapshot = PackageSnapshot {
499            id: PackageId::new("missing"),
500            manifest_name: "missing".to_string(),
501            version: Version::new(1, 0, 0),
502            version_source: VersionSource::PackageManifest,
503            ecosystem: EcosystemId::RUST,
504            path: "crates/missing".into(),
505            publishable: true,
506            dependencies: Vec::new(),
507        };
508
509        assert!(matches!(
510            ReleasePackageContext::from_snapshot(&release, &snapshot),
511            Err(ReleasePackageContextError::PackageNotReleased { .. })
512        ));
513
514        snapshot.id = PackageId::new("configured-id");
515        snapshot.version = Version::new(1, 0, 1);
516        assert!(matches!(
517            ReleasePackageContext::from_snapshot(&release, &snapshot),
518            Err(ReleasePackageContextError::CurrentVersionMismatch { .. })
519        ));
520    }
521}