use crate::matrix::MatrixSpec;
use crate::platform::PlatformSpec;
use crate::types::{OnFail, QedStep};
use indexmap::IndexMap;
use yah_qed_gha::{
classify_step, topo_sort, Disposition, ExprString, ExprToken, Job, NativeReplacement,
ServiceTouch, Step, StepAction, Workflow,
};
#[derive(Debug, Clone)]
pub struct TransformReport {
pub name: String,
pub label: String,
pub steps: Vec<TransformedStep>,
}
impl TransformReport {
pub fn native_steps(&self) -> impl Iterator<Item = &QedStep> {
self.steps.iter().filter_map(|s| s.native.as_ref())
}
pub fn collect_native(&self) -> Vec<QedStep> {
self.native_steps().cloned().collect()
}
pub fn flags(&self) -> impl Iterator<Item = (&TransformedStep, &FlagKind)> {
self.steps.iter().flat_map(|s| s.flags.iter().map(move |f| (s, f)))
}
pub fn is_clean(&self) -> bool {
self.steps.iter().all(|s| s.flags.is_empty() && s.native.is_some())
}
}
#[derive(Debug, Clone)]
pub struct TransformedStep {
pub job: String,
pub step_index: usize,
pub step_name: Option<String>,
pub native: Option<QedStep>,
pub flags: Vec<FlagKind>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FlagKind {
ReplaceWithNative(NativeReplacement),
EmbeddedServiceTouch(Vec<ServiceTouch>),
ToolkitAction { slug: String, git_ref: Option<String> },
Unknown { slug: String },
UnresolvedExpression,
UnbridgedSecret { names: Vec<String> },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FlagSeverity {
Replace,
Review,
Info,
}
impl FlagSeverity {
pub fn label(self) -> &'static str {
match self {
FlagSeverity::Replace => "replace",
FlagSeverity::Review => "review",
FlagSeverity::Info => "info",
}
}
}
impl FlagKind {
pub fn severity(&self) -> FlagSeverity {
match self {
FlagKind::ReplaceWithNative(_) => FlagSeverity::Replace,
FlagKind::EmbeddedServiceTouch(_)
| FlagKind::Unknown { .. }
| FlagKind::UnbridgedSecret { .. } => FlagSeverity::Review,
FlagKind::ToolkitAction { .. } | FlagKind::UnresolvedExpression => FlagSeverity::Info,
}
}
pub fn stanza_hint(&self) -> String {
match self {
FlagKind::ReplaceWithNative(nr) => nr.stanza_hint().to_string(),
FlagKind::EmbeddedServiceTouch(touches) => {
let names: Vec<&str> = touches.iter().map(|t| t.label()).collect();
format!(
"Run step reaches GitHub-the-service ({}); it runs on the executor but the \
call won't resolve on QED — replace with a native facility \
(content-addressed artifacts / a W208 publisher).",
names.join(", ")
)
}
FlagKind::ToolkitAction { slug, .. } => format!(
"Tier-1/2 toolkit action `{slug}` — runs via the toolkit-contract executor \
(R533-T7); no native subprocess emitted yet."
),
FlagKind::Unknown { slug } => format!(
"Unrecognized action `{slug}` — map it by hand or extend the tier catalog \
(qed-gha `classify_uses`); not run silently."
),
FlagKind::UnresolvedExpression => {
"Step carries GHA `${{ … }}` expressions (in the script or its `env:`) that \
QED's subprocess substitution won't expand; convert to QED params \
(`{{key}}`) / native outputs, or lift the build target at import time \
(R533-F9)."
.to_string()
}
FlagKind::UnbridgedSecret { names } => format!(
"References secret(s) {} which lower to the LITERAL text `${{{{ secrets.NAME }}}}`, \
not to a value — a non-empty string that defeats an `if [ -z \"$TOKEN\" ]` guard \
and is then sent as a live-looking credential. Bridge each name in \
`~/.yah/qed/secrets.toml` (`NAME = \"vault:<slot>\"`) before running this step.",
names.join(", ")
),
}
}
}
pub fn transform_workflow(wf: &Workflow) -> TransformReport {
let label = wf
.name
.clone()
.unwrap_or_else(|| "imported workflow".to_string());
let name = slugify(&label, "imported-workflow");
let sorted = topo_sort(wf).ok();
let order: Vec<String> = match &sorted {
Some(waves) => waves.iter().flatten().cloned().collect(),
None => wf.jobs.keys().cloned().collect(),
};
let mut steps: Vec<TransformedStep> = Vec::new();
let mut spans: Vec<(String, Vec<usize>)> = Vec::new();
for job_id in &order {
let Some(job) = wf.jobs.get(job_id) else { continue };
let mut native_at: Vec<usize> = Vec::new();
for (step_index, step) in job.steps.iter().enumerate() {
let t = transform_step(job_id, job, step_index, step);
if t.native.is_some() {
native_at.push(steps.len());
}
steps.push(t);
}
spans.push((job_id.clone(), native_at));
}
dedupe_native_names(&mut steps);
if sorted.is_some() {
apply_job_edges(wf, &mut steps, &spans);
}
TransformReport { name, label, steps }
}
fn dedupe_native_names(steps: &mut [TransformedStep]) {
let mut seen: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
for t in steps.iter_mut() {
let Some(native) = t.native.as_mut() else { continue };
let count = seen.entry(native.name.clone()).or_insert(0);
*count += 1;
if *count > 1 {
native.name = format!("{} ({})", native.name, count);
}
}
}
fn apply_job_edges(wf: &Workflow, steps: &mut [TransformedStep], spans: &[(String, Vec<usize>)]) {
let mut tails: IndexMap<&str, Vec<String>> = IndexMap::new();
for (job_id, native_at) in spans {
let tail = match native_at.last() {
Some(&last) => vec![steps[last]
.native
.as_ref()
.expect("index recorded only for native steps")
.name
.clone()],
None => predecessor_tails(wf, job_id, &tails),
};
tails.insert(job_id.as_str(), tail);
}
for (job_id, native_at) in spans {
let Some(&first) = native_at.first() else { continue };
let deps = predecessor_tails(wf, job_id, &tails);
steps[first]
.native
.as_mut()
.expect("index recorded only for native steps")
.needs = Some(deps);
}
}
fn predecessor_tails(wf: &Workflow, job_id: &str, tails: &IndexMap<&str, Vec<String>>) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
let Some(job) = wf.jobs.get(job_id) else {
return out;
};
for need in &job.needs {
for name in tails.get(need.as_str()).into_iter().flatten() {
if !out.contains(name) {
out.push(name.clone());
}
}
}
out
}
pub fn transform_workflow_src(src: &str) -> Result<TransformReport, yah_qed_gha::ParseError> {
Ok(transform_workflow(&yah_qed_gha::parse_workflow(src)?))
}
fn transform_step(job_id: &str, job: &Job, step_index: usize, step: &Step) -> TransformedStep {
let step_name = step.name.as_ref().map(render_exprstring).map(|s| s.trim().to_string());
let class = classify_step(step);
let mut native = None;
let mut flags = Vec::new();
match (&step.action, &class.disposition) {
(StepAction::Run { body, shell }, Disposition::Compute) => {
let (step_native, lifted_key) =
map_run_step(job_id, job, step_index, step, body, shell.as_deref());
native = Some(step_native);
if !class.service_touches.is_empty() {
flags.push(FlagKind::EmbeddedServiceTouch(class.service_touches.clone()));
}
let key = lifted_key.as_deref();
if has_unresolved_expression(body, key)
|| step.env.values().any(|v| has_unresolved_expression(v, key))
{
flags.push(FlagKind::UnresolvedExpression);
}
let secrets = referenced_secrets(step, body);
if !secrets.is_empty() {
flags.push(FlagKind::UnbridgedSecret { names: secrets });
}
}
(StepAction::Uses { slug, git_ref, .. }, Disposition::Compute) => {
flags.push(FlagKind::ToolkitAction { slug: slug.clone(), git_ref: git_ref.clone() });
}
(_, Disposition::ReplaceWithNative(nr)) => {
flags.push(FlagKind::ReplaceWithNative(*nr));
}
(StepAction::Uses { slug, .. }, Disposition::Unknown) => {
flags.push(FlagKind::Unknown { slug: slug.clone() });
}
(StepAction::Run { body, shell }, Disposition::Unknown) => {
native = Some(map_run_step(job_id, job, step_index, step, body, shell.as_deref()).0);
}
}
TransformedStep { job: job_id.to_string(), step_index, step_name, native, flags }
}
fn map_run_step(
job_id: &str,
job: &Job,
step_index: usize,
step: &Step,
body: &ExprString,
shell: Option<&str>,
) -> (QedStep, Option<String>) {
let name = match step.name.as_ref().map(render_exprstring) {
Some(n) if !n.trim().is_empty() => format!("{job_id}: {}", n.trim()),
_ => format!("{job_id}: step {step_index}"),
};
let script = render_exprstring(body);
let argv = shell_argv(shell, &script);
let env = step
.env
.iter()
.map(|(k, v)| (k.clone(), render_exprstring(v)))
.collect();
let cwd = step
.working_directory
.as_ref()
.map(render_exprstring)
.filter(|s| !s.is_empty());
let timeout = step.timeout_minutes.map(|m| u64::from(m) * 60);
let on_fail = if step.continue_on_error == Some(true) {
OnFail::Continue
} else {
OnFail::Abort
};
let if_cond = step.if_cond.as_ref().map(render_exprstring);
let (platform, matrix, lifted_key) = lift_target(job, &script);
let step = QedStep {
background: false,
background_until: None,
wait_for: None,
manual: None,
argv,
cwd,
env,
timeout,
on_fail,
if_cond,
platform,
matrix,
..base_step(name)
};
(step, lifted_key)
}
fn lift_target(job: &Job, script: &str) -> (Option<PlatformSpec>, Option<MatrixSpec>, Option<String>) {
let Some(raw_target) = extract_target(script) else {
return (None, None, None);
};
let platform = Some(PlatformSpec {
target: Some(raw_target.clone()),
container_platform: None,
native: false,
});
if let Some(key) = matrix_ref_key(&raw_target) {
let values = matrix_target_values(job, &key);
if !values.is_empty() {
return (platform, Some(target_matrix(&key, &values)), Some(key));
}
}
(platform, None, None)
}
fn extract_target(script: &str) -> Option<String> {
const FLAG: &str = "--target";
let mut from = 0;
while let Some(rel) = script[from..].find(FLAG) {
let pos = from + rel;
let after = &script[pos + FLAG.len()..];
from = pos + FLAG.len();
let mut chars = after.chars();
match chars.next() {
Some('=') => {
if let Some(v) = read_target_value(&after[1..]) {
return Some(v);
}
}
Some(c) if c.is_whitespace() => {
if let Some(v) = read_target_value(after) {
return Some(v);
}
}
_ => {}
}
}
None
}
fn read_target_value(s: &str) -> Option<String> {
let s = s.trim_start();
if let Some(rest) = s.strip_prefix("${{") {
let end = rest.find("}}")?;
return Some(format!("${{{{ {} }}}}", rest[..end].trim()));
}
let val: String = s.chars().take_while(|c| !c.is_whitespace()).collect();
(!val.is_empty()).then_some(val)
}
fn matrix_ref_key(value: &str) -> Option<String> {
let inner = value.trim().strip_prefix("${{")?.strip_suffix("}}")?.trim();
let key = inner.strip_prefix("matrix.")?.trim();
(!key.is_empty() && !key.contains(char::is_whitespace)).then(|| key.to_string())
}
fn matrix_target_values(job: &Job, key: &str) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
let mut push = |s: String| {
if !out.contains(&s) {
out.push(s);
}
};
if let Some(matrix) = job.strategy.as_ref().and_then(|s| s.matrix.as_ref()) {
if let Some(values) = matrix.dimensions.get(key) {
for v in values {
if let Some(s) = v.as_str() {
push(s.to_string());
}
}
}
for inc in &matrix.include {
if let Some(s) = inc.get(key).and_then(|v| v.as_str()) {
push(s.to_string());
}
}
}
out
}
fn target_matrix(key: &str, values: &[String]) -> MatrixSpec {
let mut dimensions: IndexMap<String, Vec<toml::Value>> = IndexMap::new();
dimensions.insert(
key.to_string(),
values.iter().map(|s| toml::Value::String(s.clone())).collect(),
);
MatrixSpec { dimensions, include: Vec::new(), exclude: Vec::new() }
}
fn base_step(name: String) -> QedStep {
QedStep {
name,
..Default::default()
}
}
fn shell_argv(shell: Option<&str>, script: &str) -> Vec<String> {
let argv = |prog: &str, flag: &str, body: String| {
vec![prog.to_string(), flag.to_string(), body]
};
match shell.unwrap_or("bash") {
"bash" => argv("bash", "-c", format!("set -eo pipefail\n{script}")),
"sh" => argv("sh", "-c", format!("set -e\n{script}")),
"pwsh" | "powershell" => argv("pwsh", "-Command", script.to_string()),
"python" | "python3" => argv("python3", "-c", script.to_string()),
other => argv(other, "-c", script.to_string()),
}
}
pub(crate) fn render_exprstring(s: &ExprString) -> String {
let mut out = String::new();
for t in &s.tokens {
match t {
ExprToken::Literal(x) => out.push_str(x),
ExprToken::Expr(x) => {
out.push_str("${{ ");
out.push_str(x);
out.push_str(" }}");
}
}
}
out
}
fn has_unresolved_expression(s: &ExprString, resolved_matrix_key: Option<&str>) -> bool {
s.tokens.iter().any(|t| match t {
ExprToken::Literal(_) => false,
ExprToken::Expr(raw) => match (raw.trim().strip_prefix("matrix."), resolved_matrix_key) {
(Some(k), Some(rk)) => k.trim() != rk,
_ => true,
},
})
}
fn referenced_secrets(step: &Step, body: &ExprString) -> Vec<String> {
let mut names: Vec<String> = std::iter::once(body)
.chain(step.env.values())
.flat_map(|s| s.tokens.iter())
.filter_map(|t| match t {
ExprToken::Expr(raw) => raw.trim().strip_prefix("secrets."),
ExprToken::Literal(_) => None,
})
.map(|n| n.trim().to_string())
.filter(|n| !n.is_empty() && n != "GITHUB_TOKEN")
.collect();
names.sort();
names.dedup();
names
}
fn slugify(name: &str, fallback: &str) -> String {
let mut out = String::new();
let mut prev_dash = false;
for ch in name.chars() {
if ch.is_ascii_alphanumeric() {
out.push(ch.to_ascii_lowercase());
prev_dash = false;
} else if !prev_dash {
out.push('-');
prev_dash = true;
}
}
let slug = out.trim_matches('-').to_string();
if slug.is_empty() {
fallback.to_string()
} else {
slug
}
}
#[cfg(test)]
mod tests {
use super::*;
fn xf(src: &str) -> TransformReport {
transform_workflow_src(src).expect("parse")
}
fn native_at<'a>(r: &'a TransformReport, job: &str, idx: usize) -> &'a QedStep {
r.steps
.iter()
.find(|s| s.job == job && s.step_index == idx)
.and_then(|s| s.native.as_ref())
.unwrap_or_else(|| panic!("no native step at {job}[{idx}]"))
}
fn flags_at<'a>(r: &'a TransformReport, job: &str, idx: usize) -> &'a [FlagKind] {
&r.steps
.iter()
.find(|s| s.job == job && s.step_index == idx)
.unwrap_or_else(|| panic!("no step at {job}[{idx}]"))
.flags
}
const RUN_JOB: &str = r#"
name: ci
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Compile
run: cargo build --release
env:
RUSTFLAGS: "-D warnings"
working-directory: app
timeout-minutes: 20
continue-on-error: true
"#;
#[test]
fn run_step_maps_to_native_subprocess() {
let r = xf(RUN_JOB);
let step = native_at(&r, "build", 0);
assert_eq!(step.name, "build: Compile");
assert_eq!(step.kind, crate::types::StepKind::Subprocess);
assert_eq!(step.argv[0], "bash");
assert_eq!(step.argv[1], "-c");
assert!(step.argv[2].starts_with("set -eo pipefail\n"));
assert!(step.argv[2].contains("cargo build --release"));
assert_eq!(step.env.get("RUSTFLAGS").map(String::as_str), Some("-D warnings"));
assert_eq!(step.cwd.as_deref(), Some("app"));
assert_eq!(step.timeout, Some(20 * 60));
assert!(matches!(step.on_fail, OnFail::Continue));
assert!(flags_at(&r, "build", 0).is_empty(), "clean compute → no flags");
}
#[test]
fn pipeline_name_is_slugified_from_workflow_name() {
let r = xf("name: My Release Flow!\non: push\njobs:\n a:\n runs-on: x\n steps:\n - run: true\n");
assert_eq!(r.name, "my-release-flow");
assert_eq!(r.label, "My Release Flow!");
}
#[test]
fn unnamed_workflow_falls_back() {
let r = xf("on: push\njobs:\n a:\n runs-on: x\n steps:\n - run: true\n");
assert_eq!(r.name, "imported-workflow");
}
#[test]
fn tier3_checkout_is_flagged_not_mapped() {
let r = xf("on: push\njobs:\n a:\n runs-on: x\n steps:\n - uses: actions/checkout@v4\n");
let step = &r.steps[0];
assert!(step.native.is_none(), "tier-3 emits no native step");
assert_eq!(
step.flags,
vec![FlagKind::ReplaceWithNative(NativeReplacement::Checkout)]
);
assert_eq!(step.flags[0].severity(), FlagSeverity::Replace);
assert!(step.flags[0].stanza_hint().contains("checkout is implicit"));
}
#[test]
fn tier3_upload_artifact_proposes_content_addressed_output() {
let r = xf("on: push\njobs:\n a:\n runs-on: x\n steps:\n - uses: actions/upload-artifact@v4\n");
assert_eq!(
r.steps[0].flags,
vec![FlagKind::ReplaceWithNative(NativeReplacement::UploadArtifact)]
);
assert!(r.steps[0].flags[0].stanza_hint().contains("content-addressed output"));
}
#[test]
fn compute_uses_is_a_toolkit_action_flag() {
let r = xf("on: push\njobs:\n a:\n runs-on: x\n steps:\n - uses: actions/setup-node@v4\n");
let step = &r.steps[0];
assert!(step.native.is_none(), "no subprocess until the T7 executor");
assert_eq!(
step.flags,
vec![FlagKind::ToolkitAction {
slug: "actions/setup-node".into(),
git_ref: Some("v4".into()),
}]
);
assert_eq!(step.flags[0].severity(), FlagSeverity::Info);
}
#[test]
fn unknown_uses_is_flagged_for_review() {
let r = xf("on: push\njobs:\n a:\n runs-on: x\n steps:\n - uses: some-org/exotic@v1\n");
let step = &r.steps[0];
assert!(step.native.is_none());
assert_eq!(step.flags, vec![FlagKind::Unknown { slug: "some-org/exotic".into() }]);
assert_eq!(step.flags[0].severity(), FlagSeverity::Review);
}
#[test]
fn run_step_with_gh_cli_runs_but_is_flagged() {
let r = xf("on: push\njobs:\n a:\n runs-on: x\n steps:\n - run: gh release create v1 ./dist/*\n");
let step = &r.steps[0];
assert!(step.native.is_some(), "still runs on the executor");
assert_eq!(
step.flags,
vec![FlagKind::EmbeddedServiceTouch(vec![ServiceTouch::GhCli])]
);
assert_eq!(step.flags[0].severity(), FlagSeverity::Review);
}
#[test]
fn run_step_with_expression_is_flagged_unresolved() {
let r = xf("on: push\njobs:\n a:\n runs-on: x\n steps:\n - run: echo ${{ github.sha }}\n");
let step = &r.steps[0];
assert!(step.native.is_some());
assert!(step.native.as_ref().unwrap().argv[2].contains("${{ github.sha }}"));
assert!(step.flags.contains(&FlagKind::UnresolvedExpression));
}
#[test]
fn jobs_flatten_in_topological_order() {
let src = r#"
on: push
jobs:
publish:
needs: build
runs-on: x
steps:
- run: echo publish
build:
runs-on: x
steps:
- run: echo build
"#;
let r = xf(src);
let jobs: Vec<&str> = r.steps.iter().map(|s| s.job.as_str()).collect();
assert_eq!(jobs, vec!["build", "publish"]);
}
#[test]
fn shell_variants_select_the_right_interpreter() {
let r = xf("on: push\njobs:\n a:\n runs-on: x\n steps:\n - run: print(1)\n shell: python\n");
let step = native_at(&r, "a", 0);
assert_eq!(step.argv[0], "python3");
assert_eq!(step.argv[1], "-c");
assert_eq!(step.argv[2], "print(1)");
}
#[test]
fn report_accessors_partition_native_and_flagged() {
let src = r#"
on: push
jobs:
a:
runs-on: x
steps:
- run: cargo test
- uses: actions/checkout@v4
"#;
let r = xf(src);
assert_eq!(r.collect_native().len(), 1, "only the run step is native");
assert_eq!(r.flags().count(), 1, "only checkout flags");
assert!(!r.is_clean(), "a tier-3 step is present");
}
#[test]
fn unnamed_run_step_gets_positional_name() {
let r = xf("on: push\njobs:\n b:\n runs-on: x\n steps:\n - run: make\n");
assert_eq!(native_at(&r, "b", 0).name, "b: step 0");
}
#[test]
fn concrete_target_lifts_into_platform_no_matrix() {
let r = xf("on: push\njobs:\n a:\n runs-on: x\n steps:\n - run: cargo build --target x86_64-unknown-linux-musl --release\n");
let step = native_at(&r, "a", 0);
let p = step.platform.as_ref().expect("platform lifted");
assert_eq!(p.target.as_deref(), Some("x86_64-unknown-linux-musl"));
assert!(step.matrix.is_none(), "a concrete target needs no matrix");
}
#[test]
fn target_equals_form_is_recognized() {
let r = xf("on: push\njobs:\n a:\n runs-on: x\n steps:\n - run: cross build --target=aarch64-unknown-linux-gnu\n");
let p = native_at(&r, "a", 0).platform.as_ref().expect("platform");
assert_eq!(p.target.as_deref(), Some("aarch64-unknown-linux-gnu"));
}
#[test]
fn target_dir_is_not_mistaken_for_target() {
let r = xf("on: push\njobs:\n a:\n runs-on: x\n steps:\n - run: cargo build --target-dir /tmp/out\n");
assert!(native_at(&r, "a", 0).platform.is_none(), "--target-dir is not --target");
}
#[test]
fn step_without_target_has_no_platform() {
let r = xf(RUN_JOB);
assert!(native_at(&r, "build", 0).platform.is_none());
}
#[test]
fn matrix_target_dimension_lifts_and_carries_step_matrix() {
let src = r#"
on: push
jobs:
build:
runs-on: x
strategy:
matrix:
target:
- x86_64-unknown-linux-musl
- aarch64-unknown-linux-musl
steps:
- run: cross build --target ${{ matrix.target }}
"#;
let r = xf(src);
let step = native_at(&r, "build", 0);
assert_eq!(
step.platform.as_ref().unwrap().target.as_deref(),
Some("${{ matrix.target }}")
);
let m = step.matrix.as_ref().expect("step matrix carried");
let vals = m.dimensions.get("target").expect("target dimension");
assert_eq!(vals.len(), 2);
assert!(flags_at(&r, "build", 0).is_empty());
}
#[test]
fn matrix_target_from_include_rows() {
let src = r#"
on: push
jobs:
cli:
runs-on: x
strategy:
matrix:
include:
- target: x86_64-apple-darwin
- target: aarch64-apple-darwin
steps:
- run: cargo build --target ${{ matrix.target }}
"#;
let r = xf(src);
let m = native_at(&r, "cli", 0).matrix.as_ref().expect("matrix from include rows");
let vals = m.dimensions.get("target").expect("target dimension");
assert_eq!(vals.len(), 2);
}
fn needs_of<'a>(r: &'a TransformReport, step_name: &str) -> Option<&'a Vec<String>> {
r.native_steps()
.find(|s| s.name == step_name)
.unwrap_or_else(|| panic!("no native step named `{step_name}`"))
.needs
.as_ref()
}
#[test]
fn a_job_diamond_becomes_a_step_dag() {
let src = r#"
on: push
jobs:
setup:
runs-on: x
steps:
- name: prep
run: echo prep
left:
needs: setup
runs-on: x
steps:
- name: one
run: echo l1
- name: two
run: echo l2
right:
needs: setup
runs-on: x
steps:
- name: one
run: echo r1
join:
needs: [left, right]
runs-on: x
steps:
- name: fin
run: echo done
"#;
let r = xf(src);
assert_eq!(needs_of(&r, "setup: prep"), Some(&vec![]));
assert_eq!(needs_of(&r, "left: one"), Some(&vec!["setup: prep".to_string()]));
assert_eq!(needs_of(&r, "right: one"), Some(&vec!["setup: prep".to_string()]));
assert_eq!(needs_of(&r, "left: two"), None);
assert_eq!(
needs_of(&r, "join: fin"),
Some(&vec!["left: two".to_string(), "right: one".to_string()])
);
}
#[test]
fn a_job_with_no_native_steps_is_transparent_in_the_graph() {
let src = r#"
on: push
jobs:
build:
runs-on: x
steps:
- name: compile
run: cargo build
stage:
needs: build
runs-on: x
steps:
- uses: actions/upload-artifact@v4
ship:
needs: stage
runs-on: x
steps:
- name: publish
run: echo ship
"#;
let r = xf(src);
assert_eq!(
needs_of(&r, "ship: publish"),
Some(&vec!["build: compile".to_string()]),
"the all-tier-3 `stage` job emits nothing, so `ship` hangs off `build`",
);
}
#[test]
fn the_emitted_graph_resolves_and_fans_out() {
let src = r#"
on: push
jobs:
a:
runs-on: x
steps:
- name: s
run: echo a
b:
runs-on: x
steps:
- name: s
run: echo b
c:
needs: [a, b]
runs-on: x
steps:
- name: s
run: echo c
"#;
let r = xf(src);
let steps = r.collect_native();
let waves = crate::dag::waves(&steps, crate::dag::Missing::Reject)
.expect("the emitted graph resolves");
assert_eq!(
waves,
vec![vec![0, 1], vec![2]],
"two independent jobs share a wave; their dependent follows",
);
}
#[test]
fn duplicate_step_names_within_a_job_are_disambiguated() {
let src = r#"
on: push
jobs:
a:
runs-on: x
steps:
- name: build
run: echo one
- name: build
run: echo two
b:
needs: a
runs-on: x
steps:
- name: ship
run: echo three
"#;
let r = xf(src);
let names: Vec<&str> = r.native_steps().map(|s| s.name.as_str()).collect();
assert_eq!(names, vec!["a: build", "a: build (2)", "b: ship"]);
assert_eq!(needs_of(&r, "b: ship"), Some(&vec!["a: build (2)".to_string()]));
assert!(crate::dag::waves(&r.collect_native(), crate::dag::Missing::Reject).is_ok());
}
#[test]
fn a_cyclic_job_graph_imports_flat_with_no_edges() {
let src = r#"
on: push
jobs:
a:
needs: b
runs-on: x
steps:
- name: s
run: echo a
b:
needs: a
runs-on: x
steps:
- name: s
run: echo b
"#;
let r = xf(src);
assert!(
r.native_steps().all(|s| s.needs.is_none()),
"no synthesized edges off a graph that doesn't resolve",
);
assert!(crate::dag::waves(&r.collect_native(), crate::dag::Missing::Reject).is_ok());
}
fn release_yml() -> Option<String> {
let mut dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
loop {
let cand = dir.join(".github/workflows/release.yml");
if cand.is_file() && dir.join(".yah/camp.toml").is_file() {
return std::fs::read_to_string(cand).ok();
}
if !dir.pop() {
return None;
}
}
}
#[test]
fn env_secret_is_flagged_for_review_not_buried_in_info() {
let r = xf(r#"
on: [push]
jobs:
sweep:
runs-on: ubuntu-latest
steps:
- name: Sweep
env:
HETZNER_API_TOKEN: ${{ secrets.HETZNER_API_TOKEN }}
run: |
if [ -z "$HETZNER_API_TOKEN" ]; then exit 0; fi
curl -H "Authorization: Bearer $HETZNER_API_TOKEN" https://api.example.com
"#);
let flag = r
.flags()
.map(|(_, f)| f)
.find(|f| matches!(f, FlagKind::UnbridgedSecret { .. }))
.expect("env secret is flagged");
assert_eq!(
flag,
&FlagKind::UnbridgedSecret { names: vec!["HETZNER_API_TOKEN".into()] }
);
assert_eq!(
flag.severity(),
FlagSeverity::Review,
"a credential that lowers to a plausible non-empty literal is not informational"
);
assert!(flag.stanza_hint().contains("secrets.toml"));
}
#[test]
fn secrets_in_the_script_body_are_collected_deduped_and_sorted() {
let r = xf(r#"
on: [push]
jobs:
pub:
runs-on: ubuntu-latest
steps:
- run: |
echo "${{ secrets.R2_SECRET }}" > /dev/null
echo "${{ secrets.CF_TOKEN }}"
echo "${{ secrets.R2_SECRET }}"
"#);
let flag = r
.flags()
.map(|(_, f)| f)
.find(|f| matches!(f, FlagKind::UnbridgedSecret { .. }))
.expect("script secrets are flagged");
assert_eq!(
flag,
&FlagKind::UnbridgedSecret {
names: vec!["CF_TOKEN".into(), "R2_SECRET".into()]
}
);
}
#[test]
fn github_token_is_not_reported_as_bridgeable() {
let r = xf(r#"
on: [push]
jobs:
rel:
runs-on: ubuntu-latest
steps:
- env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: gh release upload v1 ./dist/x
"#);
assert!(
!r.flags().any(|(_, f)| matches!(f, FlagKind::UnbridgedSecret { .. })),
"GITHUB_TOKEN must not be offered as a bridgeable secret"
);
}
#[test]
fn a_step_with_no_secrets_raises_no_secret_flag() {
let r = xf(r#"
on: [push]
jobs:
build:
runs-on: ubuntu-latest
steps:
- env:
RUSTFLAGS: -D warnings
run: cargo build --release
"#);
assert!(!r.flags().any(|(_, f)| matches!(f, FlagKind::UnbridgedSecret { .. })));
}
#[test]
fn release_yml_transforms_end_to_end() {
let Some(src) = release_yml() else {
eprintln!("skip: yah workflow fixtures not present");
return;
};
let r = xf(&src);
assert!(!r.steps.is_empty(), "release.yml has steps");
let checkout = r
.steps
.iter()
.find(|s| s.job == "smoke" && s.step_index == 0)
.expect("smoke step 0");
assert!(checkout.native.is_none());
assert!(checkout
.flags
.contains(&FlagKind::ReplaceWithNative(NativeReplacement::Checkout)));
assert!(r.flags().any(|(_, f)| matches!(
f,
FlagKind::ReplaceWithNative(NativeReplacement::RegistryPublish)
)));
assert!(
r.native_steps().any(|s| s.argv.first().map(String::as_str) == Some("bash")
&& s.argv.last().is_some_and(|c| c.contains("cargo"))),
"at least one native cargo build step",
);
let first_idx = |job: &str| r.steps.iter().position(|s| s.job == job);
let (build, smoke, publish) =
(first_idx("cli-build"), first_idx("smoke"), first_idx("publish-cli"));
if let (Some(b), Some(s), Some(p)) = (build, smoke, publish) {
assert!(b < s, "cli-build before smoke");
assert!(s < p, "smoke before publish-cli");
}
let native = r.collect_native();
let waves = crate::dag::waves(&native, crate::dag::Missing::Reject)
.expect("release.yml's emitted graph resolves");
assert!(
waves.len() < native.len(),
"release.yml has independent jobs, so the ported graph must have at \
least one wave with more than one step in it ({} waves over {} steps)",
waves.len(),
native.len(),
);
}
}