use std::collections::HashMap;
use crate::transform::render_exprstring;
use yah_qed_gha::{classify_workflow, Disposition, NativeReplacement, Workflow};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WorkflowPorts {
pub reusable: bool,
pub inputs: Vec<PortInput>,
pub secrets: Vec<PortSecret>,
pub outputs: Vec<PortOutput>,
pub tier3_assumptions: Vec<NativeReplacement>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PortInput {
pub name: String,
pub required: bool,
pub ty: Option<String>,
pub default: Option<String>,
pub description: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PortSecret {
pub name: String,
pub required: bool,
pub env_var: String,
pub description: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PortOutput {
pub name: String,
pub artifact: String,
pub value: Option<String>,
pub description: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PortError {
MissingRequired(Vec<String>),
}
impl WorkflowPorts {
pub fn resolve_inputs(
&self,
supplied: &HashMap<String, String>,
) -> Result<HashMap<String, String>, PortError> {
let mut resolved = HashMap::new();
let mut missing = Vec::new();
for input in &self.inputs {
if let Some(v) = supplied.get(&input.name) {
resolved.insert(input.name.clone(), v.clone());
} else if let Some(d) = &input.default {
resolved.insert(input.name.clone(), d.clone());
} else if input.required {
missing.push(input.name.clone());
}
}
if missing.is_empty() {
Ok(resolved)
} else {
Err(PortError::MissingRequired(missing))
}
}
pub fn output_artifacts(&self) -> impl Iterator<Item = &PortOutput> {
self.outputs.iter()
}
pub fn secret_env(&self) -> HashMap<String, String> {
self.secrets.iter().map(|s| (s.env_var.clone(), s.name.clone())).collect()
}
pub fn assumes(&self, facility: NativeReplacement) -> bool {
self.tier3_assumptions.contains(&facility)
}
}
pub fn workflow_ports(wf: &Workflow) -> WorkflowPorts {
let call = wf.triggers.workflow_call.as_ref();
let reusable = call.is_some();
let inputs = call
.map(|c| {
c.inputs
.iter()
.map(|(name, i)| PortInput {
name: name.clone(),
required: i.required.unwrap_or(false),
ty: i.r#type.clone(),
default: i.default.as_ref().map(render_exprstring),
description: i.description.clone(),
})
.collect()
})
.unwrap_or_default();
let secrets = call
.map(|c| {
c.secrets
.iter()
.map(|(name, s)| PortSecret {
name: name.clone(),
required: s.required.unwrap_or(false),
env_var: name.clone(),
description: s.description.clone(),
})
.collect()
})
.unwrap_or_default();
let outputs = call
.map(|c| {
c.outputs
.iter()
.map(|(name, o)| PortOutput {
name: name.clone(),
artifact: name.clone(),
value: o.value.as_ref().map(render_exprstring),
description: o.description.clone(),
})
.collect()
})
.unwrap_or_default();
WorkflowPorts { reusable, inputs, secrets, outputs, tier3_assumptions: tier3_assumptions(wf) }
}
fn tier3_assumptions(wf: &Workflow) -> Vec<NativeReplacement> {
let mut out: Vec<NativeReplacement> = Vec::new();
for classified in classify_workflow(wf) {
if let Disposition::ReplaceWithNative(nr) = classified.class.disposition {
if !out.contains(&nr) {
out.push(nr);
}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn wf(src: &str) -> Workflow {
yah_qed_gha::parse_workflow(src).expect("parse")
}
const REUSABLE: &str = r#"
name: build-and-publish
on:
workflow_call:
inputs:
tag:
required: true
type: string
channel:
required: false
type: string
default: stable
secrets:
CARGO_TOKEN:
required: true
outputs:
digest:
description: the built image digest
value: ${{ jobs.build.outputs.digest }}
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/upload-artifact@v4
- run: cargo build --release
"#;
#[test]
fn reusable_workflow_declares_typed_ports() {
let p = workflow_ports(&wf(REUSABLE));
assert!(p.reusable);
assert_eq!(p.inputs.len(), 2);
let tag = &p.inputs[0];
assert_eq!(tag.name, "tag");
assert!(tag.required);
assert_eq!(tag.ty.as_deref(), Some("string"));
let channel = &p.inputs[1];
assert!(!channel.required);
assert_eq!(channel.default.as_deref(), Some("stable"));
assert_eq!(p.secrets.len(), 1);
assert_eq!(p.secrets[0].name, "CARGO_TOKEN");
assert_eq!(p.secrets[0].env_var, "CARGO_TOKEN");
assert!(p.secrets[0].required);
assert_eq!(p.outputs.len(), 1);
assert_eq!(p.outputs[0].name, "digest");
assert_eq!(p.outputs[0].artifact, "digest");
assert_eq!(p.outputs[0].value.as_deref(), Some("${{ jobs.build.outputs.digest }}"));
}
#[test]
fn down_port_resolves_inputs_with_defaults_and_required() {
let p = workflow_ports(&wf(REUSABLE));
let err = p.resolve_inputs(&HashMap::new()).unwrap_err();
assert_eq!(err, PortError::MissingRequired(vec!["tag".into()]));
let supplied = HashMap::from([("tag".to_string(), "v1.2.3".to_string())]);
let resolved = p.resolve_inputs(&supplied).expect("resolves");
assert_eq!(resolved.get("tag").map(String::as_str), Some("v1.2.3"));
assert_eq!(resolved.get("channel").map(String::as_str), Some("stable"));
}
#[test]
fn secret_env_injection_map() {
let p = workflow_ports(&wf(REUSABLE));
let env = p.secret_env();
assert_eq!(env.get("CARGO_TOKEN").map(String::as_str), Some("CARGO_TOKEN"));
}
#[test]
fn explicit_tier3_boundary_declaration() {
let p = workflow_ports(&wf(REUSABLE));
assert!(p.assumes(NativeReplacement::Checkout));
assert!(p.assumes(NativeReplacement::UploadArtifact));
assert!(!p.assumes(NativeReplacement::ReleasePublisher));
assert_eq!(
p.tier3_assumptions,
vec![NativeReplacement::Checkout, NativeReplacement::UploadArtifact]
);
}
#[test]
fn non_reusable_workflow_has_no_ports_but_keeps_tier3_declaration() {
let src = r#"
on: push
jobs:
a:
runs-on: x
steps:
- uses: actions/checkout@v4
- run: make
"#;
let p = workflow_ports(&wf(src));
assert!(!p.reusable);
assert!(p.inputs.is_empty());
assert!(p.secrets.is_empty());
assert!(p.outputs.is_empty());
assert_eq!(p.tier3_assumptions, vec![NativeReplacement::Checkout]);
}
#[test]
fn tier3_assumptions_dedupe_across_jobs() {
let src = r#"
on: workflow_call
jobs:
a:
runs-on: x
steps:
- uses: actions/checkout@v4
b:
runs-on: x
steps:
- uses: actions/checkout@v4
- uses: actions/cache@v4
"#;
let p = workflow_ports(&wf(src));
assert_eq!(
p.tier3_assumptions,
vec![NativeReplacement::Checkout, NativeReplacement::ContentAddressedCache]
);
}
#[test]
fn output_artifacts_iterator_exposes_up_port() {
let p = workflow_ports(&wf(REUSABLE));
let arts: Vec<&str> = p.output_artifacts().map(|o| o.artifact.as_str()).collect();
assert_eq!(arts, vec!["digest"]);
}
}