Skip to main content

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}