1pub(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#[derive(Clone, Copy, Debug)]
33pub struct ConfiguredProposal<'a> {
34 pub repository: &'a RepositoryRoot,
36 pub authority: &'a AuthorityBranch,
38 pub branch: &'a str,
40 pub commit_subject: &'a CommitSubject,
42 pub author: Author<'a>,
44 pub date: &'a str,
46 pub unit: Option<&'a str>,
48 pub version: Option<&'a str>,
50 pub revision: Option<u64>,
52 pub regenerate: bool,
54}
55
56impl ConfiguredProposal<'_> {
57 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#[derive(Clone, Debug, Eq, PartialEq)]
87pub enum ProposalOutcome {
88 Proposed {
90 unfinished: Vec<String>,
93 },
94}
95
96#[derive(Debug, thiserror::Error)]
98pub(crate) enum ProposalError {
99 #[error("cannot apply a {bump} bump to {version} for {unit}")]
101 BumpOverflow {
102 unit: String,
104 version: String,
106 bump: Bump,
108 },
109 #[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 unit: String,
118 path: RepositoryPath,
120 },
121 #[error("supply --unit with either --version or a positive --revision, but not both")]
123 InvalidRequest,
124 #[error("release unit {0:?} has no configured revisionFile")]
126 MissingRevisionFile(String),
127 #[error(transparent)]
129 File(#[from] RequiredFileError),
130 #[error("cannot create a scratch directory")]
132 Scratch(#[source] io::Error),
133 #[error("release proposal git operation failed")]
135 Git(#[from] GitFailure),
136 #[error("release proposal forge operation failed")]
138 Forge(#[from] ForgeFailure),
139 #[error("release compatibility check failed")]
141 Compatibility(#[source] ResolveError),
142 #[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 expected: String,
151 actual: Option<String>,
153 },
154 #[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 head: String,
162 #[source]
164 reason: MarkerError,
165 },
166 #[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 head: String,
174 },
175 #[error(transparent)]
177 Adapter(#[from] ResolveError),
178 #[error(transparent)]
180 UnparsableTag(#[from] UnparsableTag),
181 #[error("release units {units:?} depend on each other in a cycle")]
183 DependencyCycle {
184 units: Vec<String>,
186 },
187 #[error(transparent)]
189 Edit(#[from] EditError),
190 #[error(transparent)]
192 Path(#[from] PathError),
193}
194
195struct Synchronized {
197 proposal_head: String,
198 unfinished: Vec<String>,
199}
200
201struct 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
214struct Revisions {
216 files: BTreeMap<String, RepositoryPath>,
217 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 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 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 ¤t,
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, ¤t)?);
348 committer.stage(&paths)?;
349 let mut pin_edits = PlannedEdits::new(worktree);
350 Adapters::<R, N>::plan_pin_edits(
351 worktree,
352 ¤t,
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 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 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 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
487pub 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
536fn 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
609fn 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
638fn 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
680const 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;