Skip to main content

ephemeris_release/
proposal.rs

1//! Automated proposal analysis and isolated release-branch maintenance.
2
3pub(crate) mod analysis;
4pub(crate) mod assets;
5mod branch;
6mod groups;
7mod metadata;
8
9use std::collections::{BTreeMap, BTreeSet};
10use std::io;
11use std::path::Path;
12
13use ephemeris_files::{PathError, RepositoryPath, RepositoryRoot};
14
15use self::analysis::{History, Policy, Proposal};
16use self::assets::{AssetBuilder, NixBuilder};
17use self::branch::{BranchSync, Committer, Expectation, SyncMode, SyncResult};
18use self::groups::ReleaseGroup;
19use self::metadata::{MarkerError, ProposalMetadata};
20use crate::adapters::{Adapters, ChangelogEntry, EditContext, ResolveError, read_digest};
21use crate::commit_subject::CommitSubject;
22use crate::conventional::Commit;
23use crate::edits::{EditError, PlannedEdits};
24use crate::error::{Error, ForgeFailure, GitFailure, RequiredFileError};
25use crate::git::{GitHistory, ProposalGit, UnitScope};
26use crate::state::{ApplicationBump, AuthorityBranch, CommitFormat, ReleaseState, UnitKind};
27use crate::unit::{Unit, UnitStatus, UnparsableTag};
28use crate::version::{Bump, Proposed, Version};
29use crate::{Author, ForgeProposal, GitRefs};
30
31/// Inputs for applying and synchronizing a configured release proposal.
32#[derive(Clone, Copy, Debug)]
33pub struct ConfiguredProposal<'a> {
34    /// Repository whose `origin` holds both branches.
35    pub repository: &'a RepositoryRoot,
36    /// Authoritative branch the proposal is built on.
37    pub authority: &'a AuthorityBranch,
38    /// Maintained proposal branch.
39    pub branch: &'a str,
40    /// Subject template of the proposal commits.
41    pub commit_subject: &'a CommitSubject,
42    /// Author and committer of the proposal commits.
43    pub author: Author<'a>,
44    /// Release date written to changelogs.
45    pub date: &'a str,
46    /// Unit an explicit version or revision applies to.
47    pub unit: Option<&'a str>,
48    /// Explicit version for `unit`, replacing the automatic bump.
49    pub version: Option<&'a str>,
50    /// Explicit revision for `unit`.
51    pub revision: Option<u64>,
52    /// Rebuild the branch from the authority, discarding manual edits.
53    pub regenerate: bool,
54}
55
56impl ConfiguredProposal<'_> {
57    /// Validate the requested unit, version, and revision, returning the
58    /// explicit version proposal.
59    pub(crate) fn explicit_versions(
60        self,
61        units: &[Unit],
62    ) -> Result<BTreeMap<String, Version>, ProposalError> {
63        if let Some(name) = self.unit
64            && !units.iter().any(|candidate| candidate.name == name)
65        {
66            return Err(ResolveError::UnknownUnit(name.to_owned()).into());
67        }
68        match (self.unit, self.version, self.revision) {
69            (Some(name), Some(value), None) => {
70                let version =
71                    Version::parse(value).map_err(|source| ResolveError::InvalidVersion {
72                        unit: name.to_owned(),
73                        version: value.to_owned(),
74                        source,
75                    })?;
76                Ok(BTreeMap::from([(name.to_owned(), version)]))
77            }
78            (Some(_), None, Some(value)) if value > 0 => Ok(BTreeMap::new()),
79            (None, None, None) => Ok(BTreeMap::new()),
80            _ => Err(ProposalError::InvalidRequest),
81        }
82    }
83}
84
85/// What a proposal run did.
86#[derive(Clone, Debug, Eq, PartialEq)]
87pub enum ProposalOutcome {
88    /// The proposal branch and pull request are up to date.
89    Proposed {
90        /// Units left out because their file version has no tag yet,
91        /// sorted by name.
92        unfinished: Vec<String>,
93    },
94}
95
96/// A release proposal cannot be analyzed or synchronized.
97#[derive(Debug, thiserror::Error)]
98pub(crate) enum ProposalError {
99    /// The bumped component is already at its maximum.
100    #[error("cannot apply a {bump} bump to {version} for {unit}")]
101    BumpOverflow {
102        /// The unit.
103        unit: String,
104        /// The version at its maximum component.
105        version: String,
106        /// The bump that overflows.
107        bump: Bump,
108    },
109    /// No first version can be derived for a unit still at 0.0.0.
110    #[error(
111        "release unit {unit} is at 0.0.0 and no commit asks for a first version. Set the version \
112         in {} or run the release workflow with the unit and version inputs",
113        path.as_path().display()
114    )]
115    FirstVersionUnknown {
116        /// The unit.
117        unit: String,
118        /// The file that holds the unit version.
119        path: RepositoryPath,
120    },
121    /// The unit, version, and revision options do not form a valid request.
122    #[error("supply --unit with either --version or a positive --revision, but not both")]
123    InvalidRequest,
124    /// A revision was requested for a unit without a revision file.
125    #[error("release unit {0:?} has no configured revisionFile")]
126    MissingRevisionFile(String),
127    /// A revision file cannot be read.
128    #[error(transparent)]
129    File(#[from] RequiredFileError),
130    /// The scratch directory for the proposal worktree cannot be created.
131    #[error("cannot create a scratch directory")]
132    Scratch(#[source] io::Error),
133    /// A Git read or branch update failed.
134    #[error("release proposal git operation failed")]
135    Git(#[from] GitFailure),
136    /// The forge could not look up or maintain the proposal pull request.
137    #[error("release proposal forge operation failed")]
138    Forge(#[from] ForgeFailure),
139    /// The API compatibility check could not run.
140    #[error("release compatibility check failed")]
141    Compatibility(#[source] ResolveError),
142    /// The branch head differs from the one the PR marker names.
143    #[error(
144        "release proposal branch was edited: the marker names {expected} but the branch is at \
145         {}. Run the proposal with regenerate to replace it",
146        actual.as_deref().unwrap_or("nothing")
147    )]
148    ManualEdits {
149        /// The head recorded in the PR marker.
150        expected: String,
151        /// The head on `origin`, absent when the branch does not exist.
152        actual: Option<String>,
153    },
154    /// The pull request has no readable marker and the branch exists.
155    #[error(
156        "release proposal branch {head} exists but the pull request marker is unreadable. Run the \
157         proposal with regenerate to replace the branch"
158    )]
159    UnreadableMarker {
160        /// The head on `origin`.
161        head: String,
162        /// Why the marker cannot be read.
163        #[source]
164        reason: MarkerError,
165    },
166    /// The branch exists and no pull request refers to it.
167    #[error(
168        "release proposal branch {head} exists but has no pull request. Run the proposal with \
169         regenerate to replace the branch"
170    )]
171    UntrackedBranch {
172        /// The head on `origin`.
173        head: String,
174    },
175    /// Unit resolution, version grouping, or edit planning failed.
176    #[error(transparent)]
177    Adapter(#[from] ResolveError),
178    /// Tags under a unit's prefix name no version.
179    #[error(transparent)]
180    UnparsableTag(#[from] UnparsableTag),
181    /// The proposed units depend on each other in a cycle.
182    #[error("release units {units:?} depend on each other in a cycle")]
183    DependencyCycle {
184        /// The units that cannot be ordered.
185        units: Vec<String>,
186    },
187    /// The proposal edits conflict or are stale.
188    #[error(transparent)]
189    Edit(#[from] EditError),
190    /// A revision file path is unsafe.
191    #[error(transparent)]
192    Path(#[from] PathError),
193}
194
195/// The branch after a proposal run and the units it left out.
196struct Synchronized {
197    proposal_head: String,
198    unfinished: Vec<String>,
199}
200
201/// Everything needed to write a proposal into a worktree.
202struct ProposalWrite<'a, R, N, B> {
203    adapters: &'a Adapters<R, N>,
204    builder: &'a B,
205    state: &'a ReleaseState,
206    units: &'a [Unit],
207    versions: &'a BTreeMap<String, Proposed>,
208    date: &'a str,
209    entries: &'a BTreeMap<String, ChangelogEntry>,
210    revisions: &'a Revisions,
211    applications: &'a BTreeSet<String>,
212}
213
214/// Revision-file edits that accompany a proposal.
215struct Revisions {
216    files: BTreeMap<String, RepositoryPath>,
217    /// The unit and value of an explicitly requested revision.
218    explicit: Option<(String, u64)>,
219}
220
221impl Revisions {
222    fn replace(
223        edits: &mut PlannedEdits,
224        root: &RepositoryRoot,
225        relative: &RepositoryPath,
226        replacement: &str,
227    ) -> Result<(), ProposalError> {
228        relative.resolve(root)?;
229        let before = RequiredFileError::read(root, relative)?;
230        edits.add(
231            relative.clone(),
232            Some(before),
233            Some(replacement.as_bytes().to_vec()),
234        )?;
235        Ok(())
236    }
237
238    fn new(state: &ReleaseState, request: ConfiguredProposal<'_>) -> Result<Self, ProposalError> {
239        let files: BTreeMap<_, _> = state
240            .units
241            .iter()
242            .filter_map(|(name, configured)| {
243                Some((name.clone(), configured.revision_file.clone()?))
244            })
245            .collect();
246        let explicit = match (request.unit, request.revision) {
247            (Some(name), Some(value)) => {
248                if !files.contains_key(name) {
249                    return Err(ProposalError::MissingRevisionFile(name.to_owned()));
250                }
251                Some((name.to_owned(), value))
252            }
253            _ => None,
254        };
255        Ok(Self { files, explicit })
256    }
257
258    /// Reset proposed applications to revision 1 and apply the explicit
259    /// revision, if any.
260    fn contribute(
261        &self,
262        edits: &mut PlannedEdits,
263        worktree: &RepositoryRoot,
264        proposed_applications: impl IntoIterator<Item = String>,
265    ) -> Result<(), ProposalError> {
266        let explicit_unit = self.explicit.as_ref().map(|(unit, _)| unit.as_str());
267        for name in proposed_applications {
268            if let Some(path) = self.files.get(&name)
269                && explicit_unit != Some(name.as_str())
270            {
271                Self::replace(edits, worktree, path, "1\n")?;
272            }
273        }
274        if let Some((name, value)) = &self.explicit
275            && let Some(path) = self.files.get(name)
276        {
277            Self::replace(edits, worktree, path, &format!("{value}\n"))?;
278        }
279        Ok(())
280    }
281}
282
283impl<R, N, B: AssetBuilder> ProposalWrite<'_, R, N, B> {
284    /// Write the proposal into the worktree of the authority tip, one commit
285    /// per release group. Units whose version on the tip differs from the
286    /// analyzed checkout are left unchanged.
287    ///
288    /// The whole proposal is checked first. Each group is then planned and
289    /// applied alone, and generated files such as `Cargo.lock` are refreshed
290    /// after its manifest edits, so every commit has a consistent lockfile.
291    fn write<G: ProposalGit>(
292        &self,
293        worktree: &Path,
294        committer: &Committer<'_, G>,
295    ) -> Result<(), ProposalError> {
296        let worktree = &RepositoryRoot::new(worktree)?;
297        let tip_versions: BTreeMap<_, _> = self
298            .adapters
299            .resolve(worktree, &self.state.units)?
300            .into_iter()
301            .map(|unit| (unit.name, unit.version))
302            .collect();
303        let effective: BTreeMap<_, _> = self
304            .versions
305            .iter()
306            .filter(|(name, _)| {
307                self.units.iter().any(|unit| {
308                    unit.name == **name && tip_versions.get(*name) == Some(&unit.version)
309                })
310            })
311            .map(|(name, version)| (name.clone(), version.clone()))
312            .collect();
313        self.adapters
314            .check_consumers(worktree, self.units, &effective)?;
315        let released: BTreeSet<_> = effective.keys().cloned().collect();
316        let shared = self.adapters.shared_groups(worktree, self.units)?;
317        let mut current = self.units.to_vec();
318        for group in ReleaseGroup::ordered(self.units, &effective, &shared)? {
319            let versions: BTreeMap<_, _> = effective
320                .iter()
321                .filter(|(name, _)| group.contains(name))
322                .map(|(name, version)| (name.clone(), version.clone()))
323                .collect();
324            let mut edits = PlannedEdits::new(worktree);
325            self.adapters.plan_edits(
326                worktree,
327                &current,
328                &versions,
329                &EditContext {
330                    date: self.date,
331                    entries: self.entries,
332                    released: &released,
333                },
334                &mut edits,
335            )?;
336            self.revisions.contribute(
337                &mut edits,
338                worktree,
339                versions
340                    .keys()
341                    .filter(|name| self.applications.contains(*name))
342                    .cloned(),
343            )?;
344            let mut paths = edits.changed_paths();
345            edits.apply()?;
346            self.adapters.reload();
347            paths.extend(Adapters::<R, N>::prepare(worktree, &current)?);
348            committer.stage(&paths)?;
349            let mut pin_edits = PlannedEdits::new(worktree);
350            Adapters::<R, N>::plan_pin_edits(
351                worktree,
352                &current,
353                &versions,
354                self.builder,
355                worktree.as_path(),
356                &mut pin_edits,
357            )?;
358            paths.extend(pin_edits.changed_paths());
359            pin_edits.apply()?;
360            if let Some(version) = versions.values().next().map(Proposed::version) {
361                let names: Vec<_> = group.units().collect();
362                committer.commit(&paths, &self.state.commit_subject.render(&names, version))?;
363            }
364            for unit in &mut current {
365                if let Some(proposed) = versions.get(&unit.name) {
366                    unit.version = proposed.version().clone();
367                }
368            }
369        }
370        Ok(())
371    }
372}
373
374impl ReleaseState {
375    /// Resolved application units configured with the given automatic bump.
376    pub(crate) fn application_units(
377        &self,
378        units: &[Unit],
379        bump: ApplicationBump,
380    ) -> BTreeSet<String> {
381        units
382            .iter()
383            .filter(|unit| {
384                self.units.get(&unit.name).is_some_and(|configured| {
385                    configured.kind == UnitKind::Application && configured.application_bump == bump
386                })
387            })
388            .map(|unit| unit.name.clone())
389            .collect()
390    }
391}
392
393impl History {
394    /// Collect each unit's baseline tag, change status, and, with a commit
395    /// format, the commits since that tag from Git.
396    fn collect<G: GitHistory>(
397        git: &G,
398        state: &ReleaseState,
399        request: ConfiguredProposal<'_>,
400        units: &[Unit],
401    ) -> Result<BTreeMap<String, Self>, ProposalError> {
402        let repository = request.repository.as_path();
403        let authority = request.authority.tracking();
404        let mut history = BTreeMap::new();
405        for unit in units {
406            let tags = git
407                .tags(repository, &unit.tag_prefix)
408                .map_err(GitFailure::new)?;
409            let baseline_tag = match unit.status(&tags)? {
410                UnitStatus::Released { latest, tagged: _ } => Some(latest.to_owned()),
411                UnitStatus::Unversioned => None,
412                UnitStatus::Pending => continue,
413            };
414            let baseline = baseline_tag.as_deref();
415            let scope = UnitScope {
416                source: unit.source.as_slice(),
417                changelog: unit.changelog.as_ref().map(RepositoryPath::as_path),
418            };
419            let changed = git
420                .has_changes(repository, baseline, &authority, scope)
421                .map_err(GitFailure::new)?;
422            let conventional = state.commit_format == Some(CommitFormat::Conventional);
423            let commits = match state.commit_format {
424                Some(CommitFormat::Conventional) if changed => {
425                    let messages = git
426                        .commit_messages(repository, baseline, &authority, scope)
427                        .map_err(GitFailure::new)?;
428                    Commit::from_messages(
429                        messages.iter().map(String::as_str),
430                        request.commit_subject,
431                    )
432                }
433                Some(CommitFormat::Conventional) | None => Vec::new(),
434            };
435            history.insert(
436                unit.name.clone(),
437                Self {
438                    baseline_tag,
439                    changed,
440                    conventional,
441                    commits,
442                },
443            );
444        }
445        Ok(history)
446    }
447}
448
449impl History {
450    /// Mark a released dependency as changed when a unit of `versions` holds
451    /// an empty digest pin for it. Only a release of the dependency fills the
452    /// digest. Returns whether a unit was marked.
453    fn mark_empty_digests(
454        history: &mut BTreeMap<String, Self>,
455        repository: &RepositoryRoot,
456        units: &[Unit],
457        versions: &BTreeMap<String, Proposed>,
458    ) -> Result<bool, ProposalError> {
459        let mut marked = false;
460        for unit in units
461            .iter()
462            .filter(|unit| versions.contains_key(&unit.name))
463        {
464            for (dependency, pin) in &unit.pins {
465                let Some(dependency_history) = history.get_mut(dependency) else {
466                    continue;
467                };
468                if dependency_history.changed || dependency_history.baseline_tag.is_none() {
469                    continue;
470                }
471                for digest in pin.digests.values() {
472                    if read_digest(repository, unit, dependency, digest)
473                        .map_err(ResolveError::from)?
474                        .is_empty()
475                    {
476                        dependency_history.changed = true;
477                        marked = true;
478                        break;
479                    }
480                }
481            }
482        }
483        Ok(marked)
484    }
485}
486
487/// Maintain the release proposal branch and its pull request.
488///
489/// Reads the marker of the open proposal pull request, synchronizes the
490/// branch, and creates or updates the pull request with the new marker.
491///
492/// # Errors
493/// Returns an error for invalid release configuration, analysis failures,
494/// unsafe branch updates, or forge failures.
495pub async fn propose<G, F>(
496    git: &G,
497    forge: &F,
498    state: &ReleaseState,
499    request: ConfiguredProposal<'_>,
500) -> Result<ProposalOutcome, Error>
501where
502    G: GitHistory + GitRefs + ProposalGit,
503    F: ForgeProposal,
504{
505    let base = request.authority.name();
506    let existing = forge
507        .proposal_body(request.branch, base)
508        .await
509        .map_err(ForgeFailure::new)
510        .map_err(ProposalError::from)?;
511    let metadata = existing.as_deref().map(ProposalMetadata::parse);
512    let expectation = match &metadata {
513        None => Expectation::NoPullRequest,
514        Some(Ok(metadata)) => Expectation::Marked(&metadata.proposal_head),
515        Some(Err(reason)) => Expectation::Unreadable(*reason),
516    };
517    let synchronized = sync_proposal(git, &NixBuilder, state, request, expectation)?;
518    let marker = ProposalMetadata {
519        proposal_head: synchronized.proposal_head,
520    }
521    .marker();
522    let body = format!(
523        "Ephemeris maintains this aggregate release proposal. Manual branch edits stop automatic \
524         refresh.\n\n{marker}"
525    );
526    forge
527        .ensure_proposal(request.branch, base, "Release proposal", &body)
528        .await
529        .map_err(ForgeFailure::new)
530        .map_err(ProposalError::from)?;
531    Ok(ProposalOutcome::Proposed {
532        unfinished: synchronized.unfinished,
533    })
534}
535
536/// Discover configured units, analyze changes, and synchronize the
537/// maintained release branch using the evaluated release policy. `expectation`
538/// comes from the open proposal pull request.
539fn sync_proposal<G: GitHistory + GitRefs + ProposalGit, B: AssetBuilder>(
540    git: &G,
541    builder: &B,
542    state: &ReleaseState,
543    request: ConfiguredProposal<'_>,
544    expectation: Expectation<'_>,
545) -> Result<Synchronized, ProposalError> {
546    let repository = request.repository;
547    let adapters = Adapters::without_registry();
548    let units = adapters.resolve(repository, &state.units)?;
549    let (units, unfinished) = without_unfinished(git, request, units)?;
550    let applications = state.application_units(&units, ApplicationBump::Patch);
551    let mut history = History::collect(git, state, request, &units)?;
552    let (versions, proposal) = loop {
553        let proposed = propose_versions(
554            &adapters,
555            state,
556            repository,
557            &units,
558            &applications,
559            &history,
560            request,
561        )?;
562        if !History::mark_empty_digests(&mut history, repository, &units, &proposed.0)? {
563            break proposed;
564        }
565    };
566    let entries = proposal
567        .changes
568        .into_iter()
569        .map(|change| {
570            let commits = history
571                .remove(&change.unit)
572                .map(|history| history.commits)
573                .unwrap_or_default();
574            let entry = ChangelogEntry {
575                commits,
576                dependencies: change.dependencies,
577            };
578            (change.unit, entry)
579        })
580        .collect();
581    let revisions = Revisions::new(state, request)?;
582    let write = ProposalWrite {
583        adapters: &adapters,
584        builder,
585        state,
586        units: &units,
587        versions: &versions,
588        date: request.date,
589        entries: &entries,
590        revisions: &revisions,
591        applications: &applications,
592    };
593    let branch = BranchSync {
594        repository: repository.as_path(),
595        authority: request.authority,
596        branch: request.branch,
597        author: request.author,
598        mode: sync_mode(request.regenerate, expectation),
599    };
600    let SyncResult { proposal_head } = branch::sync(git, &branch, |worktree, committer| {
601        write.write(worktree, committer)
602    })?;
603    Ok(Synchronized {
604        proposal_head,
605        unfinished,
606    })
607}
608
609/// Split off the pending units, whose file version waits for publication.
610/// The remaining units no longer depend on the removed ones.
611fn without_unfinished<G: GitHistory>(
612    git: &G,
613    request: ConfiguredProposal<'_>,
614    units: Vec<Unit>,
615) -> Result<(Vec<Unit>, Vec<String>), ProposalError> {
616    let repository = request.repository.as_path();
617    let mut unfinished = BTreeSet::new();
618    for unit in &units {
619        let tags = git
620            .tags(repository, &unit.tag_prefix)
621            .map_err(GitFailure::new)?;
622        if unit.status(&tags)? == UnitStatus::Pending {
623            unfinished.insert(unit.name.clone());
624        }
625    }
626    let remaining = units
627        .into_iter()
628        .filter(|unit| !unfinished.contains(&unit.name))
629        .map(|mut unit| {
630            unit.dependencies
631                .retain(|dependency| !unfinished.contains(dependency));
632            unit
633        })
634        .collect();
635    Ok((remaining, unfinished.into_iter().collect()))
636}
637
638/// The versions a proposal sets: explicit requests completed to their
639/// groups, then the automatic bumps analysis derives from Git history.
640/// The proposal carries the dependency updates of each bumped unit.
641fn propose_versions<R, N>(
642    adapters: &Adapters<R, N>,
643    state: &ReleaseState,
644    repository: &RepositoryRoot,
645    units: &[Unit],
646    applications: &BTreeSet<String>,
647    history: &BTreeMap<String, History>,
648    request: ConfiguredProposal<'_>,
649) -> Result<(BTreeMap<String, Proposed>, Proposal), ProposalError> {
650    let explicit = request.explicit_versions(units)?;
651    let overrides = adapters.complete_versions(repository, units, &explicit, &BTreeMap::new())?;
652    let policy = Policy {
653        applications,
654        non_bumping: &state.application_units(units, ApplicationBump::None),
655        overrides: &overrides,
656    };
657    let proposal = analysis::analyze(units, history, policy, |unit, tag| {
658        unit.required_bump(repository, tag)
659            .map_err(ProposalError::Compatibility)
660    })?;
661    let proposed = proposal.versions();
662    let automatic = proposed
663        .iter()
664        .map(|(name, proposed)| (name.clone(), proposed.version().clone()))
665        .collect();
666    let versions = adapters
667        .complete_versions(repository, units, &BTreeMap::new(), &automatic)?
668        .into_iter()
669        .map(|(name, version)| {
670            let version = match proposed.get(&name) {
671                Some(kind) => kind.at(version),
672                None => Proposed::Bump(version),
673            };
674            (name, version)
675        })
676        .collect();
677    Ok((versions, proposal))
678}
679
680/// Regenerate on request. Otherwise refresh only what the pull request
681/// vouches for.
682const fn sync_mode(regenerate: bool, expectation: Expectation<'_>) -> SyncMode<'_> {
683    if regenerate {
684        SyncMode::Regenerate
685    } else {
686        SyncMode::Automatic {
687            expected: expectation,
688        }
689    }
690}
691
692#[cfg(test)]
693mod tests;