From cc3e6f007cf99d5c9e2213e2af8d0daedee3d094 Mon Sep 17 00:00:00 2001 From: Luther Monson Date: Sun, 16 Aug 2026 19:09:59 -0700 Subject: [PATCH 1/2] ci: add self-hosted CI workflow and raise test coverage Add .github/workflows/ci.yml targeting the ephpm orgs --- .github/workflows/ci.yml | 60 +++++++++++++ src/config.rs | 92 ++++++++++++++++++++ src/deployer.rs | 182 +++++++++++++++++++++++++++++++++++++++ src/github.rs | 63 +++++++++++++- src/main.rs | 37 ++++++++ src/manifest.rs | 51 +++++++++++ src/secrets.rs | 57 ++++++++++++ 7 files changed, 539 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..36ed76c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,60 @@ +name: CI + +# switchboard is a small pure-Rust crate — no PHP, no SDK, no Docker. The +# gate is just: does it format, lint clean, and pass its tests. Runs on the +# ephpm org's self-hosted (ephemerd) fleet, same `[self-hosted, linux, x64]` +# label ephpm/ephpm's ci.yml uses so the fleet picks these jobs up. +on: + push: + branches: [main] + pull_request: + # `main` plus long-lived feature branches other PRs stack onto — without + # the glob a PR targeting e.g. feat/deploy-pipeline gets no checks at all. + branches: [main, "feat/**"] + +env: + CARGO_TERM_COLOR: always + +jobs: + fmt: + name: Format + runs-on: [self-hosted, linux, x64] + steps: + - uses: actions/checkout@v4 + # No rustfmt.toml in this repo — no nightly-only options (group_imports / + # imports_granularity), so stable rustfmt is sufficient and correct. + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt + - run: cargo fmt --all -- --check + + clippy: + name: Clippy + runs-on: [self-hosted, linux, x64] + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy + - uses: Swatinem/rust-cache@v2 + - run: cargo clippy --all-targets -- -D warnings + + test: + name: Test (linux-x64) + runs-on: [self-hosted, linux, x64] + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - run: cargo test + + msrv: + name: MSRV (cargo check) + runs-on: [self-hosted, linux, x64] + steps: + - uses: actions/checkout@v4 + # Pinned to the crate's declared rust-version (Cargo.toml). A build that + # needs a newer toolchain than we advertise is a bug in the manifest. + - uses: dtolnay/rust-toolchain@1.85 + - uses: Swatinem/rust-cache@v2 + - run: cargo check --all-targets diff --git a/src/config.rs b/src/config.rs index 3c15279..8735cd9 100644 --- a/src/config.rs +++ b/src/config.rs @@ -59,3 +59,95 @@ pub struct Config { #[arg(long, default_value_t = 2, env = "SWITCHBOARD_HEALTH_INTERVAL_SECS")] pub health_interval_secs: u64, } + +#[cfg(test)] +mod tests { + use super::*; + + /// The three flags with no default and no `Option` — a parse must supply + /// them or fail. Kept as a helper so each test states only what it varies. + const REQUIRED: &[&str] = &[ + "switchboard", + "--webhook-secret", + "s3cr3t", + "--app-key", + "/etc/switchboard/app.pem", + "--app-id", + "12345", + ]; + + fn parse(extra: &[&str]) -> Config { + let args = REQUIRED.iter().chain(extra.iter()); + Config::try_parse_from(args).expect("expected a valid config parse") + } + + #[test] + fn defaults_applied_when_only_required_given() { + let c = parse(&[]); + assert_eq!(c.listen, "0.0.0.0:9090"); + assert_eq!(c.sites_dir, PathBuf::from("/var/www/sites")); + assert_eq!(c.preview_domain, "preview.ephpm.dev"); + assert_eq!(c.composer, "composer"); + assert_eq!(c.health_timeout_secs, 60); + assert_eq!(c.health_interval_secs, 2); + // Optional-with-no-default stays None. + assert!(c.secrets_file.is_none()); + // Required values round-trip. + assert_eq!(c.webhook_secret, "s3cr3t"); + assert_eq!(c.app_key, PathBuf::from("/etc/switchboard/app.pem")); + assert_eq!(c.app_id, 12345); + } + + #[test] + fn explicit_flags_override_defaults() { + let c = parse(&[ + "--listen", + "127.0.0.1:1234", + "--sites-dir", + "/srv/previews", + "--preview-domain", + "pr.example.com", + "--composer", + "/usr/local/bin/composer", + "--secrets-file", + "/etc/switchboard/secrets.yaml", + "--health-timeout-secs", + "5", + "--health-interval-secs", + "1", + ]); + assert_eq!(c.listen, "127.0.0.1:1234"); + assert_eq!(c.sites_dir, PathBuf::from("/srv/previews")); + assert_eq!(c.preview_domain, "pr.example.com"); + assert_eq!(c.composer, "/usr/local/bin/composer"); + assert_eq!( + c.secrets_file, + Some(PathBuf::from("/etc/switchboard/secrets.yaml")) + ); + assert_eq!(c.health_timeout_secs, 5); + assert_eq!(c.health_interval_secs, 1); + } + + #[test] + fn missing_required_flag_is_an_error() { + // Drop --app-id (and its value) — parsing must fail rather than + // silently defaulting a security-relevant field. + let args = ["switchboard", "--webhook-secret", "x", "--app-key", "/k"]; + assert!(Config::try_parse_from(args).is_err()); + } + + #[test] + fn non_numeric_app_id_is_rejected() { + // app_id is a u64; a non-numeric value must fail parsing, not truncate. + let args = [ + "switchboard", + "--webhook-secret", + "x", + "--app-key", + "/k", + "--app-id", + "not-a-number", + ]; + assert!(Config::try_parse_from(args).is_err()); + } +} diff --git a/src/deployer.rs b/src/deployer.rs index 46bc375..ebd149e 100644 --- a/src/deployer.rs +++ b/src/deployer.rs @@ -607,6 +607,57 @@ mod tests { assert_eq!(detect_framework(dir.path()).await, Framework::Generic); } + #[tokio::test] + async fn detect_symfony_from_composer() { + let dir = tempfile::tempdir().unwrap(); + tokio::fs::write( + dir.path().join("composer.json"), + r#"{"require": {"symfony/framework-bundle": "^7.0"}}"#, + ) + .await + .unwrap(); + assert_eq!(detect_framework(dir.path()).await, Framework::Symfony); + } + + #[tokio::test] + async fn detect_drupal_from_composer() { + let dir = tempfile::tempdir().unwrap(); + tokio::fs::write( + dir.path().join("composer.json"), + r#"{"require": {"drupal/core": "^10.0"}}"#, + ) + .await + .unwrap(); + assert_eq!(detect_framework(dir.path()).await, Framework::Drupal); + } + + #[tokio::test] + async fn detect_wordpress_takes_precedence_over_composer() { + // A repo can carry both wp-config and a composer.json naming another + // framework; the wp-config check runs first and must win. + let dir = tempfile::tempdir().unwrap(); + tokio::fs::write(dir.path().join("wp-config.php"), " AppManifest { @@ -717,6 +768,52 @@ mod tests { assert!(php.contains("'K' => 'it\\'s a \\\\ backslash'")); } + #[test] + fn php_prepend_exports_all_three_superglobals() { + // The prepend must populate putenv + $_ENV + $_SERVER so both + // WordPress getenv() and Laravel env() see the values. + let mut env = BTreeMap::new(); + env.insert("APP_ENV".to_string(), "preview".to_string()); + let php = render_php_prepend(&env); + assert!(php.starts_with(" 'preview'")); + assert!(php.contains("putenv(")); + assert!(php.contains("$_ENV[")); + assert!(php.contains("$_SERVER[")); + } + + // ── dotenv rendering ──────────────────────────────────────────── + + #[test] + fn dotenv_quotes_and_escapes_values() { + let mut env = BTreeMap::new(); + env.insert("PLAIN".to_string(), "value".to_string()); + env.insert( + "TRICKY".to_string(), + "a \"quote\" and a \\ and\nnewline".to_string(), + ); + let out = render_dotenv(&env); + assert!(out.starts_with("# Generated by switchboard")); + // BTreeMap orders keys, so PLAIN precedes TRICKY deterministically. + assert!(out.contains("PLAIN=\"value\"")); + // Backslash, double-quote and newline are all escaped so a dotenv + // loader reads exactly one line per key. + assert!(out.contains("TRICKY=\"a \\\"quote\\\" and a \\\\ and\\nnewline\"")); + assert!( + !out.contains("newline\nnewline"), + "raw newline must not split the value across lines" + ); + } + + #[test] + fn dotenv_empty_env_is_just_the_header() { + let out = render_dotenv(&BTreeMap::new()); + assert_eq!( + out, + "# Generated by switchboard for the ePHPm preview. Do not commit.\n" + ); + } + // ── preview_url ───────────────────────────────────────────────── #[test] @@ -751,6 +848,37 @@ mod tests { ); } + #[test] + fn preview_url_non_8x_version_has_no_port() { + // A version that isn't "8." (e.g. a hypothetical 7.4 or a + // major-only "9") can't be mapped to the 808x port scheme, so it + // falls back to the default port-less https URL rather than emitting + // a bogus port. + assert_eq!( + preview_url("h.preview.ephpm.dev", Some("7.4")), + "https://h.preview.ephpm.dev" + ); + assert_eq!( + preview_url("h.preview.ephpm.dev", Some("9")), + "https://h.preview.ephpm.dev" + ); + // Non-numeric minor also falls back rather than panicking. + assert_eq!( + preview_url("h.preview.ephpm.dev", Some("8.x")), + "https://h.preview.ephpm.dev" + ); + } + + #[test] + fn preview_url_maps_arbitrary_8x_minor() { + // The port formula is 8080 + minor, so 8.6 → :8086 generalizes beyond + // the two currently-shipped older versions. + assert_eq!( + preview_url("h.preview.ephpm.dev", Some("8.6")), + "https://h.preview.ephpm.dev:8086" + ); + } + #[tokio::test] async fn health_disabled_when_timeout_zero() { let secrets = Secrets::default(); @@ -764,4 +892,58 @@ mod tests { }; assert!(!wait_healthy("https://example.invalid", "/", &ctx).await); } + + // ── teardown ──────────────────────────────────────────────────── + + fn teardown_event() -> PullRequestEvent { + serde_json::from_value(serde_json::json!({ + "action": "closed", + "number": 7, + "pull_request": { + "head": {"ref": "feature", "sha": "deadbeef", "repo": null}, + "base": {"ref": "main"}, + "merged": true + }, + "repository": { + "full_name": "ephpm/my-blog", + "clone_url": "https://github.com/ephpm/my-blog.git", + "name": "my-blog", + "owner": {"login": "ephpm"} + }, + "installation": null + })) + .unwrap() + } + + #[tokio::test] + async fn teardown_removes_the_site_dir() { + let sites = tempfile::tempdir().unwrap(); + let event = teardown_event(); + let host = event.preview_host("preview.ephpm.dev"); + let site_dir = sites.path().join(&host); + tokio::fs::create_dir_all(site_dir.join("wp-content")) + .await + .unwrap(); + assert!(site_dir.exists()); + + teardown_preview(&event, sites.path(), "preview.ephpm.dev") + .await + .unwrap(); + assert!( + !site_dir.exists(), + "teardown must remove the preview directory" + ); + } + + #[tokio::test] + async fn teardown_is_ok_when_already_absent() { + // Teardown of a never-deployed / already-removed preview is a no-op + // success, not an error — GitHub can send `closed` for a PR that never + // deployed. + let sites = tempfile::tempdir().unwrap(); + let event = teardown_event(); + teardown_preview(&event, sites.path(), "preview.ephpm.dev") + .await + .expect("absent preview teardown must succeed"); + } } diff --git a/src/github.rs b/src/github.rs index e4b645e..5d58726 100644 --- a/src/github.rs +++ b/src/github.rs @@ -55,9 +55,8 @@ impl GitHubClient { let pr_number = event.number; if let Some(comment_id) = self.find_existing_comment(owner, repo, pr_number).await? { - let body = "**ePHPm Preview** — removed\n\n\ - Preview deployment has been torn down."; - self.update_comment(owner, repo, comment_id, body).await?; + self.update_comment(owner, repo, comment_id, teardown_comment_body()) + .await?; } Ok(()) @@ -205,6 +204,15 @@ impl GitHubClient { } } +/// The PR comment body posted when a preview is torn down. Kept as its own +/// pure function (rather than inlined in the async network path) so the exact +/// rendered markdown is unit-testable and still carries the `**ePHPm Preview**` +/// marker that [`GitHubClient::find_existing_comment`] matches on. +fn teardown_comment_body() -> &'static str { + "**ePHPm Preview** — removed\n\n\ + Preview deployment has been torn down." +} + /// Format the PR comment body for a successful deploy. fn format_deploy_comment(result: &DeployResult) -> String { let url = crate::deployer::preview_url(&result.hostname, result.php_version.as_deref()); @@ -271,4 +279,53 @@ mod tests { assert!(comment.contains("Laravel")); assert!(comment.contains("8.4")); } + + #[test] + fn comment_carries_marker_and_table() { + // The marker is load-bearing: find_existing_comment matches on it to + // decide update-vs-create, so it must always be present. + let result = DeployResult { + hostname: "pr-1.app.preview.ephpm.dev".into(), + framework: Framework::Symfony, + duration: Duration::from_millis(3_000), + php_version: Some("8.3".into()), + healthy: true, + }; + let comment = format_deploy_comment(&result); + assert!(comment.contains("**ePHPm Preview**")); + assert!(comment.contains("| URL |")); + assert!(comment.contains("| Framework |")); + assert!(comment.contains("| PHP |")); + assert!(comment.contains("Symfony")); + assert!(comment.contains(":8083"), "PHP 8.3 should use port 8083"); + // Auto-update footer is present so reviewers know pushes refresh it. + assert!(comment.contains("updates automatically")); + } + + #[test] + fn comment_duration_rounds_to_one_decimal() { + // 2_449ms rounds to 2.4s (one decimal), not 2s or 2.449s. + let result = DeployResult { + hostname: "h".into(), + framework: Framework::Drupal, + duration: Duration::from_millis(2_449), + php_version: Some("8.5".into()), + healthy: true, + }; + let comment = format_deploy_comment(&result); + assert!(comment.contains("2.4s"), "got: {comment}"); + assert!(comment.contains("Drupal")); + // 8.5 is the default port-less URL — no explicit port in the link. + assert!(!comment.contains(":8085")); + } + + #[test] + fn teardown_body_is_marked_and_removed() { + let body = teardown_comment_body(); + // Must keep the marker so the existing comment is found and updated in + // place rather than a fresh "removed" comment being appended. + assert!(body.contains("**ePHPm Preview**")); + assert!(body.contains("removed")); + assert!(body.contains("torn down")); + } } diff --git a/src/main.rs b/src/main.rs index 8af6603..991b009 100644 --- a/src/main.rs +++ b/src/main.rs @@ -276,3 +276,40 @@ fn base64_url_encode(input: &[u8]) -> String { use base64::Engine; base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(input) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn base64url_encodes_without_padding() { + // "hello" is standard base64 "aGVsbG8=" — the JWT encoding must drop + // the '=' padding (a padded segment is not a valid JWS part). + assert_eq!(base64_url_encode(b"hello"), "aGVsbG8"); + assert!(!base64_url_encode(b"hello").contains('=')); + // Empty input is the empty string, not "=". + assert_eq!(base64_url_encode(b""), ""); + } + + #[test] + fn base64url_uses_url_safe_alphabet() { + // These bytes encode to "+/8" in the standard alphabet; the URL-safe + // JWT encoding must instead emit '-' and '_' and never '+' or '/', + // otherwise the token breaks when placed in an Authorization header. + let encoded = base64_url_encode(&[0xfb, 0xff]); + assert_eq!(encoded, "-_8"); + assert!(!encoded.contains('+')); + assert!(!encoded.contains('/')); + } + + #[test] + fn base64url_roundtrips_via_decode() { + use base64::Engine; + let original = b"the quick brown fox \x00\x01\xff"; + let encoded = base64_url_encode(original); + let decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(&encoded) + .expect("url-safe no-pad output must decode"); + assert_eq!(decoded, original); + } +} diff --git a/src/manifest.rs b/src/manifest.rs index d18e104..4017f42 100644 --- a/src/manifest.rs +++ b/src/manifest.rs @@ -445,6 +445,57 @@ ini: assert!(m.seed.is_empty()); } + #[test] + fn framework_default_symfony() { + let m = AppManifest::from_framework(Framework::Symfony); + assert_eq!(m.docroot, "public"); + assert!(m.build.iter().any(|c| c.contains("composer install"))); + // Symfony has no framework-supplied seed step. + assert!(m.seed.is_empty()); + } + + #[test] + fn framework_default_drupal() { + let m = AppManifest::from_framework(Framework::Drupal); + assert_eq!(m.docroot, "web"); + assert!(m.build.iter().any(|c| c.contains("composer install"))); + } + + #[test] + fn database_none_string_disables() { + // The DatabaseService deserializer accepts the string "none" as an + // alias for disabled, alongside the bool `false`. + let m = AppManifest::from_yaml_str("version: 1\nservices:\n database: none\n").unwrap(); + assert_eq!(m.services.database, DatabaseService::Disabled); + assert_eq!(m.services.database.as_str(), "disabled"); + } + + #[test] + fn database_turso_string_case_insensitive() { + let m = AppManifest::from_yaml_str("version: 1\nservices:\n database: TURSO\n").unwrap(); + assert_eq!(m.services.database, DatabaseService::Turso); + assert_eq!(m.services.database.as_str(), "turso"); + } + + #[test] + fn websocket_explicit_true_parses() { + let m = AppManifest::from_yaml_str("version: 1\nservices:\n websocket: true\n").unwrap(); + assert_eq!(m.services.websocket, Some(true)); + // Explicit true wins with no file present on disk. + assert!(m.websocket_enabled(Path::new("/nonexistent"))); + } + + #[test] + fn legacy_json_without_overrides_keeps_framework_defaults() { + // A legacy config with neither php nor seed set must leave the + // framework-synthesized values (WordPress seed, default php) intact. + let legacy = LegacyEphpmConfig::default(); + let m = AppManifest::from_legacy_json(&legacy, Framework::WordPress); + assert_eq!(m.php, "8.5"); + assert_eq!(m.seed.len(), 1); + assert!(m.seed[0].contains("wp core install")); + } + #[test] fn legacy_json_preserves_seed_and_php() { let legacy = LegacyEphpmConfig { diff --git a/src/secrets.rs b/src/secrets.rs index 423ce9a..f8b2417 100644 --- a/src/secrets.rs +++ b/src/secrets.rs @@ -240,6 +240,63 @@ mod tests { assert!(missing.is_empty()); } + #[test] + fn multiple_references_in_one_string() { + let s = store(); + let mut missing = Vec::new(); + // Two present refs and a literal between them all resolve in one pass. + let out = s.substitute( + "ephpm/other", + "${secret.some_key}::${secret.shared}", + &mut missing, + ); + assert_eq!(out, "default-value::d"); + assert!(missing.is_empty()); + } + + #[test] + fn missing_refs_accumulate_in_order() { + let s = store(); + let mut missing = Vec::new(); + let out = s.substitute("ephpm/other", "${secret.a}-${secret.b}", &mut missing); + assert_eq!(out, "-"); + assert_eq!(missing, vec!["a".to_string(), "b".to_string()]); + } + + #[test] + fn repo_scope_falls_back_to_default_for_unlisted_name() { + // The repo scope overrides `some_key` but not `shared`; `shared` must + // still resolve from the default scope for that repo. + let s = store(); + assert_eq!(s.get("ephpm/wordpress-sample", "shared"), Some("d")); + assert_eq!( + s.get("ephpm/wordpress-sample", "some_key"), + Some("repo-value") + ); + // A totally unknown name is absent in both scopes. + assert_eq!(s.get("ephpm/wordpress-sample", "unknown"), None); + } + + #[test] + fn env_fold_skips_empty_suffix_and_lowercases() { + let mut s = Secrets::default(); + s.fold_env(vec![ + // Bare prefix with no name — must be skipped, not stored under "". + ("SWITCHBOARD_SECRET_".to_string(), "orphan".to_string()), + ("SWITCHBOARD_SECRET_MixedCase".to_string(), "v".to_string()), + // Unrelated var is ignored entirely. + ("PATH".to_string(), "/usr/bin".to_string()), + ]); + let mut missing = Vec::new(); + assert_eq!( + s.substitute("any/repo", "${secret.mixedcase}", &mut missing), + "v" + ); + assert!(missing.is_empty()); + // The empty-suffix var did not create a "" secret. + assert_eq!(s.get("any/repo", ""), None); + } + #[test] fn file_wins_over_env() { let mut default = BTreeMap::new(); From 230c58aa415a03b1bd870db958ee44bcf71d8fda Mon Sep 17 00:00:00 2001 From: Luther Monson Date: Sun, 16 Aug 2026 19:17:28 -0700 Subject: [PATCH 2/2] ci: install build-essential on compile jobs (fleet runners lack a C linker) The ephemerd runner image ships without a C compiler. Even though switchboard is pure Rust, unavoidable transitive deps have build scripts (proc-macro2, libc) or compile C (ring, via reqwest's rustls stack), so every compile job died at "linker cc not found" before checking any of our code. fmt was unaffected (it never compiles). Add the same root/sudo-aware build-essential install step ephpm/ephpm uses to the clippy, test, and msrv jobs. --- .github/workflows/ci.yml | 66 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 36ed76c..85b4982 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,6 +4,15 @@ name: CI # gate is just: does it format, lint clean, and pass its tests. Runs on the # ephpm org's self-hosted (ephemerd) fleet, same `[self-hosted, linux, x64]` # label ephpm/ephpm's ci.yml uses so the fleet picks these jobs up. +# +# NOTE: "pure Rust" does NOT mean "no C toolchain". The ephemerd runner images +# ship without a compiler, and several unavoidable transitive deps have build +# scripts (proc-macro2, libc) or compile C (ring, via reqwest's rustls stack). +# Without `cc` on PATH every compile job dies at "linker `cc` not found" before +# a single crate of ours is checked — which is exactly what the first run of +# this workflow did. So every job that COMPILES installs build-essential first, +# mirroring ephpm/ephpm's ci.yml. The fmt job is the one exception: it parses, +# never compiles, so it needs no toolchain beyond rustfmt. on: push: branches: [main] @@ -33,6 +42,29 @@ jobs: runs-on: [self-hosted, linux, x64] steps: - uses: actions/checkout@v4 + - name: Install build prerequisites + # switchboard needs only a C compiler/linker (`cc`) — no bindgen, no + # openssl-sys (reqwest uses rustls). Some fleet runners come up as root + # without `sudo`, others as non-root with it; a bare `sudo apt-get` + # exits 127 on the former and reds the job for reasons unrelated to the + # change. Resolve the escalation method at run time. (Pattern lifted + # from ephpm/ephpm's ci.yml.) + run: | + set -eu + if ! command -v apt-get >/dev/null 2>&1; then + echo "apt-get unavailable; assuming the image already provides build prerequisites" + exit 0 + fi + if [ "$(id -u)" -eq 0 ]; then + SUDO="" + elif command -v sudo >/dev/null 2>&1; then + SUDO="sudo" + else + echo "::error::not running as root and sudo is unavailable" + exit 1 + fi + $SUDO apt-get update + $SUDO apt-get install -y --no-install-recommends build-essential pkg-config - uses: dtolnay/rust-toolchain@stable with: components: clippy @@ -44,6 +76,23 @@ jobs: runs-on: [self-hosted, linux, x64] steps: - uses: actions/checkout@v4 + - name: Install build prerequisites + run: | + set -eu + if ! command -v apt-get >/dev/null 2>&1; then + echo "apt-get unavailable; assuming the image already provides build prerequisites" + exit 0 + fi + if [ "$(id -u)" -eq 0 ]; then + SUDO="" + elif command -v sudo >/dev/null 2>&1; then + SUDO="sudo" + else + echo "::error::not running as root and sudo is unavailable" + exit 1 + fi + $SUDO apt-get update + $SUDO apt-get install -y --no-install-recommends build-essential pkg-config - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 - run: cargo test @@ -53,6 +102,23 @@ jobs: runs-on: [self-hosted, linux, x64] steps: - uses: actions/checkout@v4 + - name: Install build prerequisites + run: | + set -eu + if ! command -v apt-get >/dev/null 2>&1; then + echo "apt-get unavailable; assuming the image already provides build prerequisites" + exit 0 + fi + if [ "$(id -u)" -eq 0 ]; then + SUDO="" + elif command -v sudo >/dev/null 2>&1; then + SUDO="sudo" + else + echo "::error::not running as root and sudo is unavailable" + exit 1 + fi + $SUDO apt-get update + $SUDO apt-get install -y --no-install-recommends build-essential pkg-config # Pinned to the crate's declared rust-version (Cargo.toml). A build that # needs a newer toolchain than we advertise is a bug in the manifest. - uses: dtolnay/rust-toolchain@1.85