onevcs/releases.rs
1//! The release-targets file: what each repository releases, and how a release of
2//! it is learned about.
3//!
4//! YAML, first match wins, matched on the same [`RuleMatch`] the rules file uses —
5//! two match vocabularies over the same identities would drift.
6//!
7//! **The style decides the shape of a target rather than labelling it.** An
8//! *automated* target carries a probe and is answered by running it; a *human-step*
9//! target carries no probe at all — there is nothing to ask, because the release
10//! happens when a person does something — and is answered by an explicit record a
11//! person writes afterwards. So `style` is the tag over the two shapes and not a
12//! field beside them: a human-step target naming a `probe:` and an automated target
13//! naming an `action:` are both refused where the document is read, and "a
14//! human-step target has a probe" is not a state this crate can hold.
15//!
16//! Nothing here declares `deny_unknown_fields`. A document a *newer* build wrote
17//! loads on this one, which takes the fields it understands and ignores the keys it
18//! has no opinion on — an older `onevcs` on a host whose release targets were
19//! configured by a newer one is degraded, never stopped. What a target's own shape
20//! still refuses it refuses by naming the target, because those are not keys this
21//! build has no opinion on: they are the two forms of a probe, and which one a
22//! target carries is the whole of what makes it answerable.
23
24use std::collections::BTreeMap;
25use std::path::{Path, PathBuf};
26use std::time::{Duration, Instant};
27
28use serde::{Deserialize, Serialize};
29
30use crate::declaration::{Declaration, InstructionTemplate};
31use crate::rules::RuleMatch;
32
33/// The version of the release-targets file this build writes, and the oldest it
34/// reads.
35///
36/// The oldest rather than the newest: a document declaring a *later* version is read
37/// as this shape, with whatever it names beyond it ignored, because refusing it would
38/// stop every release verb on a host a newer `onevcs` had configured.
39pub const VERSION: u32 = 1;
40
41/// The bound a probe runs under when the document names none.
42pub const DEFAULT_PROBE_TIMEOUT_SECONDS: u64 = 60;
43
44/// A release-targets file, as it is stored on disk.
45#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
46pub struct ReleasesFile {
47 /// The schema version: `1` is the shape declared here.
48 // llmlint: ignore[boundary_inputs_validated] which versions this build reads is the
49 // loader's question rather than this type's, and `release::load` answers it: a
50 // document below this one is refused by number, before the shape is enforced, and a
51 // later one is read as this shape with whatever it names beyond it ignored. What the
52 // shape can reject — an undeclared adoption, or a target whose style and body
53 // disagree — is rejected here and asserted in tests/contract.rs.
54 pub version: u32,
55 /// The rules, in priority order: the first one that matches wins.
56 #[serde(default, skip_serializing_if = "Vec::is_empty")]
57 pub repositories: Vec<ReleaseRule>,
58 /// What a repository no rule below names gets.
59 pub default: ReleaseDefault,
60}
61
62/// The global rung of the adoption chain.
63#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
64pub struct ReleaseDefault {
65 /// What a repository no rule names adopts.
66 pub adoption: Adoption,
67}
68
69/// One rule: what it matches, what that repository adopts, and what it releases.
70#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
71pub struct ReleaseRule {
72 /// What this rule applies to, in the rules file's own vocabulary.
73 #[serde(rename = "match")]
74 pub r#match: RuleMatch,
75 /// The per-repository rung of the adoption chain. Unset falls to the global one.
76 #[serde(default, skip_serializing_if = "Option::is_none")]
77 pub adoption: Option<Adoption>,
78 /// What a consumer naming no target gets.
79 #[serde(default, skip_serializing_if = "Option::is_none")]
80 pub default_target: Option<TargetName>,
81 /// What this rule does with the repository's own declaration. Unset is
82 /// [`DeclarationPolicy::Merge`], which is what makes a producer's declaration
83 /// visible to a host that has said nothing about it.
84 #[serde(default, skip_serializing_if = "Option::is_none")]
85 pub declaration: Option<DeclarationPolicy>,
86 /// The targets this host waits on for this repository.
87 #[serde(default, skip_serializing_if = "Vec::is_empty")]
88 pub targets: Vec<ReleaseTarget>,
89}
90
91/// What one rule does with the declaration the repository itself carries.
92///
93/// The default is [`Merge`](Self::Merge), because a host that has said nothing
94/// about a repository's own declaration has not said it should be ignored — and a
95/// producer that declares what it publishes is discoverable without this host being
96/// told the same thing twice.
97///
98/// [`Ignore`](Self::Ignore) is the one way a host says *a target it does not
99/// consume*: the rule's own targets become the whole answer, which is exactly what
100/// a rule answered before a producer declaration existed.
101#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
102#[serde(rename_all = "kebab-case")]
103pub enum DeclarationPolicy {
104 /// This rule's targets are laid over the repository's own, by short name.
105 #[default]
106 Merge,
107 /// The repository's own declaration is not consulted for targets at all.
108 Ignore,
109}
110
111impl DeclarationPolicy {
112 /// How this policy is spelled in the document and in a rendering.
113 pub fn as_str(&self) -> &'static str {
114 match self {
115 DeclarationPolicy::Merge => "merge",
116 DeclarationPolicy::Ignore => "ignore",
117 }
118 }
119}
120
121impl std::fmt::Display for DeclarationPolicy {
122 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
123 f.write_str(self.as_str())
124 }
125}
126
127/// Whether a node waits for the *work* or for the *release* that carries it.
128///
129/// Two rungs of a four-rung chain live here — the global one and the
130/// per-repository one. The node rung and a consumer's own default are the
131/// consumer's, which is why [`adoption_for`](crate::adoption_for) answers these two
132/// and never those.
133#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
134#[serde(rename_all = "kebab-case")]
135pub enum Adoption {
136 /// The work is enough: a consumer may proceed as soon as the change lands.
137 Fast,
138 /// The release is what is depended on, so a consumer waits for one.
139 Published,
140}
141
142impl Adoption {
143 /// How this rung is spelled in the document and in a rendering.
144 pub fn as_str(&self) -> &'static str {
145 match self {
146 Adoption::Fast => "fast",
147 Adoption::Published => "published",
148 }
149 }
150}
151
152impl std::fmt::Display for Adoption {
153 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
154 f.write_str(self.as_str())
155 }
156}
157
158/// One thing a repository releases.
159#[derive(Debug, Clone, PartialEq, Eq)]
160pub struct ReleaseTarget {
161 /// What it is called, which is how every command and every record names it.
162 pub name: TargetName,
163 /// How it is released, and therefore how a release of it is learned about.
164 pub release: ReleaseMethod,
165 /// The minijinja template a consumer of this target is instructed by, after the
166 /// three layers have resolved.
167 ///
168 /// A **declared** target carries the producer's own, copied across when the
169 /// declaration is read; a target this host names carries this host's. An override
170 /// replaces the target whole, this field with it — a half-host, half-producer
171 /// target is a probe nobody wrote. Composition is the consumer's explicit act
172 /// instead, inside the template: a host template may begin
173 /// `{% extends "producer" %}` and override named blocks, and one naming no
174 /// `extends` replaces wholly.
175 pub adoption_instructions: Option<InstructionTemplate>,
176}
177
178impl ReleaseTarget {
179 /// The label this target's style is reported under.
180 pub fn style(&self) -> ReleaseStyle {
181 match self.release {
182 ReleaseMethod::Automated { .. } => ReleaseStyle::Automated,
183 ReleaseMethod::HumanStep { .. } => ReleaseStyle::HumanStep,
184 }
185 }
186
187 /// The probe that answers what is released, when there is one.
188 ///
189 /// **Always `None` for a human-step target**, by construction rather than by a
190 /// check that could be forgotten: the probe lives on the automated variant, so
191 /// there is none here to hand back and none for a caller to run.
192 pub fn probe(&self) -> Option<&Probe> {
193 match &self.release {
194 ReleaseMethod::Automated { probe } => Some(probe),
195 ReleaseMethod::HumanStep { .. } => None,
196 }
197 }
198
199 /// What a person has to do, for the target that waits on one.
200 pub fn action(&self) -> Option<&str> {
201 match &self.release {
202 ReleaseMethod::HumanStep { action } => Some(action),
203 ReleaseMethod::Automated { .. } => None,
204 }
205 }
206}
207
208/// How a target is released, and therefore how a release of it is learned about.
209#[derive(Debug, Clone, PartialEq, Eq)]
210pub enum ReleaseMethod {
211 /// A machine releases it, and a probe says what is out.
212 Automated {
213 /// What to run to find out what is released right now.
214 probe: Probe,
215 },
216 /// A person releases it, and says so afterwards.
217 HumanStep {
218 /// What that person has to do, rendered in the wait.
219 action: String,
220 },
221}
222
223/// Which of the two styles a target is.
224#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
225#[serde(rename_all = "kebab-case")]
226pub enum ReleaseStyle {
227 /// A machine releases it; a probe answers what is out.
228 Automated,
229 /// A person releases it; an acknowledgement records that they did.
230 HumanStep,
231}
232
233impl ReleaseStyle {
234 /// How this style is spelled in the document, in an event, and in a rendering.
235 pub fn as_str(&self) -> &'static str {
236 match self {
237 ReleaseStyle::Automated => "automated",
238 ReleaseStyle::HumanStep => "human-step",
239 }
240 }
241}
242
243impl std::fmt::Display for ReleaseStyle {
244 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
245 f.write_str(self.as_str())
246 }
247}
248
249/// Where the answer that a release carries a landing came from.
250#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
251#[serde(rename_all = "kebab-case")]
252pub enum ReleaseSource {
253 /// The target's configured probe answered it.
254 Probed,
255 /// A person explicitly recorded it.
256 Acknowledged,
257}
258
259impl std::fmt::Display for ReleaseSource {
260 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
261 match self {
262 ReleaseSource::Probed => f.write_str("probed"),
263 ReleaseSource::Acknowledged => f.write_str("acknowledged"),
264 }
265 }
266}
267
268/// What to run to find out what version of a target is released right now.
269///
270/// Two forms and no way to spell a third, neither privileged: a script checked into
271/// the repository being released, run as a direct subprocess, and a one-liner
272/// configured on this host, run through `sh -c`. Both are bounded, and a target
273/// naming both forms or neither does not deserialize at all.
274#[derive(Debug, Clone, PartialEq, Eq)]
275pub enum Probe {
276 /// A path relative to the repository root, checked into the repository being
277 /// released. Run as a direct subprocess, never through a shell.
278 Script {
279 /// Where it is, relative to the repository root.
280 script: PathBuf,
281 /// The arguments it is given.
282 args: Vec<String>,
283 /// The bound it runs under.
284 timeout_seconds: u64,
285 },
286 /// A one-liner configured on this host, run through `sh -c`.
287 Shell {
288 /// The command line, as `sh` reads it.
289 shell: String,
290 /// The bound it runs under.
291 timeout_seconds: u64,
292 },
293}
294
295impl Probe {
296 /// Which form this is, as the `release-probed` event reports it.
297 pub fn form(&self) -> &'static str {
298 match self {
299 Probe::Script { .. } => "script",
300 Probe::Shell { .. } => "shell",
301 }
302 }
303}
304
305/// A target's name, checked where it is read.
306///
307/// It names a key in the persisted release record, a directory-free file-safe
308/// token, and an operand of `--target`, so what may spell one is decided in the
309/// conversion — a name that could not be all three is unrepresentable rather than
310/// refused by whichever of the three met it first.
311#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
312#[serde(try_from = "String", into = "String")]
313pub struct TargetName(String);
314
315/// How long a target's name may be. Long enough for any name a person types, short
316/// enough that a refusal quoting one is still a sentence.
317const MAX_TARGET_NAME: usize = 64;
318
319/// How long a human step's action may be. It is one instruction rendered on one
320/// line beside the wait it explains, not the procedure behind it.
321const MAX_ACTION: usize = 200;
322
323/// Whether a value from the release-targets file renders as the one line it is
324/// printed on.
325///
326/// An `action` and a script probe's arguments are both operator-written text that
327/// this crate prints — in a table, in a refusal, and in an event payload — so a
328/// value carrying a newline or a control character renders as something other than
329/// what it is wherever it lands. Checked where the document is read, which is the
330/// boundary it arrives at, rather than by whichever renderer met it first.
331fn renders_on_one_line(value: &str) -> bool {
332 !value.chars().any(char::is_control)
333}
334
335impl TryFrom<String> for TargetName {
336 type Error = String;
337
338 fn try_from(value: String) -> std::result::Result<Self, Self::Error> {
339 if value.is_empty() {
340 return Err("a release target's name cannot be empty".to_owned());
341 }
342 if value.len() > MAX_TARGET_NAME {
343 return Err(format!(
344 "the release target name {value:?} is longer than {MAX_TARGET_NAME} characters"
345 ));
346 }
347 if !value
348 .chars()
349 .next()
350 .is_some_and(|first| first.is_ascii_alphanumeric())
351 {
352 return Err(format!(
353 "the release target name {value:?} must start with a letter or a digit"
354 ));
355 }
356 if !value
357 .chars()
358 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
359 {
360 return Err(format!(
361 "the release target name {value:?} may hold only letters, digits, '-', '_', \
362 and '.'"
363 ));
364 }
365 Ok(TargetName(value))
366 }
367}
368
369/// The conversion an argument parser reaches for, which is the same check.
370///
371/// A `--target` operand and a document's `name:` are one vocabulary, so a name the
372/// document would refuse is refused on the command line too — and with the same
373/// sentence, rather than with a parser's usage text for one and prose for the other.
374impl std::str::FromStr for TargetName {
375 type Err = String;
376
377 fn from_str(value: &str) -> std::result::Result<Self, Self::Err> {
378 TargetName::try_from(value.to_owned())
379 }
380}
381
382impl From<TargetName> for String {
383 fn from(name: TargetName) -> Self {
384 name.0
385 }
386}
387
388/// The name itself, quoted — never the wrapper, for the reason
389/// [`TrailerPrefix`](crate::rules::TrailerPrefix) spells its own: every refusal that
390/// names a target writes it with `{:?}`, and a derived `Debug` would spell each of
391/// them at an operator who configured a plain word.
392impl std::fmt::Debug for TargetName {
393 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
394 std::fmt::Debug::fmt(&self.0, f)
395 }
396}
397
398impl std::fmt::Display for TargetName {
399 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
400 f.write_str(&self.0)
401 }
402}
403
404impl std::ops::Deref for TargetName {
405 type Target = str;
406
407 fn deref(&self) -> &str {
408 &self.0
409 }
410}
411
412/// What one probe answered about what is released right now.
413///
414/// Three values, and the third is the point: **"not answered" and "not released"
415/// are different answers** and stay different all the way up. A consumer holds
416/// indefinitely on "not answered" and never reads it as evidence that a release has
417/// not happened.
418#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
419#[serde(tag = "state", rename_all = "kebab-case")]
420pub enum ReleaseAnswer {
421 /// A release exists, at this version.
422 Released {
423 /// The version the probe printed.
424 version: String,
425 },
426 /// The target has no release yet. An answer, not a failure.
427 NoRelease,
428 /// The question was not answered: a non-zero exit, a timeout, a spawn failure,
429 /// or output that is not one usable line. **Never "not released".**
430 NotAnswered {
431 /// What stopped the probe answering.
432 reason: String,
433 },
434}
435
436/// What a target had at the moment a change landed.
437///
438/// Only these two can be compared against later, which is why the answer a probe
439/// that did not answer left behind is not a member of this type — see
440/// [`BaselineRecord`].
441#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
442#[serde(tag = "state", rename_all = "kebab-case")]
443pub enum Baseline {
444 /// A release existed. A strictly greater one carries the change.
445 At {
446 /// The version that was out when the change landed.
447 version: String,
448 },
449 /// Nothing had ever been released for this target. The *first* release of any
450 /// version carries the change.
451 NoRelease,
452}
453
454/// What is persisted per landing — a baseline, or the record that there is none.
455#[derive(Debug, Clone, PartialEq, Eq)]
456pub enum BaselineRecord {
457 /// A baseline was established at the landing, and can be compared against.
458 Established(Baseline),
459 /// No baseline could be taken at landing: no probe answered then, or the landing
460 /// was found only after a release may already carry it. **Not a baseline**, and
461 /// never usable as one: a probe answering a *version* later cannot repair it,
462 /// because the release carrying this very change may already be included in it.
463 Unestablished {
464 /// Why: what the probe said when it was asked at the landing, or what says a
465 /// release may already carry a landing found late.
466 reason: String,
467 /// When it was asked, RFC3339 with millisecond precision, in UTC.
468 attempted_at: String,
469 },
470}
471
472/// The persisted form of a baseline record: one tagged object rather than a bare
473/// string, because a bare string cannot express the other two answers and
474/// conflating any pair of them is how a change gets reported as released when it is
475/// not.
476#[derive(Serialize, Deserialize)]
477#[serde(tag = "state", rename_all = "kebab-case")]
478enum StoredBaseline {
479 At {
480 version: String,
481 },
482 NoRelease,
483 Unestablished {
484 reason: String,
485 attempted_at: String,
486 },
487}
488
489impl Serialize for BaselineRecord {
490 fn serialize<S: serde::Serializer>(
491 &self,
492 serializer: S,
493 ) -> std::result::Result<S::Ok, S::Error> {
494 let stored = match self {
495 BaselineRecord::Established(Baseline::At { version }) => StoredBaseline::At {
496 version: version.clone(),
497 },
498 BaselineRecord::Established(Baseline::NoRelease) => StoredBaseline::NoRelease,
499 BaselineRecord::Unestablished {
500 reason,
501 attempted_at,
502 } => StoredBaseline::Unestablished {
503 reason: reason.clone(),
504 attempted_at: attempted_at.clone(),
505 },
506 };
507 stored.serialize(serializer)
508 }
509}
510
511impl<'de> Deserialize<'de> for BaselineRecord {
512 fn deserialize<D: serde::Deserializer<'de>>(
513 deserializer: D,
514 ) -> std::result::Result<Self, D::Error> {
515 Ok(match StoredBaseline::deserialize(deserializer)? {
516 StoredBaseline::At { version } => BaselineRecord::Established(Baseline::At { version }),
517 StoredBaseline::NoRelease => BaselineRecord::Established(Baseline::NoRelease),
518 StoredBaseline::Unestablished {
519 reason,
520 attempted_at,
521 } => BaselineRecord::Unestablished {
522 reason,
523 attempted_at,
524 },
525 })
526 }
527}
528
529/// Whether the release that carries one landed change has happened yet.
530#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
531#[serde(tag = "state", rename_all = "kebab-case")]
532pub enum ReleaseStatus {
533 /// A release carrying this change is out, at this version.
534 Released {
535 /// The target that carries it.
536 target: TargetName,
537 /// Which kind of release that was.
538 style: ReleaseStyle,
539 /// The version that carries the change.
540 version: String,
541 /// Where this answer came from.
542 source: ReleaseSource,
543 },
544 /// Automated only: a probe answered, and the baseline has not been passed.
545 // llmlint: ignore[invalid_states_unrepresentable] `now` is an empty string where
546 // there is no release right now, and that is the approved amendment's own shape —
547 // fixed across the nodes being written against it, so an `Option<String>` here
548 // would be this crate changing a shared interface unilaterally. The side a
549 // comparison is *made against* is already the enum this rule asks for: `at_landing`
550 // is a `Baseline`, which is what carries the state a bare string could not express.
551 // The empty string is documented on the field and rendered as `no release` by the
552 // one thing that prints it, and every path that produces it is a journey.
553 NotReleased {
554 /// What the target had when the change landed.
555 at_landing: Baseline,
556 /// What it has now — empty where there is no release right now at all,
557 /// which is what a target answers before its first release and after a
558 /// yank.
559 now: String,
560 },
561 /// Human step only: it landed, and nobody has acknowledged a release yet.
562 /// Neither [`NotReleased`](ReleaseStatus::NotReleased) (no probe answered) nor
563 /// [`NotAnswered`](ReleaseStatus::NotAnswered) (no probe failed).
564 AwaitingHumanStep {
565 /// The target waiting on a person.
566 target: TargetName,
567 /// What that person has to do.
568 action: String,
569 /// When the wait started: the landing commit's own committer date.
570 since: String,
571 },
572 /// The question was not answered, and this is why. **Never "not released".**
573 NotAnswered {
574 /// What stopped it being answered.
575 reason: String,
576 },
577 /// The work has not reached its base, so there is no release to ask about yet.
578 NotLanded,
579}
580
581/// One release a person performed and then recorded.
582#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
583pub struct Acknowledgement {
584 /// The repository identity it belongs to.
585 pub identity: String,
586 /// The target that was released.
587 pub target: TargetName,
588 /// The commit the change landed at, which is what it is recorded against.
589 pub landing_commit: String,
590 /// The version that was released.
591 pub version: String,
592 /// When it was recorded, RFC3339 with millisecond precision, in UTC.
593 pub recorded_at: String,
594 /// Who recorded it.
595 pub actor: String,
596 /// Every version this record replaced, oldest first. Empty on a first record.
597 #[serde(default, skip_serializing_if = "Vec::is_empty")]
598 pub superseded: Vec<SupersededRelease>,
599}
600
601/// A version an acknowledgement replaced, kept so the correction is visible rather
602/// than destructive.
603#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
604pub struct SupersededRelease {
605 /// The version that was recorded before.
606 pub version: String,
607 /// When it was recorded.
608 pub recorded_at: String,
609 /// Who recorded it.
610 pub actor: String,
611}
612
613/// What a repository's own declaration said, or why this build has no answer from
614/// it.
615///
616/// Three states, and the third is why this is a value rather than an
617/// `Option<Declaration>`: **a declaration this build could not read is not a
618/// repository that declares nothing.** A consumer waiting on a release holds
619/// indefinitely on the first and acts on the second, so collapsing them here would
620/// be the "not answered is not not released" mistake made one layer up.
621#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
622#[serde(tag = "state", rename_all = "kebab-case")]
623pub enum DeclarationSource {
624 /// The repository declares what it publishes, and this build read it.
625 Declared {
626 /// The document it was read from.
627 document: PathBuf,
628 /// What that document declares.
629 declared: Declaration,
630 },
631 /// The repository carries no declaration: nothing in it says what it publishes,
632 /// so this host's own document is the whole answer.
633 Undeclared {
634 /// The checkout that was looked in.
635 looked_in: PathBuf,
636 },
637 /// What this repository declares is **unknown** — there is nowhere to read a
638 /// declaration from, or the one that is there is not a declaration this build
639 /// can read.
640 ///
641 /// Never "it declares nothing": whatever targets are answered alongside this may
642 /// be fewer than the repository publishes.
643 Unreadable {
644 /// Why it could not be read, in the words the reader refused it with.
645 reason: String,
646 },
647}
648
649impl DeclarationSource {
650 /// How this state is spelled in a rendering and in the JSON document.
651 pub fn as_str(&self) -> &'static str {
652 match self {
653 DeclarationSource::Declared { .. } => "declared",
654 DeclarationSource::Undeclared { .. } => "undeclared",
655 DeclarationSource::Unreadable { .. } => "unreadable",
656 }
657 }
658
659 /// What the repository declares, where this build read a declaration.
660 pub fn declared(&self) -> Option<&Declaration> {
661 match self {
662 DeclarationSource::Declared { declared, .. } => Some(declared),
663 _ => None,
664 }
665 }
666
667 /// Why what this repository publishes is unknown, where it is.
668 ///
669 /// A caller that has to say "there may be more targets than these" asks this,
670 /// and gets `None` for both states in which the answer is complete.
671 pub fn unreadable(&self) -> Option<&str> {
672 match self {
673 DeclarationSource::Unreadable { reason } => Some(reason),
674 _ => None,
675 }
676 }
677}
678
679/// Which of the three layers put one target in the answer.
680///
681/// Recorded per target rather than inferred by a reader, because "why did this
682/// repository resolve these targets" has to be answerable from the answer itself —
683/// a merged set in which every target looks alike is a set nobody can audit.
684#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
685#[serde(rename_all = "kebab-case")]
686pub enum TargetSource {
687 /// The repository's own declaration, which this host did not oppose.
688 Declared,
689 /// This host's own document, naming a target the repository does not declare —
690 /// or does not declare that this build could read.
691 Host,
692 /// This host's own document, replacing a target the repository declared under
693 /// the same short name.
694 Override,
695}
696
697impl TargetSource {
698 /// How this layer is spelled in a rendering and in the JSON document.
699 pub fn as_str(&self) -> &'static str {
700 match self {
701 TargetSource::Declared => "declared",
702 TargetSource::Host => "host",
703 TargetSource::Override => "override",
704 }
705 }
706}
707
708impl std::fmt::Display for TargetSource {
709 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
710 f.write_str(self.as_str())
711 }
712}
713
714/// What one repository releases, and what it adopts.
715#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
716pub struct RepositoryReleases {
717 /// The identity key these targets belong to.
718 pub identity: String,
719 /// The rung the adoption chain resolves to here.
720 pub adoption: Adoption,
721 /// What a consumer naming no target gets.
722 #[serde(skip_serializing_if = "Option::is_none")]
723 pub default_target: Option<TargetName>,
724 /// Every target this repository releases: the ones it declares itself, in its
725 /// own publication order, then the ones this host names that it does not.
726 pub targets: Vec<ReleaseTarget>,
727 /// What the repository's own declaration said, or why there is no answer from
728 /// it.
729 pub declaration: DeclarationSource,
730 /// Which layer resolved each target above.
731 pub sources: BTreeMap<TargetName, TargetSource>,
732}
733
734/// Every target one repository has, and what each of them has released right now.
735///
736/// The answer a consumer that waits on releases asks for: [`RepositoryReleases`]
737/// alone says what there is to wait for, and this says what each of those has done.
738/// One call rather than one per target, because a node holding on a dependency has
739/// to ask about all of them and a caller that looped would have this crate resolve
740/// the three layers once per target.
741#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
742pub struct Discovery {
743 /// What this repository releases, and where each target came from.
744 pub releases: RepositoryReleases,
745 /// What each of those targets has released, in `releases.targets` order.
746 pub released: Vec<TargetRelease>,
747}
748
749/// One target, and what it has released right now.
750#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
751pub struct TargetRelease {
752 /// Which target this is.
753 pub target: TargetName,
754 /// How it is released, which is how its answer was arrived at.
755 pub style: ReleaseStyle,
756 /// What is released right now — and `NotAnswered` where nothing said, which is
757 /// never `NoRelease`.
758 pub answer: ReleaseAnswer,
759}
760
761impl RepositoryReleases {
762 /// The target one name selects, or the reason no target answers to it.
763 ///
764 /// A caller naming none gets the declared `default_target`; a repository that
765 /// declares no default answers with what it does declare rather than guessing,
766 /// because which artifact a consumer depends on is not something to infer from
767 /// there happening to be one.
768 pub fn select(&self, named: Option<&TargetName>) -> crate::Result<&ReleaseTarget> {
769 if self.targets.is_empty() {
770 return Err(crate::error::invalid(format!(
771 "the repository {} declares no release targets, so there is nothing to ask \
772 about; declare some in its own {file}, or under `repositories:` in the \
773 release-targets file{unknown}",
774 self.identity,
775 file = crate::declaration::FILE,
776 unknown = self.unknown(),
777 )));
778 }
779 let wanted = match named.or(self.default_target.as_ref()) {
780 Some(wanted) => wanted,
781 None => {
782 return Err(crate::error::invalid(format!(
783 "the repository {} declares no default_target, so a target has to be named: \
784 --target {}",
785 self.identity,
786 self.declared()
787 )))
788 }
789 };
790 self.targets
791 .iter()
792 .find(|target| target.name == *wanted)
793 .ok_or_else(|| {
794 crate::error::invalid(format!(
795 "the repository {} declares no release target {wanted:?}; it declares \
796 {declared}. `onevcs release targets {identity}` lists them{unknown}",
797 self.identity,
798 declared = self.declared(),
799 identity = self.identity,
800 unknown = self.unknown(),
801 ))
802 })
803 }
804
805 /// The targets this repository declares, as a refusal lists them.
806 fn declared(&self) -> String {
807 self.targets
808 .iter()
809 .map(|target| target.name.to_string())
810 .collect::<Vec<String>>()
811 .join(", ")
812 }
813
814 /// What a refusal about a missing target has to add where this build could not
815 /// read what the repository declares.
816 ///
817 /// Without it a refusal states as a fact — "it declares these" — something this
818 /// build only knows half of, and sends somebody to add a target to the host
819 /// document that the repository already publishes. Empty in both states where
820 /// the answer is complete, so no refusal grows a clause about a document that
821 /// read fine.
822 fn unknown(&self) -> String {
823 self.declaration
824 .unreadable()
825 .map_or_else(String::new, |reason| {
826 format!(
827 ". This repository's own declaration could not be read, so it may declare \
828 targets this does not name: {reason}"
829 )
830 })
831 }
832}
833
834/// A release target as the document spells it: the name, the style, and whichever
835/// body that style takes.
836///
837/// The shape is read flat and converted, rather than being a serde-tagged enum
838/// beside a name, so that every refusal can **name the target it is about** — which
839/// is the whole of what an operator needs to find the four lines they wrote.
840#[derive(Deserialize)]
841struct StoredTarget {
842 name: TargetName,
843 style: ReleaseStyle,
844 #[serde(default)]
845 probe: Option<StoredProbe>,
846 #[serde(default)]
847 action: Option<String>,
848 #[serde(default)]
849 adoption_instructions: Option<InstructionTemplate>,
850}
851
852/// A probe as the document spells it: both forms' keys, exactly one of which a
853/// target may name.
854#[derive(Deserialize)]
855struct StoredProbe {
856 #[serde(default)]
857 script: Option<PathBuf>,
858 #[serde(default)]
859 args: Option<Vec<String>>,
860 #[serde(default)]
861 shell: Option<String>,
862 #[serde(default)]
863 timeout_seconds: Option<u64>,
864}
865
866/// The serialized form, which is the same flat shape it is read from.
867#[derive(Serialize)]
868struct WrittenTarget<'a> {
869 name: &'a TargetName,
870 style: ReleaseStyle,
871 #[serde(skip_serializing_if = "Option::is_none")]
872 probe: Option<WrittenProbe<'a>>,
873 #[serde(skip_serializing_if = "Option::is_none")]
874 action: Option<&'a str>,
875 #[serde(skip_serializing_if = "Option::is_none")]
876 adoption_instructions: Option<&'a InstructionTemplate>,
877}
878
879#[derive(Serialize)]
880struct WrittenProbe<'a> {
881 #[serde(skip_serializing_if = "Option::is_none")]
882 script: Option<&'a Path>,
883 #[serde(skip_serializing_if = "Option::is_none")]
884 args: Option<&'a [String]>,
885 #[serde(skip_serializing_if = "Option::is_none")]
886 shell: Option<&'a str>,
887 timeout_seconds: u64,
888}
889
890impl Serialize for ReleaseTarget {
891 fn serialize<S: serde::Serializer>(
892 &self,
893 serializer: S,
894 ) -> std::result::Result<S::Ok, S::Error> {
895 let written = WrittenTarget {
896 name: &self.name,
897 style: self.style(),
898 probe: self.probe().map(|probe| match probe {
899 Probe::Script {
900 script,
901 args,
902 timeout_seconds,
903 } => WrittenProbe {
904 script: Some(script),
905 args: Some(args),
906 shell: None,
907 timeout_seconds: *timeout_seconds,
908 },
909 Probe::Shell {
910 shell,
911 timeout_seconds,
912 } => WrittenProbe {
913 script: None,
914 args: None,
915 shell: Some(shell),
916 timeout_seconds: *timeout_seconds,
917 },
918 }),
919 action: self.action(),
920 adoption_instructions: self.adoption_instructions.as_ref(),
921 };
922 written.serialize(serializer)
923 }
924}
925
926impl<'de> Deserialize<'de> for ReleaseTarget {
927 fn deserialize<D: serde::Deserializer<'de>>(
928 deserializer: D,
929 ) -> std::result::Result<Self, D::Error> {
930 let stored = StoredTarget::deserialize(deserializer)?;
931 ReleaseTarget::try_from(stored).map_err(serde::de::Error::custom)
932 }
933}
934
935impl TryFrom<StoredTarget> for ReleaseTarget {
936 type Error = String;
937
938 fn try_from(stored: StoredTarget) -> std::result::Result<Self, Self::Error> {
939 let name = stored.name;
940 let release = match stored.style {
941 ReleaseStyle::Automated => {
942 if stored.action.is_some() {
943 return Err(format!(
944 "the release target {name:?} declares style: automated and names an \
945 action, which only a human-step target has — a machine releases an \
946 automated target and its probe says what is out. Remove the action, or \
947 declare style: human-step"
948 ));
949 }
950 let probe = stored.probe.ok_or_else(|| {
951 format!(
952 "the release target {name:?} declares style: automated and names no \
953 probe; an automated target is answered by running one. Give it a \
954 `probe:` with either `script:` or `shell:`"
955 )
956 })?;
957 ReleaseMethod::Automated {
958 probe: probe.into_probe(&name)?,
959 }
960 }
961 ReleaseStyle::HumanStep => {
962 if stored.probe.is_some() {
963 return Err(format!(
964 "the release target {name:?} declares style: human-step and names a \
965 probe, which only an automated target has — a human-step target has \
966 nothing to ask, because the release happens when a person does \
967 something. Remove the probe, or declare style: automated"
968 ));
969 }
970 let action = stored.action.ok_or_else(|| {
971 format!(
972 "the release target {name:?} declares style: human-step and names no \
973 action; the action is what a person has to do, and it is what the wait \
974 renders. Give it an `action:`"
975 )
976 })?;
977 if action.trim().is_empty() {
978 return Err(format!(
979 "the release target {name:?} has a blank action; it is what a person \
980 reads to know what to do, so a blank one is a wait nobody can act on"
981 ));
982 }
983 if !renders_on_one_line(&action) || action.chars().count() > MAX_ACTION {
984 return Err(format!(
985 "the release target {name:?} has an action that is not one printable \
986 line of at most {MAX_ACTION} characters; it is rendered beside the wait \
987 it explains, so it says what to do rather than how"
988 ));
989 }
990 ReleaseMethod::HumanStep { action }
991 }
992 };
993 Ok(ReleaseTarget {
994 name,
995 release,
996 adoption_instructions: stored.adoption_instructions,
997 })
998 }
999}
1000
1001impl StoredProbe {
1002 /// The probe this spells, or the reason it spells none.
1003 fn into_probe(self, name: &TargetName) -> std::result::Result<Probe, String> {
1004 let timeout_seconds = match self.timeout_seconds {
1005 None => DEFAULT_PROBE_TIMEOUT_SECONDS,
1006 // Zero is refused rather than held: a bound that has already fired is not
1007 // a bound, and one no clock on this host can reach names a moment that
1008 // never arrives, which is the unbounded run the bound exists to prevent.
1009 Some(seconds)
1010 if seconds > 0
1011 && Instant::now()
1012 .checked_add(Duration::from_secs(seconds))
1013 .is_some() =>
1014 {
1015 seconds
1016 }
1017 Some(seconds) => {
1018 return Err(format!(
1019 "the release target {name:?} names timeout_seconds: {seconds}; a probe's \
1020 bound must be above zero and short enough to be waited out from now"
1021 ))
1022 }
1023 };
1024 match (self.script, self.shell) {
1025 (Some(_), Some(_)) => Err(format!(
1026 "the release target {name:?} names both a script and a shell probe; a probe is \
1027 one or the other. Keep the form the repository actually carries"
1028 )),
1029 (None, None) => Err(format!(
1030 "the release target {name:?} names neither a script nor a shell probe; an \
1031 automated target is answered by running one of the two"
1032 )),
1033 (Some(script), None) => {
1034 relative_to_the_repository(&script, name)?;
1035 let args = self.args.unwrap_or_default();
1036 if let Some(argument) = args.iter().find(|it| !renders_on_one_line(it)) {
1037 return Err(format!(
1038 "the release target {name:?} names a probe argument carrying a control \
1039 character ({argument:?}); an argument is printed wherever the probe is \
1040 named, so it has to be the one line it renders as"
1041 ));
1042 }
1043 Ok(Probe::Script {
1044 script,
1045 args,
1046 timeout_seconds,
1047 })
1048 }
1049 (None, Some(shell)) => {
1050 if self.args.is_some() {
1051 return Err(format!(
1052 "the release target {name:?} names args beside a shell probe; a shell \
1053 probe is one line that `sh` reads, so its arguments are written into \
1054 it. Move them into the `shell:` line, or name a `script:` instead"
1055 ));
1056 }
1057 if shell.trim().is_empty() {
1058 return Err(format!(
1059 "the release target {name:?} names a blank shell probe; there is nothing \
1060 there to run"
1061 ));
1062 }
1063 if !renders_on_one_line(&shell) {
1064 return Err(format!(
1065 "the release target {name:?} names a shell probe carrying a control \
1066 character; this form is the one line `sh` reads, and it is printed \
1067 wherever the probe is named. Put a script the repository carries behind \
1068 a `script:` instead"
1069 ));
1070 }
1071 Ok(Probe::Shell {
1072 shell,
1073 timeout_seconds,
1074 })
1075 }
1076 }
1077 }
1078}
1079
1080/// Refuse a script path that does not name a file inside the repository.
1081///
1082/// The form exists to run something the repository being released **carries**, so a
1083/// path that leaves the repository root is refused where the document is read
1084/// rather than resolved at the moment it would be executed.
1085fn relative_to_the_repository(script: &Path, name: &TargetName) -> std::result::Result<(), String> {
1086 if script.as_os_str().is_empty() {
1087 return Err(format!(
1088 "the release target {name:?} names an empty script path"
1089 ));
1090 }
1091 if script.is_absolute() {
1092 return Err(format!(
1093 "the release target {name:?} names the absolute script path {script}; a script probe \
1094 is a path relative to the repository root, because it runs what the repository being \
1095 released carries",
1096 script = script.display()
1097 ));
1098 }
1099 if script
1100 .components()
1101 .any(|component| matches!(component, std::path::Component::ParentDir))
1102 {
1103 return Err(format!(
1104 "the release target {name:?} names the script path {script}, which leaves the \
1105 repository root; a script probe runs what the repository being released carries",
1106 script = script.display()
1107 ));
1108 }
1109 Ok(())
1110}