1use crate::matrix::MatrixSpec;
39use crate::platform::PlatformSpec;
40use crate::types::{OnFail, QedStep, StepActivation, StepKind};
41use indexmap::IndexMap;
42use yah_qed_gha::{
43 classify_step, topo_sort, Disposition, ExprString, ExprToken, Job, NativeReplacement,
44 ServiceTouch, Step, StepAction, Workflow,
45};
46
47#[derive(Debug, Clone)]
52pub struct TransformReport {
53 pub name: String,
56 pub label: String,
58 pub steps: Vec<TransformedStep>,
62}
63
64impl TransformReport {
65 pub fn native_steps(&self) -> impl Iterator<Item = &QedStep> {
68 self.steps.iter().filter_map(|s| s.native.as_ref())
69 }
70
71 pub fn collect_native(&self) -> Vec<QedStep> {
74 self.native_steps().cloned().collect()
75 }
76
77 pub fn flags(&self) -> impl Iterator<Item = (&TransformedStep, &FlagKind)> {
79 self.steps.iter().flat_map(|s| s.flags.iter().map(move |f| (s, f)))
80 }
81
82 pub fn is_clean(&self) -> bool {
85 self.steps.iter().all(|s| s.flags.is_empty() && s.native.is_some())
86 }
87}
88
89#[derive(Debug, Clone)]
94pub struct TransformedStep {
95 pub job: String,
97 pub step_index: usize,
99 pub step_name: Option<String>,
101 pub native: Option<QedStep>,
105 pub flags: Vec<FlagKind>,
107}
108
109#[derive(Debug, Clone, PartialEq, Eq)]
112pub enum FlagKind {
113 ReplaceWithNative(NativeReplacement),
116 EmbeddedServiceTouch(Vec<ServiceTouch>),
120 ToolkitAction { slug: String, git_ref: Option<String> },
124 Unknown { slug: String },
126 UnresolvedExpression,
131}
132
133#[derive(Debug, Clone, Copy, PartialEq, Eq)]
135pub enum FlagSeverity {
136 Replace,
139 Review,
141 Info,
143}
144
145impl FlagSeverity {
146 pub fn label(self) -> &'static str {
147 match self {
148 FlagSeverity::Replace => "replace",
149 FlagSeverity::Review => "review",
150 FlagSeverity::Info => "info",
151 }
152 }
153}
154
155impl FlagKind {
156 pub fn severity(&self) -> FlagSeverity {
158 match self {
159 FlagKind::ReplaceWithNative(_) => FlagSeverity::Replace,
160 FlagKind::EmbeddedServiceTouch(_) | FlagKind::Unknown { .. } => FlagSeverity::Review,
161 FlagKind::ToolkitAction { .. } | FlagKind::UnresolvedExpression => FlagSeverity::Info,
162 }
163 }
164
165 pub fn stanza_hint(&self) -> String {
168 match self {
169 FlagKind::ReplaceWithNative(nr) => nr.stanza_hint().to_string(),
170 FlagKind::EmbeddedServiceTouch(touches) => {
171 let names: Vec<&str> = touches.iter().map(|t| t.label()).collect();
172 format!(
173 "Run step reaches GitHub-the-service ({}); it runs on the executor but the \
174 call won't resolve on QED — replace with a native facility \
175 (content-addressed artifacts / a W208 publisher).",
176 names.join(", ")
177 )
178 }
179 FlagKind::ToolkitAction { slug, .. } => format!(
180 "Tier-1/2 toolkit action `{slug}` — runs via the toolkit-contract executor \
181 (R533-T7); no native subprocess emitted yet."
182 ),
183 FlagKind::Unknown { slug } => format!(
184 "Unrecognized action `{slug}` — map it by hand or extend the tier catalog \
185 (qed-gha `classify_uses`); not run silently."
186 ),
187 FlagKind::UnresolvedExpression => {
188 "Script carries GHA `${{ … }}` expressions QED's subprocess substitution won't \
189 expand; convert to QED params (`{{key}}`) / native outputs, or lift the build \
190 target at import time (R533-F9)."
191 .to_string()
192 }
193 }
194 }
195}
196
197pub fn transform_workflow(wf: &Workflow) -> TransformReport {
204 let label = wf
205 .name
206 .clone()
207 .unwrap_or_else(|| "imported workflow".to_string());
208 let name = slugify(&label, "imported-workflow");
209
210 let order: Vec<String> = topo_sort(wf)
213 .map(|waves| waves.into_iter().flatten().collect())
214 .unwrap_or_else(|_| wf.jobs.keys().cloned().collect());
215
216 let mut steps = Vec::new();
217 for job_id in &order {
218 let Some(job) = wf.jobs.get(job_id) else { continue };
219 for (step_index, step) in job.steps.iter().enumerate() {
220 steps.push(transform_step(job_id, job, step_index, step));
221 }
222 }
223
224 TransformReport { name, label, steps }
225}
226
227pub fn transform_workflow_src(src: &str) -> Result<TransformReport, yah_qed_gha::ParseError> {
231 Ok(transform_workflow(&yah_qed_gha::parse_workflow(src)?))
232}
233
234fn transform_step(job_id: &str, job: &Job, step_index: usize, step: &Step) -> TransformedStep {
236 let step_name = step.name.as_ref().map(render_exprstring).map(|s| s.trim().to_string());
237 let class = classify_step(step);
238 let mut native = None;
239 let mut flags = Vec::new();
240
241 match (&step.action, &class.disposition) {
242 (StepAction::Run { body, shell }, Disposition::Compute) => {
245 let (step_native, lifted_key) =
246 map_run_step(job_id, job, step_index, step, body, shell.as_deref());
247 native = Some(step_native);
248 if !class.service_touches.is_empty() {
249 flags.push(FlagKind::EmbeddedServiceTouch(class.service_touches.clone()));
250 }
251 let key = lifted_key.as_deref();
255 if has_unresolved_expression(body, key)
256 || step.env.values().any(|v| has_unresolved_expression(v, key))
257 {
258 flags.push(FlagKind::UnresolvedExpression);
259 }
260 }
261 (StepAction::Uses { slug, git_ref, .. }, Disposition::Compute) => {
263 flags.push(FlagKind::ToolkitAction { slug: slug.clone(), git_ref: git_ref.clone() });
264 }
265 (_, Disposition::ReplaceWithNative(nr)) => {
267 flags.push(FlagKind::ReplaceWithNative(*nr));
268 }
269 (StepAction::Uses { slug, .. }, Disposition::Unknown) => {
271 flags.push(FlagKind::Unknown { slug: slug.clone() });
272 }
273 (StepAction::Run { body, shell }, Disposition::Unknown) => {
276 native = Some(map_run_step(job_id, job, step_index, step, body, shell.as_deref()).0);
277 }
278 }
279
280 TransformedStep { job: job_id.to_string(), step_index, step_name, native, flags }
281}
282
283fn map_run_step(
290 job_id: &str,
291 job: &Job,
292 step_index: usize,
293 step: &Step,
294 body: &ExprString,
295 shell: Option<&str>,
296) -> (QedStep, Option<String>) {
297 let name = match step.name.as_ref().map(render_exprstring) {
298 Some(n) if !n.trim().is_empty() => format!("{job_id}: {}", n.trim()),
299 _ => format!("{job_id}: step {step_index}"),
300 };
301 let script = render_exprstring(body);
302 let argv = shell_argv(shell, &script);
303 let env = step
304 .env
305 .iter()
306 .map(|(k, v)| (k.clone(), render_exprstring(v)))
307 .collect();
308 let cwd = step
309 .working_directory
310 .as_ref()
311 .map(render_exprstring)
312 .filter(|s| !s.is_empty());
313 let timeout = step.timeout_minutes.map(|m| u64::from(m) * 60);
314 let on_fail = if step.continue_on_error == Some(true) {
315 OnFail::Continue
316 } else {
317 OnFail::Abort
318 };
319 let if_cond = step.if_cond.as_ref().map(render_exprstring);
320
321 let (platform, matrix, lifted_key) = lift_target(job, &script);
325
326 let step = QedStep {
327 background: false,
328 background_until: None,
329 wait_for: None,
330 argv,
331 cwd,
332 env,
333 timeout,
334 on_fail,
335 if_cond,
336 platform,
337 matrix,
338 ..base_step(name)
339 };
340 (step, lifted_key)
341}
342
343fn lift_target(job: &Job, script: &str) -> (Option<PlatformSpec>, Option<MatrixSpec>, Option<String>) {
352 let Some(raw_target) = extract_target(script) else {
353 return (None, None, None);
354 };
355 let platform = Some(PlatformSpec {
356 target: Some(raw_target.clone()),
357 container_platform: None,
358 native: false,
359 });
360
361 if let Some(key) = matrix_ref_key(&raw_target) {
364 let values = matrix_target_values(job, &key);
365 if !values.is_empty() {
366 return (platform, Some(target_matrix(&key, &values)), Some(key));
367 }
368 }
369 (platform, None, None)
370}
371
372fn extract_target(script: &str) -> Option<String> {
377 const FLAG: &str = "--target";
378 let mut from = 0;
379 while let Some(rel) = script[from..].find(FLAG) {
380 let pos = from + rel;
381 let after = &script[pos + FLAG.len()..];
382 from = pos + FLAG.len();
383 let mut chars = after.chars();
384 match chars.next() {
385 Some('=') => {
387 if let Some(v) = read_target_value(&after[1..]) {
388 return Some(v);
389 }
390 }
391 Some(c) if c.is_whitespace() => {
393 if let Some(v) = read_target_value(after) {
394 return Some(v);
395 }
396 }
397 _ => {}
399 }
400 }
401 None
402}
403
404fn read_target_value(s: &str) -> Option<String> {
407 let s = s.trim_start();
408 if let Some(rest) = s.strip_prefix("${{") {
409 let end = rest.find("}}")?;
410 return Some(format!("${{{{ {} }}}}", rest[..end].trim()));
411 }
412 let val: String = s.chars().take_while(|c| !c.is_whitespace()).collect();
413 (!val.is_empty()).then_some(val)
414}
415
416fn matrix_ref_key(value: &str) -> Option<String> {
419 let inner = value.trim().strip_prefix("${{")?.strip_suffix("}}")?.trim();
420 let key = inner.strip_prefix("matrix.")?.trim();
421 (!key.is_empty() && !key.contains(char::is_whitespace)).then(|| key.to_string())
422}
423
424fn matrix_target_values(job: &Job, key: &str) -> Vec<String> {
428 let mut out: Vec<String> = Vec::new();
429 let mut push = |s: String| {
430 if !out.contains(&s) {
431 out.push(s);
432 }
433 };
434 if let Some(matrix) = job.strategy.as_ref().and_then(|s| s.matrix.as_ref()) {
435 if let Some(values) = matrix.dimensions.get(key) {
436 for v in values {
437 if let Some(s) = v.as_str() {
438 push(s.to_string());
439 }
440 }
441 }
442 for inc in &matrix.include {
443 if let Some(s) = inc.get(key).and_then(|v| v.as_str()) {
444 push(s.to_string());
445 }
446 }
447 }
448 out
449}
450
451fn target_matrix(key: &str, values: &[String]) -> MatrixSpec {
453 let mut dimensions: IndexMap<String, Vec<toml::Value>> = IndexMap::new();
454 dimensions.insert(
455 key.to_string(),
456 values.iter().map(|s| toml::Value::String(s.clone())).collect(),
457 );
458 MatrixSpec { dimensions, include: Vec::new(), exclude: Vec::new() }
459}
460
461fn base_step(name: String) -> QedStep {
465 QedStep {
466 background: false,
467 background_until: None,
468 wait_for: None,
469 manifest_stitch: None,
470 name,
471 argv: Vec::new(),
472 cwd: None,
473 env: std::collections::HashMap::new(),
474 timeout: None,
475 on_fail: OnFail::Abort,
476 produces: Vec::new(),
477 runtime: None,
478 kind: StepKind::Subprocess,
479 image: None,
480 tag: None,
481 push: false,
482 platforms: Vec::new(),
483 binary_path: None,
484 triple: None,
485 package: None,
486 context: None,
487 load: false,
488 sub_pipeline: None,
489 outputs: Vec::new(),
490 gha_workflow: None,
491 import: None,
492 matrix: None,
493 enabled: true,
494 activation: StepActivation::Active,
495 if_cond: None,
496 platform: None,
497 toolchain: None,
498 }
499}
500
501fn shell_argv(shell: Option<&str>, script: &str) -> Vec<String> {
506 let argv = |prog: &str, flag: &str, body: String| {
507 vec![prog.to_string(), flag.to_string(), body]
508 };
509 match shell.unwrap_or("bash") {
510 "bash" => argv("bash", "-c", format!("set -eo pipefail\n{script}")),
511 "sh" => argv("sh", "-c", format!("set -e\n{script}")),
512 "pwsh" | "powershell" => argv("pwsh", "-Command", script.to_string()),
513 "python" | "python3" => argv("python3", "-c", script.to_string()),
514 other => argv(other, "-c", script.to_string()),
515 }
516}
517
518pub(crate) fn render_exprstring(s: &ExprString) -> String {
521 let mut out = String::new();
522 for t in &s.tokens {
523 match t {
524 ExprToken::Literal(x) => out.push_str(x),
525 ExprToken::Expr(x) => {
526 out.push_str("${{ ");
527 out.push_str(x);
528 out.push_str(" }}");
529 }
530 }
531 }
532 out
533}
534
535fn has_unresolved_expression(s: &ExprString, resolved_matrix_key: Option<&str>) -> bool {
541 s.tokens.iter().any(|t| match t {
542 ExprToken::Literal(_) => false,
543 ExprToken::Expr(raw) => match (raw.trim().strip_prefix("matrix."), resolved_matrix_key) {
544 (Some(k), Some(rk)) => k.trim() != rk,
545 _ => true,
546 },
547 })
548}
549
550fn slugify(name: &str, fallback: &str) -> String {
553 let mut out = String::new();
554 let mut prev_dash = false;
555 for ch in name.chars() {
556 if ch.is_ascii_alphanumeric() {
557 out.push(ch.to_ascii_lowercase());
558 prev_dash = false;
559 } else if !prev_dash {
560 out.push('-');
561 prev_dash = true;
562 }
563 }
564 let slug = out.trim_matches('-').to_string();
565 if slug.is_empty() {
566 fallback.to_string()
567 } else {
568 slug
569 }
570}
571
572#[cfg(test)]
573mod tests {
574 use super::*;
575
576 fn xf(src: &str) -> TransformReport {
578 transform_workflow_src(src).expect("parse")
579 }
580
581 fn native_at<'a>(r: &'a TransformReport, job: &str, idx: usize) -> &'a QedStep {
583 r.steps
584 .iter()
585 .find(|s| s.job == job && s.step_index == idx)
586 .and_then(|s| s.native.as_ref())
587 .unwrap_or_else(|| panic!("no native step at {job}[{idx}]"))
588 }
589
590 fn flags_at<'a>(r: &'a TransformReport, job: &str, idx: usize) -> &'a [FlagKind] {
592 &r.steps
593 .iter()
594 .find(|s| s.job == job && s.step_index == idx)
595 .unwrap_or_else(|| panic!("no step at {job}[{idx}]"))
596 .flags
597 }
598
599 const RUN_JOB: &str = r#"
600name: ci
601on: push
602jobs:
603 build:
604 runs-on: ubuntu-latest
605 steps:
606 - name: Compile
607 run: cargo build --release
608 env:
609 RUSTFLAGS: "-D warnings"
610 working-directory: app
611 timeout-minutes: 20
612 continue-on-error: true
613"#;
614
615 #[test]
616 fn run_step_maps_to_native_subprocess() {
617 let r = xf(RUN_JOB);
618 let step = native_at(&r, "build", 0);
619 assert_eq!(step.name, "build: Compile");
620 assert_eq!(step.kind, StepKind::Subprocess);
621 assert_eq!(step.argv[0], "bash");
622 assert_eq!(step.argv[1], "-c");
623 assert!(step.argv[2].starts_with("set -eo pipefail\n"));
624 assert!(step.argv[2].contains("cargo build --release"));
625 assert_eq!(step.env.get("RUSTFLAGS").map(String::as_str), Some("-D warnings"));
626 assert_eq!(step.cwd.as_deref(), Some("app"));
627 assert_eq!(step.timeout, Some(20 * 60));
628 assert!(matches!(step.on_fail, OnFail::Continue));
629 assert!(flags_at(&r, "build", 0).is_empty(), "clean compute → no flags");
630 }
631
632 #[test]
633 fn pipeline_name_is_slugified_from_workflow_name() {
634 let r = xf("name: My Release Flow!\non: push\njobs:\n a:\n runs-on: x\n steps:\n - run: true\n");
635 assert_eq!(r.name, "my-release-flow");
636 assert_eq!(r.label, "My Release Flow!");
637 }
638
639 #[test]
640 fn unnamed_workflow_falls_back() {
641 let r = xf("on: push\njobs:\n a:\n runs-on: x\n steps:\n - run: true\n");
642 assert_eq!(r.name, "imported-workflow");
643 }
644
645 #[test]
646 fn tier3_checkout_is_flagged_not_mapped() {
647 let r = xf("on: push\njobs:\n a:\n runs-on: x\n steps:\n - uses: actions/checkout@v4\n");
648 let step = &r.steps[0];
649 assert!(step.native.is_none(), "tier-3 emits no native step");
650 assert_eq!(
651 step.flags,
652 vec![FlagKind::ReplaceWithNative(NativeReplacement::Checkout)]
653 );
654 assert_eq!(step.flags[0].severity(), FlagSeverity::Replace);
655 assert!(step.flags[0].stanza_hint().contains("checkout is implicit"));
656 }
657
658 #[test]
659 fn tier3_upload_artifact_proposes_content_addressed_output() {
660 let r = xf("on: push\njobs:\n a:\n runs-on: x\n steps:\n - uses: actions/upload-artifact@v4\n");
661 assert_eq!(
662 r.steps[0].flags,
663 vec![FlagKind::ReplaceWithNative(NativeReplacement::UploadArtifact)]
664 );
665 assert!(r.steps[0].flags[0].stanza_hint().contains("content-addressed output"));
666 }
667
668 #[test]
669 fn compute_uses_is_a_toolkit_action_flag() {
670 let r = xf("on: push\njobs:\n a:\n runs-on: x\n steps:\n - uses: actions/setup-node@v4\n");
671 let step = &r.steps[0];
672 assert!(step.native.is_none(), "no subprocess until the T7 executor");
673 assert_eq!(
674 step.flags,
675 vec![FlagKind::ToolkitAction {
676 slug: "actions/setup-node".into(),
677 git_ref: Some("v4".into()),
678 }]
679 );
680 assert_eq!(step.flags[0].severity(), FlagSeverity::Info);
681 }
682
683 #[test]
684 fn unknown_uses_is_flagged_for_review() {
685 let r = xf("on: push\njobs:\n a:\n runs-on: x\n steps:\n - uses: some-org/exotic@v1\n");
686 let step = &r.steps[0];
687 assert!(step.native.is_none());
688 assert_eq!(step.flags, vec![FlagKind::Unknown { slug: "some-org/exotic".into() }]);
689 assert_eq!(step.flags[0].severity(), FlagSeverity::Review);
690 }
691
692 #[test]
693 fn run_step_with_gh_cli_runs_but_is_flagged() {
694 let r = xf("on: push\njobs:\n a:\n runs-on: x\n steps:\n - run: gh release create v1 ./dist/*\n");
695 let step = &r.steps[0];
696 assert!(step.native.is_some(), "still runs on the executor");
697 assert_eq!(
698 step.flags,
699 vec![FlagKind::EmbeddedServiceTouch(vec![ServiceTouch::GhCli])]
700 );
701 assert_eq!(step.flags[0].severity(), FlagSeverity::Review);
702 }
703
704 #[test]
705 fn run_step_with_expression_is_flagged_unresolved() {
706 let r = xf("on: push\njobs:\n a:\n runs-on: x\n steps:\n - run: echo ${{ github.sha }}\n");
707 let step = &r.steps[0];
708 assert!(step.native.is_some());
709 assert!(step.native.as_ref().unwrap().argv[2].contains("${{ github.sha }}"));
711 assert!(step.flags.contains(&FlagKind::UnresolvedExpression));
712 }
713
714 #[test]
715 fn jobs_flatten_in_topological_order() {
716 let src = r#"
717on: push
718jobs:
719 publish:
720 needs: build
721 runs-on: x
722 steps:
723 - run: echo publish
724 build:
725 runs-on: x
726 steps:
727 - run: echo build
728"#;
729 let r = xf(src);
730 let jobs: Vec<&str> = r.steps.iter().map(|s| s.job.as_str()).collect();
733 assert_eq!(jobs, vec!["build", "publish"]);
734 }
735
736 #[test]
737 fn shell_variants_select_the_right_interpreter() {
738 let r = xf("on: push\njobs:\n a:\n runs-on: x\n steps:\n - run: print(1)\n shell: python\n");
739 let step = native_at(&r, "a", 0);
740 assert_eq!(step.argv[0], "python3");
741 assert_eq!(step.argv[1], "-c");
742 assert_eq!(step.argv[2], "print(1)");
743 }
744
745 #[test]
746 fn report_accessors_partition_native_and_flagged() {
747 let src = r#"
748on: push
749jobs:
750 a:
751 runs-on: x
752 steps:
753 - run: cargo test
754 - uses: actions/checkout@v4
755"#;
756 let r = xf(src);
757 assert_eq!(r.collect_native().len(), 1, "only the run step is native");
758 assert_eq!(r.flags().count(), 1, "only checkout flags");
759 assert!(!r.is_clean(), "a tier-3 step is present");
760 }
761
762 #[test]
763 fn unnamed_run_step_gets_positional_name() {
764 let r = xf("on: push\njobs:\n b:\n runs-on: x\n steps:\n - run: make\n");
765 assert_eq!(native_at(&r, "b", 0).name, "b: step 0");
766 }
767
768 #[test]
771 fn concrete_target_lifts_into_platform_no_matrix() {
772 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");
773 let step = native_at(&r, "a", 0);
774 let p = step.platform.as_ref().expect("platform lifted");
775 assert_eq!(p.target.as_deref(), Some("x86_64-unknown-linux-musl"));
776 assert!(step.matrix.is_none(), "a concrete target needs no matrix");
777 }
778
779 #[test]
780 fn target_equals_form_is_recognized() {
781 let r = xf("on: push\njobs:\n a:\n runs-on: x\n steps:\n - run: cross build --target=aarch64-unknown-linux-gnu\n");
782 let p = native_at(&r, "a", 0).platform.as_ref().expect("platform");
783 assert_eq!(p.target.as_deref(), Some("aarch64-unknown-linux-gnu"));
784 }
785
786 #[test]
787 fn target_dir_is_not_mistaken_for_target() {
788 let r = xf("on: push\njobs:\n a:\n runs-on: x\n steps:\n - run: cargo build --target-dir /tmp/out\n");
789 assert!(native_at(&r, "a", 0).platform.is_none(), "--target-dir is not --target");
790 }
791
792 #[test]
793 fn step_without_target_has_no_platform() {
794 let r = xf(RUN_JOB);
795 assert!(native_at(&r, "build", 0).platform.is_none());
796 }
797
798 #[test]
799 fn matrix_target_dimension_lifts_and_carries_step_matrix() {
800 let src = r#"
801on: push
802jobs:
803 build:
804 runs-on: x
805 strategy:
806 matrix:
807 target:
808 - x86_64-unknown-linux-musl
809 - aarch64-unknown-linux-musl
810 steps:
811 - run: cross build --target ${{ matrix.target }}
812"#;
813 let r = xf(src);
814 let step = native_at(&r, "build", 0);
815 assert_eq!(
817 step.platform.as_ref().unwrap().target.as_deref(),
818 Some("${{ matrix.target }}")
819 );
820 let m = step.matrix.as_ref().expect("step matrix carried");
822 let vals = m.dimensions.get("target").expect("target dimension");
823 assert_eq!(vals.len(), 2);
824 assert!(flags_at(&r, "build", 0).is_empty());
826 }
827
828 #[test]
829 fn matrix_target_from_include_rows() {
830 let src = r#"
832on: push
833jobs:
834 cli:
835 runs-on: x
836 strategy:
837 matrix:
838 include:
839 - target: x86_64-apple-darwin
840 - target: aarch64-apple-darwin
841 steps:
842 - run: cargo build --target ${{ matrix.target }}
843"#;
844 let r = xf(src);
845 let m = native_at(&r, "cli", 0).matrix.as_ref().expect("matrix from include rows");
846 let vals = m.dimensions.get("target").expect("target dimension");
847 assert_eq!(vals.len(), 2);
848 }
849
850 fn release_yml() -> Option<String> {
856 let mut dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
857 loop {
858 let cand = dir.join(".github/workflows/release.yml");
859 if cand.is_file() {
860 return std::fs::read_to_string(cand).ok();
861 }
862 if !dir.pop() {
863 return None;
864 }
865 }
866 }
867
868 #[test]
869 fn release_yml_transforms_end_to_end() {
870 let Some(src) = release_yml() else {
871 eprintln!("skip: yah workflow fixtures not present");
872 return;
873 };
874 let r = xf(&src);
875 assert!(!r.steps.is_empty(), "release.yml has steps");
876
877 let checkout = r
879 .steps
880 .iter()
881 .find(|s| s.job == "smoke" && s.step_index == 0)
882 .expect("smoke step 0");
883 assert!(checkout.native.is_none());
884 assert!(checkout
885 .flags
886 .contains(&FlagKind::ReplaceWithNative(NativeReplacement::Checkout)));
887
888 assert!(r.flags().any(|(_, f)| matches!(
890 f,
891 FlagKind::ReplaceWithNative(NativeReplacement::RegistryPublish)
892 )));
893
894 assert!(
896 r.native_steps().any(|s| s.argv.first().map(String::as_str) == Some("bash")
897 && s.argv.last().is_some_and(|c| c.contains("cargo"))),
898 "at least one native cargo build step",
899 );
900
901 let first_idx = |job: &str| r.steps.iter().position(|s| s.job == job);
903 let (build, smoke, publish) =
904 (first_idx("cli-build"), first_idx("smoke"), first_idx("publish-cli"));
905 if let (Some(b), Some(s), Some(p)) = (build, smoke, publish) {
906 assert!(b < s, "cli-build before smoke");
907 assert!(s < p, "smoke before publish-cli");
908 }
909 }
910}