runner_manager_platform/os.rs
1// owner: d1-platform-core
2
3//! Where this host stands in GitHub's documented self-hosted runner support
4//! matrix.
5//!
6//! The matrix is quoted in `01-current-architecture.md` from GitHub's
7//! self-hosted runner reference:
8//!
9//! > GitHub documents Windows 10/11 64-bit and Windows Server 2016/2019/2022
10//! > 64-bit, macOS 11.0 (Big Sur) or later, and nine Linux distributions
11//! > (RHEL/CentOS/Oracle 8+, Fedora 29+, Debian 10+, Ubuntu 20.04+, Mint 20+,
12//! > openSUSE 15.2+, SLES 15 SP2+) as supported runner platforms. Supported
13//! > architectures are x64 on all three, ARM64 on all three (**public
14//! > preview**), and ARM32 on Linux only.
15//!
16//! Two consequences of that quotation shape this module, and both are
17//! requirements rather than conveniences:
18//!
19//! 1. **ARM64 warns; it does not reject.** The persona's host is an Apple
20//! Silicon Mac mini, so a design that rejected public-preview architectures
21//! would reject the primary target machine. [`validate`] therefore returns
22//! `Ok` carrying a [`SupportWarning`].
23//! 2. **Container actions and service containers require Linux.** A host does
24//! not gain them by running Docker (`01-current-architecture.md`, edge case
25//! 2), so [`HostSupport::container_support`] reports the limitation and
26//! `f2` surfaces it on macOS and Windows policy validation.
27//!
28//! # The types are the domain's; the verdict is this module's
29//!
30//! [`Os`] and [`Arch`] come from `runner-manager-domain` and are not restated
31//! here. An earlier version of this module defined its own `HostOs`/`HostArch`
32//! on the reasoning that platform detection sits below the persistence model —
33//! but this crate already depends on `runner-manager-domain`, so nothing was
34//! being avoided, and two enums naming the same three values had begun to
35//! disagree: `arm32` against the domain's `arm`, `windows`/`macos` against
36//! `win`/`osx`. One of those spellings feeds runner-package selection, so a
37//! disagreement there is a download of the wrong archive rather than a
38//! cosmetic difference. The domain's own documentation says as much —
39//! *"Enforcing that pairing is `d1`'s job; this enum only has to be able to
40//! name the values"* — which asks `d1` to **validate** those types, not to mint
41//! parallel ones.
42//!
43//! For the same reason the two predicates the domain already answers are not
44//! answered again here. [`SupportStatus`] and the ARM64 warning are derived
45//! from [`Arch::is_public_preview`], and [`ContainerSupport`] from
46//! [`Os::supports_container_actions`], so `f2` reading either this module or
47//! the domain gets the same verdict by construction rather than by two tables
48//! being kept in step.
49//!
50//! What is left is genuinely this module's: which *pairs* are documented,
51//! detection of the running host, the operator-facing text, and
52//! [`documented_releases`].
53//!
54//! ## Why the pair check is a `match` and not a table lookup
55//!
56//! [`validate`] classifies with an exhaustive `match` over `(Os, Arch)` rather
57//! than by searching a list of accepted pairs. That costs a few lines and buys
58//! two things. Adding a variant to either enum becomes a compile error here —
59//! the pair cannot be silently accepted or silently rejected by falling off the
60//! end of a table. And the tests can then carry their own, independently
61//! written copy of the documented matrix; asserting a table against itself
62//! would prove nothing.
63
64use std::fmt;
65
66use runner_manager_domain::model::{Arch, Os};
67use serde::{Deserialize, Serialize};
68
69// ---------------------------------------------------------------------------
70// Prose names
71//
72// `Os::label_token` and `Arch::label_token` are GitHub's runner-package tokens
73// — `win`, `osx`, `arm` — and `e2` selects a download with them. They are not
74// prose, and an operator reading "GitHub documents ARM32 runners on win only"
75// is being shown an internal token. These two functions exist for messages and
76// for nothing else; nothing that builds a label, a package name, or a path may
77// use them.
78// ---------------------------------------------------------------------------
79
80/// The operating system's name as GitHub's documentation writes it in prose.
81#[must_use]
82pub const fn os_name(os: Os) -> &'static str {
83 match os {
84 Os::Windows => "Windows",
85 Os::MacOs => "macOS",
86 Os::Linux => "Linux",
87 }
88}
89
90/// The architecture's name as GitHub's documentation writes it in prose.
91#[must_use]
92pub const fn arch_name(arch: Arch) -> &'static str {
93 match arch {
94 Arch::X64 => "x64",
95 Arch::Arm64 => "ARM64",
96 Arch::Arm32 => "ARM32",
97 }
98}
99
100/// The operating system this binary was compiled for, or `None` when that is
101/// not one of the three documented systems.
102///
103/// Resolved from `cfg!`, not from a runtime probe: a binary compiled for one
104/// operating system cannot be running on another, and a compile-time answer
105/// cannot be wrong about the thing it is most likely to be asked during an
106/// incident.
107#[must_use]
108pub const fn detect_os() -> Option<Os> {
109 if cfg!(target_os = "windows") {
110 Some(Os::Windows)
111 } else if cfg!(target_os = "macos") {
112 Some(Os::MacOs)
113 } else if cfg!(target_os = "linux") {
114 Some(Os::Linux)
115 } else {
116 None
117 }
118}
119
120/// The architecture this binary was compiled for, or `None` when that is not
121/// one of the three documented architectures.
122#[must_use]
123pub const fn detect_arch() -> Option<Arch> {
124 if cfg!(target_arch = "x86_64") {
125 Some(Arch::X64)
126 } else if cfg!(target_arch = "aarch64") {
127 Some(Arch::Arm64)
128 } else if cfg!(target_arch = "arm") {
129 Some(Arch::Arm32)
130 } else {
131 None
132 }
133}
134
135/// The operating system and architecture this binary was compiled for.
136///
137/// # Errors
138///
139/// [`UnsupportedHost::UndocumentedPlatform`] when the operating system or the
140/// architecture is outside GitHub's documented matrix entirely — a FreeBSD or
141/// RISC-V build, for instance. That is a *build* that should not exist rather
142/// than a host that should be warned about, so it is an error and not a
143/// warning.
144pub const fn detect_host() -> Result<(Os, Arch), UnsupportedHost> {
145 match (detect_os(), detect_arch()) {
146 (Some(os), Some(arch)) => Ok((os, arch)),
147 _ => Err(UnsupportedHost::UndocumentedPlatform {
148 os: std::env::consts::OS,
149 arch: std::env::consts::ARCH,
150 }),
151 }
152}
153
154/// A host that GitHub's matrix does not document.
155#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
156pub enum UnsupportedHost {
157 /// The build targets an operating system or architecture outside the
158 /// matrix altogether.
159 #[error(
160 "runner-manager is built for Windows, macOS, and Linux on x64, ARM64, or ARM32, \
161 but this binary targets {os}/{arch}, which GitHub does not document as a \
162 self-hosted runner platform"
163 )]
164 UndocumentedPlatform {
165 /// `std::env::consts::OS` for the offending build.
166 os: &'static str,
167 /// `std::env::consts::ARCH` for the offending build.
168 arch: &'static str,
169 },
170
171 /// Both halves are documented, but not together: ARM32 is Linux-only.
172 #[error(
173 "GitHub documents ARM32 self-hosted runners on Linux only, so {} on {} is not a \
174 supported combination; use an x64 or ARM64 build of {} instead",
175 arch_name(*arch),
176 os_name(*os),
177 os_name(*os)
178 )]
179 UndocumentedPair {
180 /// The host operating system.
181 os: Os,
182 /// The host architecture.
183 arch: Arch,
184 },
185}
186
187/// How firmly GitHub supports a documented pair.
188#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
189#[serde(rename_all = "snake_case")]
190pub enum SupportStatus {
191 /// Documented without qualification.
192 GenerallyAvailable,
193 /// Documented as public preview. Accepted, with a warning.
194 PublicPreview,
195}
196
197impl SupportStatus {
198 /// Derived from [`Arch::is_public_preview`] rather than decided again here,
199 /// so `f2` cannot get one answer from the domain and another from this
200 /// module.
201 #[must_use]
202 pub const fn of(arch: Arch) -> Self {
203 if arch.is_public_preview() {
204 Self::PublicPreview
205 } else {
206 Self::GenerallyAvailable
207 }
208 }
209}
210
211/// Something an operator should be told about an accepted host.
212#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
213#[serde(rename_all = "snake_case")]
214pub enum SupportWarning {
215 /// ARM64 self-hosted runners are a GitHub public preview.
216 Arm64PublicPreview,
217}
218
219impl SupportWarning {
220 /// Operator-facing text. Actionable rather than merely descriptive: it says
221 /// what the operator may observe, not just that a label applies.
222 #[must_use]
223 pub const fn message(self) -> &'static str {
224 match self {
225 Self::Arm64PublicPreview => {
226 "ARM64 self-hosted runners are a GitHub public preview. Runners will \
227 register and run jobs, but GitHub may change or withdraw ARM64 support \
228 without the notice a generally available platform gets, and some actions \
229 publish no ARM64 build."
230 }
231 }
232 }
233}
234
235impl fmt::Display for SupportWarning {
236 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
237 f.write_str(self.message())
238 }
239}
240
241/// Whether this host can run container actions and service containers.
242///
243/// `01-current-architecture.md`, edge case 2: a host does not gain them merely
244/// because Docker is installed; GitHub's reference requires Linux.
245#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
246#[serde(rename_all = "snake_case")]
247pub enum ContainerSupport {
248 /// Container actions and service containers are available.
249 Available,
250 /// They are unavailable on this operating system, whatever is installed.
251 RequiresLinux,
252}
253
254impl ContainerSupport {
255 /// Derived from [`Os::supports_container_actions`]. This type adds the
256 /// operator-facing explanation; it does not re-decide the question.
257 #[must_use]
258 pub const fn of(os: Os) -> Self {
259 if os.supports_container_actions() {
260 Self::Available
261 } else {
262 Self::RequiresLinux
263 }
264 }
265
266 /// Whether container workflow features work on this host.
267 #[must_use]
268 pub const fn is_available(self) -> bool {
269 matches!(self, Self::Available)
270 }
271
272 /// Operator-facing text for the limitation, or `None` when there is none.
273 #[must_use]
274 pub const fn message(self) -> Option<&'static str> {
275 match self {
276 Self::Available => None,
277 Self::RequiresLinux => Some(
278 "Container actions and service containers require a Linux runner. This host \
279 cannot run them even with Docker installed, so a workflow that uses \
280 `container:` or `services:` will fail on it.",
281 ),
282 }
283 }
284}
285
286/// One documented operating system release, with the oldest version GitHub
287/// documents for it.
288#[derive(Debug, Clone, Copy, PartialEq, Eq)]
289pub struct DocumentedRelease {
290 /// The release or distribution name, as GitHub's documentation writes it.
291 pub name: &'static str,
292 /// The oldest documented version, or `None` when GitHub names the release
293 /// without a version floor.
294 pub minimum_version: Option<&'static str>,
295}
296
297impl fmt::Display for DocumentedRelease {
298 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
299 match self.minimum_version {
300 Some(version) => write!(f, "{} {version}+", self.name),
301 None => f.write_str(self.name),
302 }
303 }
304}
305
306const fn release(name: &'static str, minimum_version: Option<&'static str>) -> DocumentedRelease {
307 DocumentedRelease {
308 name,
309 minimum_version,
310 }
311}
312
313const WINDOWS_RELEASES: &[DocumentedRelease] = &[
314 release("Windows 10", None),
315 release("Windows 11", None),
316 release("Windows Server 2016", None),
317 release("Windows Server 2019", None),
318 release("Windows Server 2022", None),
319];
320
321const MACOS_RELEASES: &[DocumentedRelease] = &[release("macOS", Some("11.0"))];
322
323/// The nine distributions `01-current-architecture.md` lists. RHEL, CentOS and
324/// Oracle share one "8+" floor in GitHub's documentation but are three
325/// separate distributions, which is how the count reaches nine.
326const LINUX_RELEASES: &[DocumentedRelease] = &[
327 release("Red Hat Enterprise Linux", Some("8")),
328 release("CentOS", Some("8")),
329 release("Oracle Linux", Some("8")),
330 release("Fedora", Some("29")),
331 release("Debian", Some("10")),
332 release("Ubuntu", Some("20.04")),
333 release("Linux Mint", Some("20")),
334 release("openSUSE", Some("15.2")),
335 release("SUSE Linux Enterprise Server", Some("15 SP2")),
336];
337
338/// The releases GitHub documents for one operating system.
339///
340/// Exposed as data so `f2` can render the list an operator is measured against
341/// without restating it, and so a future correction to the matrix lands in one
342/// place.
343#[must_use]
344pub const fn documented_releases(os: Os) -> &'static [DocumentedRelease] {
345 match os {
346 Os::Windows => WINDOWS_RELEASES,
347 Os::MacOs => MACOS_RELEASES,
348 Os::Linux => LINUX_RELEASES,
349 }
350}
351
352/// The verdict on one host.
353#[derive(Debug, Clone, PartialEq, Eq)]
354pub struct HostSupport {
355 os: Os,
356 arch: Arch,
357 status: SupportStatus,
358 warnings: Vec<SupportWarning>,
359 container_support: ContainerSupport,
360}
361
362impl HostSupport {
363 /// The operating system this verdict is about.
364 #[must_use]
365 pub const fn os(&self) -> Os {
366 self.os
367 }
368
369 /// The architecture this verdict is about.
370 #[must_use]
371 pub const fn arch(&self) -> Arch {
372 self.arch
373 }
374
375 /// Whether GitHub documents the pair without qualification.
376 #[must_use]
377 pub const fn status(&self) -> SupportStatus {
378 self.status
379 }
380
381 /// Everything an operator should be told. Empty for a generally available
382 /// pair.
383 #[must_use]
384 pub fn warnings(&self) -> &[SupportWarning] {
385 &self.warnings
386 }
387
388 /// Whether container actions and service containers work here.
389 #[must_use]
390 pub const fn container_support(&self) -> ContainerSupport {
391 self.container_support
392 }
393
394 /// The releases GitHub documents for this host's operating system.
395 #[must_use]
396 pub const fn documented_releases(&self) -> &'static [DocumentedRelease] {
397 documented_releases(self.os)
398 }
399}
400
401impl fmt::Display for HostSupport {
402 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
403 write!(f, "{} on {}", arch_name(self.arch), os_name(self.os))
404 }
405}
406
407/// Classifies a host against GitHub's documented matrix.
408///
409/// # Errors
410///
411/// [`UnsupportedHost::UndocumentedPair`] when both halves are documented but
412/// not together, which today means ARM32 anywhere other than Linux.
413pub fn validate(os: Os, arch: Arch) -> Result<HostSupport, UnsupportedHost> {
414 // Exhaustive on purpose: see the module documentation. A new `Os` or `Arch`
415 // variant must fail to compile here rather than fall through to an accept
416 // or a reject nobody chose.
417 //
418 // This match decides *only* which pairs are documented, which is the part
419 // the domain explicitly delegates. How firmly a documented pair is
420 // supported, and whether it runs containers, are the domain's own
421 // predicates and are read from there below.
422 match (os, arch) {
423 (Os::Windows | Os::MacOs | Os::Linux, Arch::X64 | Arch::Arm64)
424 | (Os::Linux, Arch::Arm32) => {}
425
426 (Os::Windows | Os::MacOs, Arch::Arm32) => {
427 return Err(UnsupportedHost::UndocumentedPair { os, arch });
428 }
429 }
430
431 let status = SupportStatus::of(arch);
432
433 // Matched on `arch`, not on `status`. The *verdict* is single-sourced from
434 // `Arch::is_public_preview` and stays that way -- `status` still gates the
435 // arms -- but the warning *text* names an architecture, and choosing it by
436 // status re-encoded which architecture that is. A second preview
437 // architecture would have been handed ARM64's message with no compile
438 // error: the one place this module's "adding a variant is a compile error"
439 // claim did not hold.
440 //
441 // Matching the pair means a new `Arch` variant fails to compile here until
442 // someone decides what it is owed, which for a preview architecture is a
443 // `SupportWarning` variant of its own.
444 let warnings = match (status, arch) {
445 (SupportStatus::GenerallyAvailable, _) => Vec::new(),
446 (SupportStatus::PublicPreview, Arch::Arm64) => vec![SupportWarning::Arm64PublicPreview],
447 // Unreachable while ARM64 is the only preview architecture, and it is
448 // `Arch::is_public_preview` that decides that, not this match.
449 (SupportStatus::PublicPreview, Arch::X64 | Arch::Arm32) => Vec::new(),
450 };
451
452 Ok(HostSupport {
453 os,
454 arch,
455 status,
456 warnings,
457 container_support: ContainerSupport::of(os),
458 })
459}
460
461/// Classifies the host this binary is running on.
462///
463/// # Errors
464///
465/// Both variants of [`UnsupportedHost`]; see [`detect_host`] and [`validate`].
466pub fn detect() -> Result<HostSupport, UnsupportedHost> {
467 let (os, arch) = detect_host()?;
468 validate(os, arch)
469}
470
471// ---------------------------------------------------------------------------
472// Privacy consent
473// ---------------------------------------------------------------------------
474
475/// The macOS settings pane that grants a program Full Disk Access.
476///
477/// A URL rather than a scripted click: `x-apple.systempreferences:` is the
478/// documented way to open one pane of System Settings, it needs no automation
479/// permission of its own, and it lands the operator on the exact list they have
480/// to add the program to.
481///
482/// It lives here rather than beside the command that opens it because it is an
483/// operating-system constant, and this crate is where those are kept. Deciding
484/// *whether* to open anything stays with the caller, which already has one
485/// policy for that.
486pub const FULL_DISK_ACCESS_SETTINGS_URL: &str =
487 "x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles";
488
489#[cfg(test)]
490mod tests {
491 use super::*;
492
493 /// The matrix, written out here from the evidence line in
494 /// `01-current-architecture.md` rather than read back from the module under
495 /// test. Asserting the classifier against its own table would prove
496 /// nothing; this list is the independent copy that makes the assertions
497 /// mean something.
498 const DOCUMENTED: &[(Os, Arch)] = &[
499 (Os::Windows, Arch::X64),
500 (Os::MacOs, Arch::X64),
501 (Os::Linux, Arch::X64),
502 (Os::Windows, Arch::Arm64),
503 (Os::MacOs, Arch::Arm64),
504 (Os::Linux, Arch::Arm64),
505 (Os::Linux, Arch::Arm32),
506 ];
507
508 const UNDOCUMENTED: &[(Os, Arch)] = &[(Os::Windows, Arch::Arm32), (Os::MacOs, Arch::Arm32)];
509
510 /// The DoD clause "accepts every documented pair, rejects an undocumented
511 /// pair", expressed once so that it can be pointed at a deliberately broken
512 /// classifier as well as at the real one.
513 ///
514 /// Returns `Err` with the first disagreement rather than panicking, which
515 /// is what lets `the_matrix_assertions_catch_a_classifier_that_accepts_everything`
516 /// below prove these assertions are not vacuous.
517 fn check_matrix(
518 classify: impl Fn(Os, Arch) -> Result<HostSupport, UnsupportedHost>,
519 ) -> Result<(), String> {
520 for &(os, arch) in DOCUMENTED {
521 if classify(os, arch).is_err() {
522 return Err(format!(
523 "{}/{} is documented but was rejected",
524 os.label_token(),
525 arch.label_token()
526 ));
527 }
528 }
529 for &(os, arch) in UNDOCUMENTED {
530 if classify(os, arch).is_ok() {
531 return Err(format!(
532 "{}/{} is undocumented but was accepted",
533 os.label_token(),
534 arch.label_token()
535 ));
536 }
537 }
538 Ok(())
539 }
540
541 #[test]
542 fn documented_pairs_are_accepted_and_undocumented_pairs_are_rejected() {
543 check_matrix(validate).expect("the documented matrix must classify exactly");
544 }
545
546 #[test]
547 fn the_matrix_assertions_catch_a_classifier_that_accepts_everything() {
548 // The violation the DoD cares about is a matrix that has quietly become
549 // permissive. If `check_matrix` cannot see that, the test above is
550 // decoration, so point it at a classifier that is wrong in exactly that
551 // way and require it to complain.
552 let permissive = |os, arch| {
553 Ok(HostSupport {
554 os,
555 arch,
556 status: SupportStatus::GenerallyAvailable,
557 warnings: Vec::new(),
558 container_support: ContainerSupport::Available,
559 })
560 };
561
562 let complaint =
563 check_matrix(permissive).expect_err("a permissive classifier must be caught");
564 assert!(
565 complaint.contains("undocumented but was accepted"),
566 "the complaint must name the failure mode, got: {complaint}"
567 );
568 }
569
570 #[test]
571 fn the_matrix_assertions_catch_a_classifier_that_rejects_everything() {
572 let hostile = |os, arch| Err(UnsupportedHost::UndocumentedPair { os, arch });
573
574 let complaint = check_matrix(hostile).expect_err("a hostile classifier must be caught");
575 assert!(
576 complaint.contains("documented but was rejected"),
577 "the complaint must name the failure mode, got: {complaint}"
578 );
579 }
580
581 #[test]
582 fn every_pair_is_classified_and_the_two_sets_do_not_overlap() {
583 // Guards against a pair being forgotten by both lists above as the
584 // matrix changes: the cross product must be partitioned exactly.
585 //
586 // `Os::ALL` and `Arch::ALL` come from the domain, so a variant added
587 // there is covered here without an edit — which was one of the reasons
588 // for stopping keeping a second pair of enums in this file.
589 let mut seen = Vec::new();
590 for &os in &Os::ALL {
591 for &arch in &Arch::ALL {
592 let pair = (os, arch);
593 let documented = DOCUMENTED.contains(&pair);
594 let undocumented = UNDOCUMENTED.contains(&pair);
595 assert!(
596 documented ^ undocumented,
597 "{}/{} must appear in exactly one of the two test tables",
598 os.label_token(),
599 arch.label_token()
600 );
601 seen.push(pair);
602 }
603 }
604 assert_eq!(seen.len(), DOCUMENTED.len() + UNDOCUMENTED.len());
605 }
606
607 #[test]
608 fn arm64_is_accepted_with_a_public_preview_warning_on_all_three_systems() {
609 for &os in &Os::ALL {
610 let support = validate(os, Arch::Arm64)
611 .expect("ARM64 must be accepted, not rejected: the persona's host is ARM64");
612
613 assert_eq!(
614 support.status(),
615 SupportStatus::PublicPreview,
616 "on {}",
617 os_name(os)
618 );
619 assert_eq!(
620 support.warnings(),
621 [SupportWarning::Arm64PublicPreview],
622 "on {}",
623 os_name(os)
624 );
625 assert!(
626 support.warnings()[0].message().contains("public preview"),
627 "the warning must say what it is warning about"
628 );
629 }
630 }
631
632 #[test]
633 fn generally_available_pairs_carry_no_warning() {
634 for &(os, arch) in DOCUMENTED {
635 if arch == Arch::Arm64 {
636 continue;
637 }
638 let support = validate(os, arch).expect("documented");
639 assert_eq!(support.status(), SupportStatus::GenerallyAvailable);
640 assert!(
641 support.warnings().is_empty(),
642 "{}/{} is generally available and must not warn",
643 os.label_token(),
644 arch.label_token()
645 );
646 }
647 }
648
649 #[test]
650 fn container_actions_are_reported_as_linux_only() {
651 let linux = validate(Os::Linux, Arch::X64).expect("documented");
652 assert_eq!(linux.container_support(), ContainerSupport::Available);
653 assert!(linux.container_support().is_available());
654 assert!(linux.container_support().message().is_none());
655
656 for os in [Os::Windows, Os::MacOs] {
657 let support = validate(os, Arch::X64).expect("documented");
658 assert_eq!(
659 support.container_support(),
660 ContainerSupport::RequiresLinux,
661 "{} must report the container limitation so f2 can surface it",
662 os_name(os)
663 );
664 assert!(!support.container_support().is_available());
665
666 let message = support
667 .container_support()
668 .message()
669 .expect("the limitation must carry operator-facing text");
670 // Edge case 2's whole point is that Docker does not lift it, so the
671 // message must say so or an operator will install Docker and retry.
672 assert!(message.contains("Docker"), "on {}: {message}", os_name(os));
673 assert!(message.contains("Linux"), "on {}: {message}", os_name(os));
674 }
675 }
676
677 /// The single-source property the `HostOs`/`HostArch` deletion bought.
678 ///
679 /// `f2` may read the verdict from this module or from the domain predicate,
680 /// and the two must agree for every host — not because both tables were
681 /// updated together, but because there is only one table.
682 #[test]
683 fn the_verdicts_agree_with_the_domain_predicates_they_are_derived_from() {
684 for &(os, arch) in DOCUMENTED {
685 let support = validate(os, arch).expect("documented");
686
687 assert_eq!(
688 support.container_support().is_available(),
689 os.supports_container_actions(),
690 "container support disagrees with the domain for {}",
691 os_name(os)
692 );
693 assert_eq!(
694 support.status() == SupportStatus::PublicPreview,
695 arch.is_public_preview(),
696 "preview status disagrees with the domain for {}",
697 arch_name(arch)
698 );
699 assert_eq!(
700 support.warnings().is_empty(),
701 !arch.is_public_preview(),
702 "the warning list disagrees with the domain for {}",
703 arch_name(arch)
704 );
705 }
706 }
707
708 /// Prose names and routing tokens are different things, deliberately.
709 ///
710 /// The trap this guards is an edit that reaches for `label_token()` in an
711 /// operator-facing message, which would render "GitHub documents ARM32
712 /// runners on win only". The tokens belong to runner-package selection and
713 /// nowhere else.
714 #[test]
715 fn prose_names_are_not_routing_tokens() {
716 assert_eq!(os_name(Os::Windows), "Windows");
717 assert_eq!(Os::Windows.label_token(), "win");
718 assert_eq!(os_name(Os::MacOs), "macOS");
719 assert_eq!(Os::MacOs.label_token(), "osx");
720 assert_eq!(arch_name(Arch::Arm32), "ARM32");
721 assert_eq!(Arch::Arm32.label_token(), "arm");
722
723 // Linux and x64 are spelled the same either way, which is exactly why
724 // the other four are worth pinning: a partial overlap is what let two
725 // spellings drift without anything failing.
726 assert_eq!(os_name(Os::Linux), "Linux");
727 assert_eq!(arch_name(Arch::X64), "x64");
728 }
729
730 #[test]
731 fn an_undocumented_pair_says_which_pair_and_why() {
732 let error =
733 validate(Os::Windows, Arch::Arm32).expect_err("ARM32 is documented on Linux only");
734
735 assert_eq!(
736 error,
737 UnsupportedHost::UndocumentedPair {
738 os: Os::Windows,
739 arch: Arch::Arm32,
740 }
741 );
742
743 // Prose, not tokens: an operator should not have to know that `win`
744 // means Windows.
745 let rendered = error.to_string();
746 assert!(rendered.contains("Windows"), "{rendered}");
747 assert!(rendered.contains("ARM32"), "{rendered}");
748 assert!(rendered.contains("Linux only"), "{rendered}");
749 }
750
751 #[test]
752 fn the_documented_release_lists_match_the_evidence_line() {
753 // Five Windows releases, macOS with an 11.0 floor, and nine Linux
754 // distributions. The counts are asserted because a distribution
755 // dropped by an edit is otherwise invisible.
756 assert_eq!(documented_releases(Os::Windows).len(), 5);
757 assert_eq!(documented_releases(Os::MacOs).len(), 1);
758 assert_eq!(
759 documented_releases(Os::Linux).len(),
760 9,
761 "`01-current-architecture.md` names nine Linux distributions"
762 );
763
764 assert_eq!(
765 documented_releases(Os::MacOs)[0].minimum_version,
766 Some("11.0"),
767 "macOS 11.0 (Big Sur) is the documented floor"
768 );
769 assert_eq!(documented_releases(Os::MacOs)[0].to_string(), "macOS 11.0+");
770
771 for release in documented_releases(Os::Windows) {
772 assert!(
773 release.name.starts_with("Windows"),
774 "unexpected Windows release: {release}"
775 );
776 }
777
778 let linux: Vec<String> = documented_releases(Os::Linux)
779 .iter()
780 .map(ToString::to_string)
781 .collect();
782 for expected in [
783 "Red Hat Enterprise Linux 8+",
784 "CentOS 8+",
785 "Oracle Linux 8+",
786 "Fedora 29+",
787 "Debian 10+",
788 "Ubuntu 20.04+",
789 "Linux Mint 20+",
790 "openSUSE 15.2+",
791 "SUSE Linux Enterprise Server 15 SP2+",
792 ] {
793 assert!(
794 linux.iter().any(|found| found == expected),
795 "missing documented distribution {expected}; found {linux:?}"
796 );
797 }
798 }
799
800 /// Runs natively on each leg of the CI matrix, which is the only place the
801 /// three answers can actually differ.
802 #[test]
803 fn this_host_is_a_documented_pair() {
804 let support = detect().expect("every CI leg and every supported host must classify");
805
806 assert_eq!(
807 (support.os(), support.arch()),
808 detect_host().expect("detection agrees with itself")
809 );
810 assert!(
811 DOCUMENTED.contains(&(support.os(), support.arch())),
812 "detected {support} is not in the documented matrix"
813 );
814
815 // The macOS CI leg is Apple Silicon by design (`ci.yml` asserts
816 // `uname -m` is arm64), so on that leg this is the public-preview path
817 // running for real rather than as a constructed pair.
818 if support.arch() == Arch::Arm64 {
819 assert_eq!(support.status(), SupportStatus::PublicPreview);
820 assert!(!support.warnings().is_empty());
821 }
822 }
823
824 /// A host built from this module's answer is a host the domain accepts.
825 ///
826 /// The `HostOs`/`HostArch` version could not state this at all: the caller
827 /// had to bridge between two enums, and `platform::os::Host` even
828 /// serialised its architecture under a different field name (`arch`) than
829 /// the domain's `Host` (`architecture`). There is now nothing to bridge.
830 #[test]
831 fn a_detected_host_feeds_the_domain_directly() {
832 use std::num::NonZeroU16;
833
834 use runner_manager_domain::model::{Host, HostId};
835
836 let support = detect().expect("this host classifies");
837 let host = Host::new(
838 HostId::from_u128(1),
839 "the machine this test is running on",
840 support.os(),
841 support.arch(),
842 NonZeroU16::new(4).expect("non-zero"),
843 chrono::Utc::now(),
844 )
845 .expect("a named host is valid");
846
847 assert_eq!(host.os, support.os());
848 assert_eq!(host.architecture, support.arch());
849 }
850}