mirror of
https://github.com/bahdotsh/wrkflw.git
synced 2026-05-18 05:05:35 +02:00
Merge pull request #70 from bahdotsh/fix/48-resolve-action-yml-for-docker-image
fix: resolve action.yml from remote repos to determine correct Docker image
This commit is contained in:
245
Cargo.lock
generated
245
Cargo.lock
generated
@@ -152,6 +152,27 @@ version = "1.0.99"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b0674a1ddeecb70197781e945de4b3b8ffb61fa939a5597bcf48503737663100"
|
||||
|
||||
[[package]]
|
||||
name = "assert-json-diff"
|
||||
version = "2.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-channel"
|
||||
version = "1.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "81953c529336010edd6d8e358f886d9581267795c61b19475b71314bffa46d35"
|
||||
dependencies = [
|
||||
"concurrent-queue",
|
||||
"event-listener",
|
||||
"futures-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-stream"
|
||||
version = "0.3.6"
|
||||
@@ -456,6 +477,15 @@ dependencies = [
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "concurrent-queue"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
version = "0.9.4"
|
||||
@@ -596,7 +626,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
"rand_core",
|
||||
"rand_core 0.6.4",
|
||||
"typenum",
|
||||
]
|
||||
|
||||
@@ -609,6 +639,25 @@ dependencies = [
|
||||
"cipher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deadpool"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "421fe0f90f2ab22016f32a9881be5134fdd71c65298917084b0c7477cbc3856e"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"deadpool-runtime",
|
||||
"num_cpus",
|
||||
"retain_mut",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deadpool-runtime"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b"
|
||||
|
||||
[[package]]
|
||||
name = "deranged"
|
||||
version = "0.4.0"
|
||||
@@ -693,6 +742,12 @@ dependencies = [
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "event-listener"
|
||||
version = "2.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0"
|
||||
|
||||
[[package]]
|
||||
name = "fancy-regex"
|
||||
version = "0.11.0"
|
||||
@@ -703,6 +758,15 @@ dependencies = [
|
||||
"regex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "1.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e51093e27b0797c359783294ca4f0a911c270184cb10f85783b118614a1501be"
|
||||
dependencies = [
|
||||
"instant",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "2.3.0"
|
||||
@@ -794,6 +858,21 @@ version = "0.3.31"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6"
|
||||
|
||||
[[package]]
|
||||
name = "futures-lite"
|
||||
version = "1.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "49a9d51ce47660b1e808d3c990b4709f2f415d928835a17dfd16991515c46bce"
|
||||
dependencies = [
|
||||
"fastrand 1.9.0",
|
||||
"futures-core",
|
||||
"futures-io",
|
||||
"memchr",
|
||||
"parking",
|
||||
"pin-project-lite",
|
||||
"waker-fn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-macro"
|
||||
version = "0.3.31"
|
||||
@@ -817,6 +896,12 @@ version = "0.3.31"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988"
|
||||
|
||||
[[package]]
|
||||
name = "futures-timer"
|
||||
version = "3.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24"
|
||||
|
||||
[[package]]
|
||||
name = "futures-util"
|
||||
version = "0.3.31"
|
||||
@@ -845,6 +930,17 @@ dependencies = [
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.1.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"wasi 0.9.0+wasi-snapshot-preview1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.2.16"
|
||||
@@ -1004,6 +1100,27 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "http-types"
|
||||
version = "2.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e9b187a72d63adbfba487f48095306ac823049cb504ee195541e91c7775f5ad"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel",
|
||||
"base64 0.13.1",
|
||||
"futures-lite",
|
||||
"http",
|
||||
"infer",
|
||||
"pin-project-lite",
|
||||
"rand 0.7.3",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_qs",
|
||||
"serde_urlencoded",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httparse"
|
||||
version = "1.10.1"
|
||||
@@ -1242,6 +1359,12 @@ version = "2.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f4c7245a08504955605670dbf141fceab975f15ca21570696aebe9d2e71576bd"
|
||||
|
||||
[[package]]
|
||||
name = "infer"
|
||||
version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "64e9829a50b42bb782c1df523f78d332fe371b10c661e78b7a3c34b0198e9fac"
|
||||
|
||||
[[package]]
|
||||
name = "inout"
|
||||
version = "0.1.4"
|
||||
@@ -1251,6 +1374,15 @@ dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "instant"
|
||||
version = "0.1.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "io-uring"
|
||||
version = "0.7.9"
|
||||
@@ -1613,6 +1745,12 @@ version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
|
||||
|
||||
[[package]]
|
||||
name = "parking"
|
||||
version = "2.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
|
||||
|
||||
[[package]]
|
||||
name = "parking_lot"
|
||||
version = "0.12.4"
|
||||
@@ -1778,6 +1916,19 @@ version = "5.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6a6b1679d49b24bbfe0c803429aa1874472f50d9b363131f0e89fc356b544d03"
|
||||
dependencies = [
|
||||
"getrandom 0.1.16",
|
||||
"libc",
|
||||
"rand_chacha 0.2.2",
|
||||
"rand_core 0.5.1",
|
||||
"rand_hc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.8.5"
|
||||
@@ -1785,8 +1936,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rand_chacha",
|
||||
"rand_core",
|
||||
"rand_chacha 0.3.1",
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f4c8ed856279c9737206bf725bf36935d8666ead7aa69b52be55af369d193402"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core 0.5.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1796,7 +1957,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core",
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19"
|
||||
dependencies = [
|
||||
"getrandom 0.1.16",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1808,6 +1978,15 @@ dependencies = [
|
||||
"getrandom 0.2.16",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_hc"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ca3129af7b92a17112d59ad498c6f81eaf463253766b90396d39ea7a39d6613c"
|
||||
dependencies = [
|
||||
"rand_core 0.5.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ratatui"
|
||||
version = "0.23.0"
|
||||
@@ -1935,6 +2114,12 @@ dependencies = [
|
||||
"winreg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "retain_mut"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4389f1d5789befaf6029ebd9f7dac4af7f7e3d61b69d4f30e2ac02b57e7712b0"
|
||||
|
||||
[[package]]
|
||||
name = "ring"
|
||||
version = "0.17.14"
|
||||
@@ -2081,6 +2266,17 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_qs"
|
||||
version = "0.8.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c7715380eec75f029a4ef7de39a9200e0a63823176b759d055b613f5a87df6a6"
|
||||
dependencies = [
|
||||
"percent-encoding",
|
||||
"serde",
|
||||
"thiserror",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_repr"
|
||||
version = "0.1.20"
|
||||
@@ -2317,7 +2513,7 @@ version = "3.21.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "15b61f8f20e3a6f7e0649d825294eaf317edce30f82cf6026e7e4cb9222a7d1e"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"fastrand 2.3.0",
|
||||
"getrandom 0.3.3",
|
||||
"once_cell",
|
||||
"rustix 1.0.8",
|
||||
@@ -2571,6 +2767,7 @@ dependencies = [
|
||||
"form_urlencoded",
|
||||
"idna",
|
||||
"percent-encoding",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2608,6 +2805,12 @@ version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||
|
||||
[[package]]
|
||||
name = "waker-fn"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "317211a0dc0ceedd78fb2ca9a44aed3d7b9b26f81870d485c07122b4350673b7"
|
||||
|
||||
[[package]]
|
||||
name = "walkdir"
|
||||
version = "2.5.0"
|
||||
@@ -2627,6 +2830,12 @@ dependencies = [
|
||||
"try-lock",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasi"
|
||||
version = "0.9.0+wasi-snapshot-preview1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519"
|
||||
|
||||
[[package]]
|
||||
name = "wasi"
|
||||
version = "0.11.1+wasi-snapshot-preview1"
|
||||
@@ -3063,6 +3272,28 @@ dependencies = [
|
||||
"windows-sys 0.48.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wiremock"
|
||||
version = "0.5.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13a3a53eaf34f390dd30d7b1b078287dd05df2aa2e21a589ccb80f5c7253c2e9"
|
||||
dependencies = [
|
||||
"assert-json-diff",
|
||||
"async-trait",
|
||||
"base64 0.21.7",
|
||||
"deadpool",
|
||||
"futures",
|
||||
"futures-timer",
|
||||
"http-types",
|
||||
"hyper",
|
||||
"log",
|
||||
"once_cell",
|
||||
"regex",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rt"
|
||||
version = "0.39.0"
|
||||
@@ -3150,6 +3381,7 @@ dependencies = [
|
||||
"num_cpus",
|
||||
"once_cell",
|
||||
"regex",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_yaml",
|
||||
@@ -3158,6 +3390,7 @@ dependencies = [
|
||||
"thiserror",
|
||||
"tokio",
|
||||
"uuid",
|
||||
"wiremock",
|
||||
"wrkflw-logging",
|
||||
"wrkflw-matrix",
|
||||
"wrkflw-models",
|
||||
@@ -3276,7 +3509,7 @@ dependencies = [
|
||||
"hmac",
|
||||
"lazy_static",
|
||||
"pbkdf2",
|
||||
"rand",
|
||||
"rand 0.8.5",
|
||||
"regex",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
@@ -65,6 +65,7 @@ reqwest = { version = "0.11", default-features = false, features = [
|
||||
libc = "0.2"
|
||||
nix = { version = "0.27.1", features = ["fs"] }
|
||||
urlencoding = "2.1.3"
|
||||
wiremock = "0.5"
|
||||
|
||||
[profile.release]
|
||||
codegen-units = 1
|
||||
|
||||
@@ -32,6 +32,7 @@ lazy_static.workspace = true
|
||||
num_cpus.workspace = true
|
||||
once_cell.workspace = true
|
||||
regex.workspace = true
|
||||
reqwest.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
serde_yaml.workspace = true
|
||||
@@ -40,3 +41,6 @@ tempfile.workspace = true
|
||||
thiserror.workspace = true
|
||||
tokio.workspace = true
|
||||
uuid.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
wiremock.workspace = true
|
||||
|
||||
736
crates/executor/src/action_resolver.rs
Normal file
736
crates/executor/src/action_resolver.rs
Normal file
@@ -0,0 +1,736 @@
|
||||
use once_cell::sync::Lazy;
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
/// Maximum number of entries in the action resolution cache.
|
||||
const MAX_CACHE_ENTRIES: usize = 256;
|
||||
|
||||
/// Represents the type of a GitHub Action as declared in its action.yml `runs.using` field.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ActionType {
|
||||
Node {
|
||||
version: u32,
|
||||
},
|
||||
/// A Docker action that references a registry image (e.g., `rust:latest`).
|
||||
Docker {
|
||||
image: String,
|
||||
},
|
||||
/// A Docker action that bundles its own Dockerfile and needs to be built.
|
||||
DockerBuild,
|
||||
Composite,
|
||||
}
|
||||
|
||||
/// Result of resolving a remote action's action.yml.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ResolvedAction {
|
||||
pub action_type: ActionType,
|
||||
/// The raw parsed action.yml, available for composite action execution.
|
||||
pub definition: Option<serde_yaml::Value>,
|
||||
}
|
||||
|
||||
/// Bounded FIFO cache for successfully resolved actions keyed by "owner/repo@version".
|
||||
/// Only successful resolutions are cached — transient failures are not persisted
|
||||
/// so that retries can succeed if network conditions improve.
|
||||
/// Eviction is insertion-order (FIFO), not access-order, which is sufficient here
|
||||
/// because actions are typically resolved once per workflow run.
|
||||
struct BoundedCache {
|
||||
map: HashMap<String, ResolvedAction>,
|
||||
/// Insertion order for FIFO eviction (oldest at front).
|
||||
order: VecDeque<String>,
|
||||
}
|
||||
|
||||
impl BoundedCache {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
map: HashMap::new(),
|
||||
order: VecDeque::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn get(&self, key: &str) -> Option<&ResolvedAction> {
|
||||
self.map.get(key)
|
||||
}
|
||||
|
||||
#[allow(clippy::map_entry)]
|
||||
fn insert(&mut self, key: String, value: ResolvedAction) {
|
||||
if self.map.contains_key(&key) {
|
||||
// Already cached — update value, don't change insertion order
|
||||
self.map.insert(key, value);
|
||||
return;
|
||||
}
|
||||
// Evict oldest entries if at capacity
|
||||
while self.map.len() >= MAX_CACHE_ENTRIES {
|
||||
if let Some(oldest) = self.order.pop_front() {
|
||||
self.map.remove(&oldest);
|
||||
}
|
||||
}
|
||||
self.order.push_back(key.clone());
|
||||
self.map.insert(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
static ACTION_CACHE: Lazy<RwLock<BoundedCache>> = Lazy::new(|| RwLock::new(BoundedCache::new()));
|
||||
|
||||
/// Shared HTTP client to avoid repeated TLS initialization.
|
||||
/// Timeout is kept low (5s) since resolution is best-effort with a fallback.
|
||||
static HTTP_CLIENT: Lazy<reqwest::Client> = Lazy::new(|| {
|
||||
reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(5))
|
||||
.user_agent("wrkflw")
|
||||
.build()
|
||||
.expect("Failed to create HTTP client")
|
||||
});
|
||||
|
||||
/// Shared no-redirect HTTP client for authenticated requests.
|
||||
/// Prevents leaking the GITHUB_TOKEN to redirect targets (e.g., CDN hosts).
|
||||
/// Reused across requests to avoid per-request TLS initialization.
|
||||
static NO_REDIRECT_CLIENT: Lazy<reqwest::Client> = Lazy::new(|| {
|
||||
reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(5))
|
||||
.user_agent("wrkflw")
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()
|
||||
.expect("Failed to create no-redirect HTTP client")
|
||||
});
|
||||
|
||||
const GITHUB_RAW_BASE_URL: &str = "https://raw.githubusercontent.com";
|
||||
|
||||
/// Fetch and parse `action.yml` (or `action.yaml`) from a remote GitHub repository.
|
||||
///
|
||||
/// `sub_path` is the optional path within the repo (e.g., for `owner/repo/path@ref`,
|
||||
/// `sub_path` is `Some("path")`). When present, the action metadata is fetched from
|
||||
/// `{repo}/{version}/{sub_path}/action.yml` instead of `{repo}/{version}/action.yml`.
|
||||
///
|
||||
/// Returns `Ok(ResolvedAction)` on success, or `Err` if the action metadata cannot be
|
||||
/// fetched or parsed. Callers should fall back to hardcoded image mappings on error.
|
||||
pub async fn resolve_remote_action(
|
||||
repo: &str,
|
||||
version: &str,
|
||||
sub_path: Option<&str>,
|
||||
) -> Result<ResolvedAction, String> {
|
||||
let cache_key = match sub_path {
|
||||
Some(p) => format!("{}/{}@{}", repo, p, version),
|
||||
None => format!("{}@{}", repo, version),
|
||||
};
|
||||
|
||||
// Check cache first (read lock — allows concurrent reads)
|
||||
{
|
||||
let cache = ACTION_CACHE.read().await;
|
||||
if let Some(cached) = cache.get(&cache_key) {
|
||||
return Ok(cached.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let token = std::env::var("GITHUB_TOKEN").ok();
|
||||
|
||||
// Try action.yml first, then action.yaml
|
||||
let result = match fetch_and_parse(
|
||||
GITHUB_RAW_BASE_URL,
|
||||
repo,
|
||||
version,
|
||||
sub_path,
|
||||
"action.yml",
|
||||
token.as_deref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(resolved) => Ok(resolved),
|
||||
Err(yml_err) => fetch_and_parse(
|
||||
GITHUB_RAW_BASE_URL,
|
||||
repo,
|
||||
version,
|
||||
sub_path,
|
||||
"action.yaml",
|
||||
token.as_deref(),
|
||||
)
|
||||
.await
|
||||
.map_err(|yaml_err| {
|
||||
format!(
|
||||
"Neither action.yml ({}) nor action.yaml ({}) could be resolved",
|
||||
yml_err, yaml_err
|
||||
)
|
||||
}),
|
||||
};
|
||||
|
||||
// Only cache successful resolutions — transient failures should be retryable
|
||||
if let Ok(ref resolved) = result {
|
||||
let mut cache = ACTION_CACHE.write().await;
|
||||
cache.insert(cache_key, resolved.clone());
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
async fn fetch_and_parse(
|
||||
base_url: &str,
|
||||
repo: &str,
|
||||
version: &str,
|
||||
sub_path: Option<&str>,
|
||||
filename: &str,
|
||||
token: Option<&str>,
|
||||
) -> Result<ResolvedAction, String> {
|
||||
let url = match sub_path {
|
||||
Some(p) => format!("{}/{}/{}/{}/{}", base_url, repo, version, p, filename),
|
||||
None => format!("{}/{}/{}/{}", base_url, repo, version, filename),
|
||||
};
|
||||
|
||||
// Try unauthenticated first; only send GITHUB_TOKEN on 404 (private repos).
|
||||
let response = HTTP_CLIENT
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to fetch {}: {}", url, e))?;
|
||||
|
||||
let response =
|
||||
if response.status() == reqwest::StatusCode::NOT_FOUND {
|
||||
// Retry with auth if token is available — the repo may be private.
|
||||
// NO_REDIRECT_CLIENT prevents leaking the token to a non-GitHub host.
|
||||
if let Some(token) = token {
|
||||
let auth_response = NO_REDIRECT_CLIENT
|
||||
.get(&url)
|
||||
.header("Authorization", format!("token {}", token))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to fetch {}: {}", url, e))?;
|
||||
|
||||
// The no-redirect policy prevents token leakage, but the server may
|
||||
// legitimately redirect (CDN routing). If we get a 3xx, follow it
|
||||
// without the auth header to avoid leaking the token.
|
||||
if auth_response.status().is_redirection() {
|
||||
if let Some(location) = auth_response.headers().get(reqwest::header::LOCATION) {
|
||||
let redirect_url = location
|
||||
.to_str()
|
||||
.map_err(|_| "Invalid redirect URL encoding".to_string())?;
|
||||
HTTP_CLIENT.get(redirect_url).send().await.map_err(|e| {
|
||||
format!("Failed to follow redirect {}: {}", redirect_url, e)
|
||||
})?
|
||||
} else {
|
||||
return Err(format!(
|
||||
"HTTP {} (redirect with no Location header) fetching {}",
|
||||
auth_response.status(),
|
||||
url
|
||||
));
|
||||
}
|
||||
} else {
|
||||
auth_response
|
||||
}
|
||||
} else {
|
||||
response
|
||||
}
|
||||
} else {
|
||||
response
|
||||
};
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(format!("HTTP {} fetching {}", response.status(), url));
|
||||
}
|
||||
|
||||
let body = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to read response body: {}", e))?;
|
||||
|
||||
parse_action_definition(&body)
|
||||
}
|
||||
|
||||
/// Parse an action.yml body and extract the action type from the `runs` section.
|
||||
fn parse_action_definition(content: &str) -> Result<ResolvedAction, String> {
|
||||
let def: serde_yaml::Value =
|
||||
serde_yaml::from_str(content).map_err(|e| format!("Invalid action YAML: {}", e))?;
|
||||
|
||||
let runs = def
|
||||
.get("runs")
|
||||
.ok_or_else(|| "action.yml missing 'runs' section".to_string())?;
|
||||
|
||||
let using = runs
|
||||
.get("using")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| "action.yml missing 'runs.using' field".to_string())?;
|
||||
|
||||
let action_type = parse_using(using, runs)?;
|
||||
|
||||
Ok(ResolvedAction {
|
||||
action_type,
|
||||
definition: Some(def),
|
||||
})
|
||||
}
|
||||
|
||||
/// Map the `runs.using` value to an `ActionType`.
|
||||
fn parse_using(using: &str, runs: &serde_yaml::Value) -> Result<ActionType, String> {
|
||||
match using {
|
||||
"composite" => Ok(ActionType::Composite),
|
||||
|
||||
"docker" => {
|
||||
let image = runs
|
||||
.get("image")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| "Docker action missing 'runs.image' field".to_string())?;
|
||||
|
||||
// Strip "docker://" prefix if present (some actions use it, some don't)
|
||||
let image = image.trim_start_matches("docker://");
|
||||
|
||||
// If the image is "Dockerfile" or a relative path, it means the action
|
||||
// bundles its own Dockerfile that needs to be built — not pulled from a registry.
|
||||
if image == "Dockerfile"
|
||||
|| image.starts_with("./")
|
||||
|| image.starts_with("../")
|
||||
|| image.ends_with("/Dockerfile")
|
||||
{
|
||||
Ok(ActionType::DockerBuild)
|
||||
} else {
|
||||
Ok(ActionType::Docker {
|
||||
image: image.to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
s if s.starts_with("node") => {
|
||||
let version_str = s.trim_start_matches("node");
|
||||
let version: u32 = version_str.parse().map_err(|_| {
|
||||
format!(
|
||||
"Invalid node version in runs.using '{}': expected 'node<N>' (e.g., 'node20')",
|
||||
s
|
||||
)
|
||||
})?;
|
||||
Ok(ActionType::Node { version })
|
||||
}
|
||||
|
||||
other => Err(format!("Unknown runs.using value: {}", other)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_node_action() {
|
||||
let yaml = r#"
|
||||
name: 'My Action'
|
||||
runs:
|
||||
using: 'node20'
|
||||
main: 'index.js'
|
||||
"#;
|
||||
let resolved = parse_action_definition(yaml).unwrap();
|
||||
match resolved.action_type {
|
||||
ActionType::Node { version } => assert_eq!(version, 20),
|
||||
other => panic!("Expected Node action, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_docker_action() {
|
||||
let yaml = r#"
|
||||
name: 'Docker Action'
|
||||
runs:
|
||||
using: 'docker'
|
||||
image: 'docker://rust:latest'
|
||||
"#;
|
||||
let resolved = parse_action_definition(yaml).unwrap();
|
||||
match &resolved.action_type {
|
||||
ActionType::Docker { image } => assert_eq!(image, "rust:latest"),
|
||||
other => panic!("Expected Docker action, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_docker_action_with_dockerfile() {
|
||||
let yaml = r#"
|
||||
name: 'Docker Action'
|
||||
runs:
|
||||
using: 'docker'
|
||||
image: 'Dockerfile'
|
||||
"#;
|
||||
let resolved = parse_action_definition(yaml).unwrap();
|
||||
assert!(
|
||||
matches!(resolved.action_type, ActionType::DockerBuild),
|
||||
"Expected DockerBuild, got {:?}",
|
||||
resolved.action_type
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_docker_action_with_relative_dockerfile() {
|
||||
let yaml = r#"
|
||||
name: 'Docker Action'
|
||||
runs:
|
||||
using: 'docker'
|
||||
image: './docker/Dockerfile'
|
||||
"#;
|
||||
let resolved = parse_action_definition(yaml).unwrap();
|
||||
assert!(
|
||||
matches!(resolved.action_type, ActionType::DockerBuild),
|
||||
"Expected DockerBuild, got {:?}",
|
||||
resolved.action_type
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_composite_action() {
|
||||
let yaml = r#"
|
||||
name: 'Composite Action'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- run: echo hello
|
||||
"#;
|
||||
let resolved = parse_action_definition(yaml).unwrap();
|
||||
assert!(matches!(resolved.action_type, ActionType::Composite));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_missing_runs() {
|
||||
let yaml = r#"
|
||||
name: 'Bad Action'
|
||||
"#;
|
||||
assert!(parse_action_definition(yaml).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_node16_action() {
|
||||
let yaml = r#"
|
||||
name: 'Legacy Node Action'
|
||||
runs:
|
||||
using: 'node16'
|
||||
main: 'index.js'
|
||||
"#;
|
||||
let resolved = parse_action_definition(yaml).unwrap();
|
||||
match resolved.action_type {
|
||||
ActionType::Node { version } => assert_eq!(version, 16),
|
||||
other => panic!("Expected Node 16, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_unknown_using_value() {
|
||||
let yaml = r#"
|
||||
name: 'Unknown Action'
|
||||
runs:
|
||||
using: 'python3'
|
||||
"#;
|
||||
let err = parse_action_definition(yaml).unwrap_err();
|
||||
assert!(err.contains("Unknown runs.using value"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_missing_using_field() {
|
||||
let yaml = r#"
|
||||
name: 'Bad Action'
|
||||
runs:
|
||||
main: 'index.js'
|
||||
"#;
|
||||
let err = parse_action_definition(yaml).unwrap_err();
|
||||
assert!(err.contains("runs.using"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_docker_missing_image() {
|
||||
let yaml = r#"
|
||||
name: 'Bad Docker Action'
|
||||
runs:
|
||||
using: 'docker'
|
||||
"#;
|
||||
let err = parse_action_definition(yaml).unwrap_err();
|
||||
assert!(err.contains("runs.image"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_docker_with_docker_prefix_and_dockerfile() {
|
||||
let yaml = r#"
|
||||
name: 'Docker Action'
|
||||
runs:
|
||||
using: 'docker'
|
||||
image: 'docker://Dockerfile'
|
||||
"#;
|
||||
let resolved = parse_action_definition(yaml).unwrap();
|
||||
assert!(
|
||||
matches!(resolved.action_type, ActionType::DockerBuild),
|
||||
"docker://Dockerfile should be DockerBuild, got {:?}",
|
||||
resolved.action_type
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolved_action_has_definition() {
|
||||
let yaml = r#"
|
||||
name: 'My Action'
|
||||
description: 'Test'
|
||||
runs:
|
||||
using: 'node20'
|
||||
main: 'index.js'
|
||||
"#;
|
||||
let resolved = parse_action_definition(yaml).unwrap();
|
||||
let def = resolved.definition.unwrap();
|
||||
assert_eq!(def.get("name").unwrap().as_str().unwrap(), "My Action");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_malformed_node_version_returns_error() {
|
||||
let yaml = r#"
|
||||
name: 'Bad Node Action'
|
||||
runs:
|
||||
using: 'nodefoo'
|
||||
main: 'index.js'
|
||||
"#;
|
||||
let err = parse_action_definition(yaml).unwrap_err();
|
||||
assert!(
|
||||
err.contains("Invalid node version"),
|
||||
"Expected error about invalid node version, got: {}",
|
||||
err
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_bare_node_returns_error() {
|
||||
let yaml = r#"
|
||||
name: 'Bare Node Action'
|
||||
runs:
|
||||
using: 'node'
|
||||
main: 'index.js'
|
||||
"#;
|
||||
let err = parse_action_definition(yaml).unwrap_err();
|
||||
assert!(
|
||||
err.contains("Invalid node version"),
|
||||
"Expected error about invalid node version, got: {}",
|
||||
err
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_cache_respects_max_capacity() {
|
||||
let mut cache = BoundedCache::new();
|
||||
// Fill beyond capacity
|
||||
for i in 0..MAX_CACHE_ENTRIES + 10 {
|
||||
cache.insert(
|
||||
format!("owner/repo@v{}", i),
|
||||
ResolvedAction {
|
||||
action_type: ActionType::Node { version: 20 },
|
||||
definition: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
cache.map.len() <= MAX_CACHE_ENTRIES,
|
||||
"Cache size {} exceeds max {}",
|
||||
cache.map.len(),
|
||||
MAX_CACHE_ENTRIES
|
||||
);
|
||||
// Oldest entries should have been evicted
|
||||
assert!(cache.get("owner/repo@v0").is_none());
|
||||
// Newest entries should still be present
|
||||
assert!(cache
|
||||
.get(&format!("owner/repo@v{}", MAX_CACHE_ENTRIES + 9))
|
||||
.is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_cache_duplicate_insert_does_not_grow() {
|
||||
let mut cache = BoundedCache::new();
|
||||
cache.insert(
|
||||
"owner/repo@v1".to_string(),
|
||||
ResolvedAction {
|
||||
action_type: ActionType::Node { version: 20 },
|
||||
definition: None,
|
||||
},
|
||||
);
|
||||
cache.insert(
|
||||
"owner/repo@v1".to_string(),
|
||||
ResolvedAction {
|
||||
action_type: ActionType::Node { version: 16 },
|
||||
definition: None,
|
||||
},
|
||||
);
|
||||
assert_eq!(cache.map.len(), 1);
|
||||
// Value should be updated
|
||||
match &cache.get("owner/repo@v1").unwrap().action_type {
|
||||
ActionType::Node { version } => assert_eq!(*version, 16),
|
||||
other => panic!("Expected Node, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
/// Tests for `fetch_and_parse` HTTP behavior using wiremock.
|
||||
///
|
||||
/// Token is passed as a parameter to `fetch_and_parse`, so no env mutation is needed.
|
||||
mod fetch_tests {
|
||||
use super::super::*;
|
||||
use wiremock::matchers::{header_exists, method, path};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
const ACTION_YML_BODY: &str =
|
||||
"name: Test Action\nruns:\n using: 'node20'\n main: 'index.js'\n";
|
||||
|
||||
#[tokio::test]
|
||||
async fn fetch_success_parses_action_yml() {
|
||||
let server = MockServer::start().await;
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/owner/repo/v1/action.yml"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string(ACTION_YML_BODY))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let result =
|
||||
fetch_and_parse(&server.uri(), "owner/repo", "v1", None, "action.yml", None).await;
|
||||
|
||||
let resolved = result.unwrap();
|
||||
match resolved.action_type {
|
||||
ActionType::Node { version } => assert_eq!(version, 20),
|
||||
other => panic!("Expected Node action, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fetch_with_sub_path() {
|
||||
let server = MockServer::start().await;
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/owner/repo/v1/my/action/action.yml"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string(ACTION_YML_BODY))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let result = fetch_and_parse(
|
||||
&server.uri(),
|
||||
"owner/repo",
|
||||
"v1",
|
||||
Some("my/action"),
|
||||
"action.yml",
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
let resolved = result.unwrap();
|
||||
assert!(matches!(
|
||||
resolved.action_type,
|
||||
ActionType::Node { version: 20 }
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fetch_404_without_token_returns_error() {
|
||||
let server = MockServer::start().await;
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/owner/repo/v1/action.yml"))
|
||||
.respond_with(ResponseTemplate::new(404))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let result =
|
||||
fetch_and_parse(&server.uri(), "owner/repo", "v1", None, "action.yml", None).await;
|
||||
|
||||
assert!(result.is_err());
|
||||
assert!(
|
||||
result.as_ref().unwrap_err().contains("404"),
|
||||
"Expected 404 in error, got: {}",
|
||||
result.unwrap_err()
|
||||
);
|
||||
}
|
||||
|
||||
/// Verifies the security-critical property: when the auth request gets a
|
||||
/// redirect response (e.g., to a CDN), the redirect is followed WITHOUT
|
||||
/// the Authorization header, preventing the GITHUB_TOKEN from leaking
|
||||
/// to a non-GitHub host.
|
||||
#[tokio::test]
|
||||
async fn auth_redirect_does_not_leak_token() {
|
||||
let server = MockServer::start().await;
|
||||
|
||||
// 1. Unauthenticated request → 404 (triggers auth retry).
|
||||
// Mounted first so it has lowest priority in wiremock's LIFO matching.
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/owner/repo/v1/action.yml"))
|
||||
.respond_with(ResponseTemplate::new(404))
|
||||
.up_to_n_times(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
// 2. Authenticated retry → 302 redirect to a different path.
|
||||
let redirect_url = format!("{}/cdn/redirected/action.yml", server.uri());
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/owner/repo/v1/action.yml"))
|
||||
.and(header_exists("Authorization"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(302).insert_header("Location", redirect_url.as_str()),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
// 3. Redirect target → 200 with action.yml body.
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/cdn/redirected/action.yml"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string(ACTION_YML_BODY))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let result = fetch_and_parse(
|
||||
&server.uri(),
|
||||
"owner/repo",
|
||||
"v1",
|
||||
None,
|
||||
"action.yml",
|
||||
Some("ghp_test_token_for_redirect_test"),
|
||||
)
|
||||
.await;
|
||||
|
||||
// The resolution should succeed via the redirect path
|
||||
let resolved = result.unwrap();
|
||||
assert!(matches!(
|
||||
resolved.action_type,
|
||||
ActionType::Node { version: 20 }
|
||||
));
|
||||
|
||||
// Verify the redirect request did NOT include the Authorization header.
|
||||
// This is the core security invariant: tokens must not leak to redirect targets.
|
||||
let requests = server.received_requests().await.unwrap();
|
||||
let redirect_req = requests
|
||||
.iter()
|
||||
.find(|r| r.url.path() == "/cdn/redirected/action.yml")
|
||||
.expect("Expected a request to the redirect target");
|
||||
let has_auth = redirect_req
|
||||
.headers
|
||||
.iter()
|
||||
.any(|(name, _)| name.as_str() == "authorization");
|
||||
assert!(
|
||||
!has_auth,
|
||||
"GITHUB_TOKEN leaked to redirect target! Authorization header found on redirect request."
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn auth_retry_on_404_with_token_succeeds() {
|
||||
let server = MockServer::start().await;
|
||||
|
||||
// 1. Unauthenticated → 404
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/owner/repo/v1/action.yml"))
|
||||
.respond_with(ResponseTemplate::new(404))
|
||||
.up_to_n_times(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
// 2. Authenticated → 200 (private repo, no redirect)
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/owner/repo/v1/action.yml"))
|
||||
.and(header_exists("Authorization"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string(ACTION_YML_BODY))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let result = fetch_and_parse(
|
||||
&server.uri(),
|
||||
"owner/repo",
|
||||
"v1",
|
||||
None,
|
||||
"action.yml",
|
||||
Some("ghp_test_token_for_auth_test"),
|
||||
)
|
||||
.await;
|
||||
|
||||
let resolved = result.unwrap();
|
||||
assert!(matches!(
|
||||
resolved.action_type,
|
||||
ActionType::Node { version: 20 }
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,7 @@
|
||||
|
||||
#![allow(unused_variables, unused_assignments)]
|
||||
|
||||
pub mod action_resolver;
|
||||
pub mod dependency;
|
||||
pub mod docker;
|
||||
pub mod engine;
|
||||
|
||||
@@ -117,25 +117,71 @@ pub struct Step {
|
||||
impl WorkflowDefinition {
|
||||
pub fn resolve_action(&self, action_ref: &str) -> ActionInfo {
|
||||
// Parse GitHub action reference like "actions/checkout@v3"
|
||||
let parts: Vec<&str> = action_ref.split('@').collect();
|
||||
let is_docker = action_ref.starts_with("docker://");
|
||||
let is_local = action_ref.starts_with("./");
|
||||
|
||||
let (repo, _) = if parts.len() > 1 {
|
||||
(parts[0], parts[1])
|
||||
// Docker references can contain `@sha256:digest` (e.g., `docker://alpine@sha256:abc`).
|
||||
// Don't split on `@` for Docker refs — the full string is the image reference.
|
||||
// Local paths also never have a meaningful `@version`.
|
||||
if is_docker {
|
||||
return ActionInfo {
|
||||
repository: action_ref.to_string(),
|
||||
version: String::new(),
|
||||
sub_path: None,
|
||||
is_docker: true,
|
||||
is_local: false,
|
||||
};
|
||||
}
|
||||
if is_local {
|
||||
return ActionInfo {
|
||||
repository: action_ref.to_string(),
|
||||
version: String::new(),
|
||||
sub_path: None,
|
||||
is_docker: false,
|
||||
is_local: true,
|
||||
};
|
||||
}
|
||||
|
||||
// For GitHub action references, split on the first `@` to get repo and version.
|
||||
let (full_repo, version) = if let Some(at_pos) = action_ref.find('@') {
|
||||
(&action_ref[..at_pos], &action_ref[at_pos + 1..])
|
||||
} else {
|
||||
(parts[0], "main") // Default to main if no version specified
|
||||
(action_ref, "main") // Default to main if no version specified
|
||||
};
|
||||
|
||||
// GitHub action refs can include a sub-path: `owner/repo/path/to/action@ref`.
|
||||
// Split into the repo (`owner/repo`) and optional sub-path (`path/to/action`).
|
||||
let parts: Vec<&str> = full_repo.splitn(3, '/').collect();
|
||||
let (repo, sub_path) = if parts.len() == 3 {
|
||||
(
|
||||
format!("{}/{}", parts[0], parts[1]),
|
||||
Some(parts[2].to_string()),
|
||||
)
|
||||
} else {
|
||||
(full_repo.to_string(), None)
|
||||
};
|
||||
|
||||
ActionInfo {
|
||||
repository: repo.to_string(),
|
||||
is_docker: repo.starts_with("docker://"),
|
||||
is_local: repo.starts_with("./"),
|
||||
repository: repo,
|
||||
version: version.to_string(),
|
||||
sub_path,
|
||||
is_docker: false,
|
||||
is_local: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ActionInfo {
|
||||
/// The repository identifier (`owner/repo`), Docker image ref, or local path.
|
||||
pub repository: String,
|
||||
/// The git ref (tag, branch, or SHA) for GitHub action references.
|
||||
/// Empty for Docker refs (`docker://...`) and local paths (`./...`).
|
||||
/// Defaults to `"main"` when a GitHub action ref omits `@version`.
|
||||
pub version: String,
|
||||
/// Optional sub-path within the repository for actions like `owner/repo/path@ref`.
|
||||
/// `None` for simple `owner/repo@ref`, Docker refs, and local paths.
|
||||
pub sub_path: Option<String>,
|
||||
pub is_docker: bool,
|
||||
pub is_local: bool,
|
||||
}
|
||||
@@ -193,10 +239,130 @@ fn normalize_triggers(on_value: &serde_yaml::Value) -> Result<Vec<String>, Strin
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::parse_workflow;
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn resolve_action_parses_version() {
|
||||
let wd = WorkflowDefinition {
|
||||
name: String::new(),
|
||||
on: vec![],
|
||||
on_raw: serde_yaml::Value::Null,
|
||||
jobs: Default::default(),
|
||||
};
|
||||
let info = wd.resolve_action("actions/checkout@v4");
|
||||
assert_eq!(info.repository, "actions/checkout");
|
||||
assert_eq!(info.version, "v4");
|
||||
assert!(info.sub_path.is_none());
|
||||
assert!(!info.is_docker);
|
||||
assert!(!info.is_local);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_action_defaults_version_to_main() {
|
||||
let wd = WorkflowDefinition {
|
||||
name: String::new(),
|
||||
on: vec![],
|
||||
on_raw: serde_yaml::Value::Null,
|
||||
jobs: Default::default(),
|
||||
};
|
||||
let info = wd.resolve_action("owner/repo");
|
||||
assert_eq!(info.repository, "owner/repo");
|
||||
assert_eq!(info.version, "main");
|
||||
assert!(info.sub_path.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_action_docker_reference() {
|
||||
let wd = WorkflowDefinition {
|
||||
name: String::new(),
|
||||
on: vec![],
|
||||
on_raw: serde_yaml::Value::Null,
|
||||
jobs: Default::default(),
|
||||
};
|
||||
let info = wd.resolve_action("docker://alpine:3.18");
|
||||
assert_eq!(info.repository, "docker://alpine:3.18");
|
||||
assert_eq!(info.version, "");
|
||||
assert!(info.is_docker);
|
||||
assert!(!info.is_local);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_action_local_path() {
|
||||
let wd = WorkflowDefinition {
|
||||
name: String::new(),
|
||||
on: vec![],
|
||||
on_raw: serde_yaml::Value::Null,
|
||||
jobs: Default::default(),
|
||||
};
|
||||
let info = wd.resolve_action("./my-action");
|
||||
assert_eq!(info.repository, "./my-action");
|
||||
assert_eq!(info.version, "");
|
||||
assert!(!info.is_docker);
|
||||
assert!(info.is_local);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_action_docker_with_digest() {
|
||||
let wd = WorkflowDefinition {
|
||||
name: String::new(),
|
||||
on: vec![],
|
||||
on_raw: serde_yaml::Value::Null,
|
||||
jobs: Default::default(),
|
||||
};
|
||||
// Docker image references can use @sha256:digest — the full string is the image ref
|
||||
let info = wd.resolve_action("docker://alpine@sha256:abcdef1234567890");
|
||||
assert_eq!(info.repository, "docker://alpine@sha256:abcdef1234567890");
|
||||
assert_eq!(info.version, "");
|
||||
assert!(info.is_docker);
|
||||
assert!(!info.is_local);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_action_with_sha_version() {
|
||||
let wd = WorkflowDefinition {
|
||||
name: String::new(),
|
||||
on: vec![],
|
||||
on_raw: serde_yaml::Value::Null,
|
||||
jobs: Default::default(),
|
||||
};
|
||||
let info = wd.resolve_action("actions/checkout@a81bbbf8298c0fa03ea29cdc473d45769f953675");
|
||||
assert_eq!(info.repository, "actions/checkout");
|
||||
assert_eq!(info.version, "a81bbbf8298c0fa03ea29cdc473d45769f953675");
|
||||
assert!(info.sub_path.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_action_with_sub_path() {
|
||||
let wd = WorkflowDefinition {
|
||||
name: String::new(),
|
||||
on: vec![],
|
||||
on_raw: serde_yaml::Value::Null,
|
||||
jobs: Default::default(),
|
||||
};
|
||||
let info = wd.resolve_action("owner/repo/path/to/action@v2");
|
||||
assert_eq!(info.repository, "owner/repo");
|
||||
assert_eq!(info.version, "v2");
|
||||
assert_eq!(info.sub_path.as_deref(), Some("path/to/action"));
|
||||
assert!(!info.is_docker);
|
||||
assert!(!info.is_local);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_action_with_single_sub_path() {
|
||||
let wd = WorkflowDefinition {
|
||||
name: String::new(),
|
||||
on: vec![],
|
||||
on_raw: serde_yaml::Value::Null,
|
||||
jobs: Default::default(),
|
||||
};
|
||||
let info = wd.resolve_action("github/codeql-action/init@v3");
|
||||
assert_eq!(info.repository, "github/codeql-action");
|
||||
assert_eq!(info.version, "v3");
|
||||
assert_eq!(info.sub_path.as_deref(), Some("init"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_workflow_allows_null_workflow_dispatch_with_other_triggers() {
|
||||
let temp_dir = tempdir().unwrap();
|
||||
|
||||
Reference in New Issue
Block a user