use std::fmt;
use runner_manager_domain::model::{Arch, Os};
use serde::{Deserialize, Serialize};
#[must_use]
pub const fn os_name(os: Os) -> &'static str {
match os {
Os::Windows => "Windows",
Os::MacOs => "macOS",
Os::Linux => "Linux",
}
}
#[must_use]
pub const fn arch_name(arch: Arch) -> &'static str {
match arch {
Arch::X64 => "x64",
Arch::Arm64 => "ARM64",
Arch::Arm32 => "ARM32",
}
}
#[must_use]
pub const fn detect_os() -> Option<Os> {
if cfg!(target_os = "windows") {
Some(Os::Windows)
} else if cfg!(target_os = "macos") {
Some(Os::MacOs)
} else if cfg!(target_os = "linux") {
Some(Os::Linux)
} else {
None
}
}
#[must_use]
pub const fn detect_arch() -> Option<Arch> {
if cfg!(target_arch = "x86_64") {
Some(Arch::X64)
} else if cfg!(target_arch = "aarch64") {
Some(Arch::Arm64)
} else if cfg!(target_arch = "arm") {
Some(Arch::Arm32)
} else {
None
}
}
pub const fn detect_host() -> Result<(Os, Arch), UnsupportedHost> {
match (detect_os(), detect_arch()) {
(Some(os), Some(arch)) => Ok((os, arch)),
_ => Err(UnsupportedHost::UndocumentedPlatform {
os: std::env::consts::OS,
arch: std::env::consts::ARCH,
}),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
pub enum UnsupportedHost {
#[error(
"runner-manager is built for Windows, macOS, and Linux on x64, ARM64, or ARM32, \
but this binary targets {os}/{arch}, which GitHub does not document as a \
self-hosted runner platform"
)]
UndocumentedPlatform {
os: &'static str,
arch: &'static str,
},
#[error(
"GitHub documents ARM32 self-hosted runners on Linux only, so {} on {} is not a \
supported combination; use an x64 or ARM64 build of {} instead",
arch_name(*arch),
os_name(*os),
os_name(*os)
)]
UndocumentedPair {
os: Os,
arch: Arch,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SupportStatus {
GenerallyAvailable,
PublicPreview,
}
impl SupportStatus {
#[must_use]
pub const fn of(arch: Arch) -> Self {
if arch.is_public_preview() {
Self::PublicPreview
} else {
Self::GenerallyAvailable
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SupportWarning {
Arm64PublicPreview,
}
impl SupportWarning {
#[must_use]
pub const fn message(self) -> &'static str {
match self {
Self::Arm64PublicPreview => {
"ARM64 self-hosted runners are a GitHub public preview. Runners will \
register and run jobs, but GitHub may change or withdraw ARM64 support \
without the notice a generally available platform gets, and some actions \
publish no ARM64 build."
}
}
}
}
impl fmt::Display for SupportWarning {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.message())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ContainerSupport {
Available,
RequiresLinux,
}
impl ContainerSupport {
#[must_use]
pub const fn of(os: Os) -> Self {
if os.supports_container_actions() {
Self::Available
} else {
Self::RequiresLinux
}
}
#[must_use]
pub const fn is_available(self) -> bool {
matches!(self, Self::Available)
}
#[must_use]
pub const fn message(self) -> Option<&'static str> {
match self {
Self::Available => None,
Self::RequiresLinux => Some(
"Container actions and service containers require a Linux runner. This host \
cannot run them even with Docker installed, so a workflow that uses \
`container:` or `services:` will fail on it.",
),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct DocumentedRelease {
pub name: &'static str,
pub minimum_version: Option<&'static str>,
}
impl fmt::Display for DocumentedRelease {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self.minimum_version {
Some(version) => write!(f, "{} {version}+", self.name),
None => f.write_str(self.name),
}
}
}
const fn release(name: &'static str, minimum_version: Option<&'static str>) -> DocumentedRelease {
DocumentedRelease {
name,
minimum_version,
}
}
const WINDOWS_RELEASES: &[DocumentedRelease] = &[
release("Windows 10", None),
release("Windows 11", None),
release("Windows Server 2016", None),
release("Windows Server 2019", None),
release("Windows Server 2022", None),
];
const MACOS_RELEASES: &[DocumentedRelease] = &[release("macOS", Some("11.0"))];
const LINUX_RELEASES: &[DocumentedRelease] = &[
release("Red Hat Enterprise Linux", Some("8")),
release("CentOS", Some("8")),
release("Oracle Linux", Some("8")),
release("Fedora", Some("29")),
release("Debian", Some("10")),
release("Ubuntu", Some("20.04")),
release("Linux Mint", Some("20")),
release("openSUSE", Some("15.2")),
release("SUSE Linux Enterprise Server", Some("15 SP2")),
];
#[must_use]
pub const fn documented_releases(os: Os) -> &'static [DocumentedRelease] {
match os {
Os::Windows => WINDOWS_RELEASES,
Os::MacOs => MACOS_RELEASES,
Os::Linux => LINUX_RELEASES,
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HostSupport {
os: Os,
arch: Arch,
status: SupportStatus,
warnings: Vec<SupportWarning>,
container_support: ContainerSupport,
}
impl HostSupport {
#[must_use]
pub const fn os(&self) -> Os {
self.os
}
#[must_use]
pub const fn arch(&self) -> Arch {
self.arch
}
#[must_use]
pub const fn status(&self) -> SupportStatus {
self.status
}
#[must_use]
pub fn warnings(&self) -> &[SupportWarning] {
&self.warnings
}
#[must_use]
pub const fn container_support(&self) -> ContainerSupport {
self.container_support
}
#[must_use]
pub const fn documented_releases(&self) -> &'static [DocumentedRelease] {
documented_releases(self.os)
}
}
impl fmt::Display for HostSupport {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{} on {}", arch_name(self.arch), os_name(self.os))
}
}
pub fn validate(os: Os, arch: Arch) -> Result<HostSupport, UnsupportedHost> {
match (os, arch) {
(Os::Windows | Os::MacOs | Os::Linux, Arch::X64 | Arch::Arm64)
| (Os::Linux, Arch::Arm32) => {}
(Os::Windows | Os::MacOs, Arch::Arm32) => {
return Err(UnsupportedHost::UndocumentedPair { os, arch });
}
}
let status = SupportStatus::of(arch);
let warnings = match (status, arch) {
(SupportStatus::GenerallyAvailable, _) => Vec::new(),
(SupportStatus::PublicPreview, Arch::Arm64) => vec![SupportWarning::Arm64PublicPreview],
(SupportStatus::PublicPreview, Arch::X64 | Arch::Arm32) => Vec::new(),
};
Ok(HostSupport {
os,
arch,
status,
warnings,
container_support: ContainerSupport::of(os),
})
}
pub fn detect() -> Result<HostSupport, UnsupportedHost> {
let (os, arch) = detect_host()?;
validate(os, arch)
}
pub const FULL_DISK_ACCESS_SETTINGS_URL: &str =
"x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles";
#[cfg(test)]
mod tests {
use super::*;
const DOCUMENTED: &[(Os, Arch)] = &[
(Os::Windows, Arch::X64),
(Os::MacOs, Arch::X64),
(Os::Linux, Arch::X64),
(Os::Windows, Arch::Arm64),
(Os::MacOs, Arch::Arm64),
(Os::Linux, Arch::Arm64),
(Os::Linux, Arch::Arm32),
];
const UNDOCUMENTED: &[(Os, Arch)] = &[(Os::Windows, Arch::Arm32), (Os::MacOs, Arch::Arm32)];
fn check_matrix(
classify: impl Fn(Os, Arch) -> Result<HostSupport, UnsupportedHost>,
) -> Result<(), String> {
for &(os, arch) in DOCUMENTED {
if classify(os, arch).is_err() {
return Err(format!(
"{}/{} is documented but was rejected",
os.label_token(),
arch.label_token()
));
}
}
for &(os, arch) in UNDOCUMENTED {
if classify(os, arch).is_ok() {
return Err(format!(
"{}/{} is undocumented but was accepted",
os.label_token(),
arch.label_token()
));
}
}
Ok(())
}
#[test]
fn documented_pairs_are_accepted_and_undocumented_pairs_are_rejected() {
check_matrix(validate).expect("the documented matrix must classify exactly");
}
#[test]
fn the_matrix_assertions_catch_a_classifier_that_accepts_everything() {
let permissive = |os, arch| {
Ok(HostSupport {
os,
arch,
status: SupportStatus::GenerallyAvailable,
warnings: Vec::new(),
container_support: ContainerSupport::Available,
})
};
let complaint =
check_matrix(permissive).expect_err("a permissive classifier must be caught");
assert!(
complaint.contains("undocumented but was accepted"),
"the complaint must name the failure mode, got: {complaint}"
);
}
#[test]
fn the_matrix_assertions_catch_a_classifier_that_rejects_everything() {
let hostile = |os, arch| Err(UnsupportedHost::UndocumentedPair { os, arch });
let complaint = check_matrix(hostile).expect_err("a hostile classifier must be caught");
assert!(
complaint.contains("documented but was rejected"),
"the complaint must name the failure mode, got: {complaint}"
);
}
#[test]
fn every_pair_is_classified_and_the_two_sets_do_not_overlap() {
let mut seen = Vec::new();
for &os in &Os::ALL {
for &arch in &Arch::ALL {
let pair = (os, arch);
let documented = DOCUMENTED.contains(&pair);
let undocumented = UNDOCUMENTED.contains(&pair);
assert!(
documented ^ undocumented,
"{}/{} must appear in exactly one of the two test tables",
os.label_token(),
arch.label_token()
);
seen.push(pair);
}
}
assert_eq!(seen.len(), DOCUMENTED.len() + UNDOCUMENTED.len());
}
#[test]
fn arm64_is_accepted_with_a_public_preview_warning_on_all_three_systems() {
for &os in &Os::ALL {
let support = validate(os, Arch::Arm64)
.expect("ARM64 must be accepted, not rejected: the persona's host is ARM64");
assert_eq!(
support.status(),
SupportStatus::PublicPreview,
"on {}",
os_name(os)
);
assert_eq!(
support.warnings(),
[SupportWarning::Arm64PublicPreview],
"on {}",
os_name(os)
);
assert!(
support.warnings()[0].message().contains("public preview"),
"the warning must say what it is warning about"
);
}
}
#[test]
fn generally_available_pairs_carry_no_warning() {
for &(os, arch) in DOCUMENTED {
if arch == Arch::Arm64 {
continue;
}
let support = validate(os, arch).expect("documented");
assert_eq!(support.status(), SupportStatus::GenerallyAvailable);
assert!(
support.warnings().is_empty(),
"{}/{} is generally available and must not warn",
os.label_token(),
arch.label_token()
);
}
}
#[test]
fn container_actions_are_reported_as_linux_only() {
let linux = validate(Os::Linux, Arch::X64).expect("documented");
assert_eq!(linux.container_support(), ContainerSupport::Available);
assert!(linux.container_support().is_available());
assert!(linux.container_support().message().is_none());
for os in [Os::Windows, Os::MacOs] {
let support = validate(os, Arch::X64).expect("documented");
assert_eq!(
support.container_support(),
ContainerSupport::RequiresLinux,
"{} must report the container limitation so f2 can surface it",
os_name(os)
);
assert!(!support.container_support().is_available());
let message = support
.container_support()
.message()
.expect("the limitation must carry operator-facing text");
assert!(message.contains("Docker"), "on {}: {message}", os_name(os));
assert!(message.contains("Linux"), "on {}: {message}", os_name(os));
}
}
#[test]
fn the_verdicts_agree_with_the_domain_predicates_they_are_derived_from() {
for &(os, arch) in DOCUMENTED {
let support = validate(os, arch).expect("documented");
assert_eq!(
support.container_support().is_available(),
os.supports_container_actions(),
"container support disagrees with the domain for {}",
os_name(os)
);
assert_eq!(
support.status() == SupportStatus::PublicPreview,
arch.is_public_preview(),
"preview status disagrees with the domain for {}",
arch_name(arch)
);
assert_eq!(
support.warnings().is_empty(),
!arch.is_public_preview(),
"the warning list disagrees with the domain for {}",
arch_name(arch)
);
}
}
#[test]
fn prose_names_are_not_routing_tokens() {
assert_eq!(os_name(Os::Windows), "Windows");
assert_eq!(Os::Windows.label_token(), "win");
assert_eq!(os_name(Os::MacOs), "macOS");
assert_eq!(Os::MacOs.label_token(), "osx");
assert_eq!(arch_name(Arch::Arm32), "ARM32");
assert_eq!(Arch::Arm32.label_token(), "arm");
assert_eq!(os_name(Os::Linux), "Linux");
assert_eq!(arch_name(Arch::X64), "x64");
}
#[test]
fn an_undocumented_pair_says_which_pair_and_why() {
let error =
validate(Os::Windows, Arch::Arm32).expect_err("ARM32 is documented on Linux only");
assert_eq!(
error,
UnsupportedHost::UndocumentedPair {
os: Os::Windows,
arch: Arch::Arm32,
}
);
let rendered = error.to_string();
assert!(rendered.contains("Windows"), "{rendered}");
assert!(rendered.contains("ARM32"), "{rendered}");
assert!(rendered.contains("Linux only"), "{rendered}");
}
#[test]
fn the_documented_release_lists_match_the_evidence_line() {
assert_eq!(documented_releases(Os::Windows).len(), 5);
assert_eq!(documented_releases(Os::MacOs).len(), 1);
assert_eq!(
documented_releases(Os::Linux).len(),
9,
"`01-current-architecture.md` names nine Linux distributions"
);
assert_eq!(
documented_releases(Os::MacOs)[0].minimum_version,
Some("11.0"),
"macOS 11.0 (Big Sur) is the documented floor"
);
assert_eq!(documented_releases(Os::MacOs)[0].to_string(), "macOS 11.0+");
for release in documented_releases(Os::Windows) {
assert!(
release.name.starts_with("Windows"),
"unexpected Windows release: {release}"
);
}
let linux: Vec<String> = documented_releases(Os::Linux)
.iter()
.map(ToString::to_string)
.collect();
for expected in [
"Red Hat Enterprise Linux 8+",
"CentOS 8+",
"Oracle Linux 8+",
"Fedora 29+",
"Debian 10+",
"Ubuntu 20.04+",
"Linux Mint 20+",
"openSUSE 15.2+",
"SUSE Linux Enterprise Server 15 SP2+",
] {
assert!(
linux.iter().any(|found| found == expected),
"missing documented distribution {expected}; found {linux:?}"
);
}
}
#[test]
fn this_host_is_a_documented_pair() {
let support = detect().expect("every CI leg and every supported host must classify");
assert_eq!(
(support.os(), support.arch()),
detect_host().expect("detection agrees with itself")
);
assert!(
DOCUMENTED.contains(&(support.os(), support.arch())),
"detected {support} is not in the documented matrix"
);
if support.arch() == Arch::Arm64 {
assert_eq!(support.status(), SupportStatus::PublicPreview);
assert!(!support.warnings().is_empty());
}
}
#[test]
fn a_detected_host_feeds_the_domain_directly() {
use std::num::NonZeroU16;
use runner_manager_domain::model::{Host, HostId};
let support = detect().expect("this host classifies");
let host = Host::new(
HostId::from_u128(1),
"the machine this test is running on",
support.os(),
support.arch(),
NonZeroU16::new(4).expect("non-zero"),
chrono::Utc::now(),
)
.expect("a named host is valid");
assert_eq!(host.os, support.os());
assert_eq!(host.architecture, support.arch());
}
}