1use crate::types::{
37 Outcome, OnFail, Pipeline, Placement, QedStep, StepKind, Trigger,
38};
39
40#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct Degradation {
45 pub site: String,
48 pub feature: String,
50 pub detail: String,
52}
53
54#[derive(Debug, Clone)]
56pub struct ExportReport {
57 pub yaml: String,
60 pub degradations: Vec<Degradation>,
63 pub shimmed: bool,
67}
68
69impl ExportReport {
70 pub fn is_lossy(&self) -> bool {
73 !self.degradations.is_empty()
74 }
75}
76
77pub fn export_pipeline(pipeline: &Pipeline) -> ExportReport {
80 let mut degradations: Vec<Degradation> = Vec::new();
81
82 let on_block = render_on(&pipeline.triggers, &mut degradations);
84 if pipeline.placement == Placement::LocalOnly {
85 degradations.push(Degradation {
86 site: "placement".into(),
87 feature: "placement:local-only".into(),
88 detail: "pipeline is local-only (its output is meaningless on a clean CI runner); \
89 exported anyway, but a GitHub run likely produces nothing useful"
90 .into(),
91 });
92 }
93
94 record_outcome_degradations("on_success", &pipeline.on_success, &mut degradations);
98 record_outcome_degradations("on_fail", &pipeline.on_fail, &mut degradations);
99
100 let native_steps: Vec<&QedStep> = pipeline
101 .steps
102 .iter()
103 .filter(|s| !is_portable_kind(&s.kind))
104 .collect();
105 let shimmed = !native_steps.is_empty();
106
107 let job_id = sanitize_job_id(&pipeline.name);
108 let steps_yaml = if shimmed {
109 for s in &native_steps {
112 degradations.push(Degradation {
113 site: s.name.clone(),
114 feature: format!("step-kind:{}", kind_tag(&s.kind)),
115 detail: format!(
116 "`{}` is a native QED step ({}) with no GitHub-native equivalent; \
117 the whole pipeline runs via the `yah qed run` shim instead",
118 s.name,
119 kind_tag(&s.kind),
120 ),
121 });
122 }
123 render_shim_steps(&pipeline.name)
124 } else {
125 render_faithful_steps(&pipeline.steps, &mut degradations)
126 };
127
128 let yaml = render_workflow(pipeline, &job_id, &on_block, &steps_yaml, °radations, shimmed);
129 ExportReport { yaml, degradations, shimmed }
130}
131
132fn is_portable_kind(kind: &StepKind) -> bool {
136 matches!(kind, StepKind::Subprocess)
137}
138
139fn kind_tag(kind: &StepKind) -> &'static str {
140 match kind {
141 StepKind::Subprocess => "subprocess",
142 StepKind::BuildImage => "build-image",
143 StepKind::PackageNativeTarball => "package-native-tarball",
144 StepKind::MuslStaticPreflight => "musl-static-preflight",
145 StepKind::SignNativeTarball => "sign-native-tarball",
146 StepKind::SubPipeline => "sub-pipeline",
147 StepKind::GhaWorkflow => "gha-workflow",
148 StepKind::Import => "import",
149 StepKind::WaitFor => "wait-for",
150 StepKind::ManifestStitch => "manifest-stitch",
151 }
152}
153
154fn render_on(triggers: &[Trigger], degradations: &mut Vec<Degradation>) -> String {
159 let mut out = String::new();
160 let mut emitted_dispatch = false;
161 let effective = if triggers.is_empty() {
162 std::slice::from_ref(&Trigger::Manual)
163 } else {
164 triggers
165 };
166 let mut tags: Vec<&str> = Vec::new();
167 let mut crons: Vec<&str> = Vec::new();
168 for t in effective {
169 match t {
170 Trigger::Manual => {
171 if !emitted_dispatch {
172 out.push_str(" workflow_dispatch:\n");
173 emitted_dispatch = true;
174 }
175 }
176 Trigger::Tag { pattern } => tags.push(pattern),
177 Trigger::Schedule { cron } => crons.push(cron),
178 Trigger::Pipeline { id, status } => {
179 degradations.push(Degradation {
180 site: "triggers".into(),
181 feature: "trigger:pipeline-chain".into(),
182 detail: format!(
183 "pipeline-completion trigger (`{id}` = {status:?}) has no GHA equivalent; \
184 emitted as a best-effort `workflow_run` (fires on ANY completion of \
185 `{id}` — the {status:?} status filter is not expressible in GHA)"
186 ),
187 });
188 out.push_str(" workflow_run:\n");
189 out.push_str(&format!(" workflows: [{}]\n", yaml_flow_scalar(id)));
190 out.push_str(" types: [completed]\n");
191 }
192 }
193 }
194 if !tags.is_empty() {
195 out.push_str(" push:\n tags:\n");
196 for pat in tags {
197 out.push_str(&format!(" - {}\n", yaml_flow_scalar(pat)));
198 }
199 }
200 if !crons.is_empty() {
201 out.push_str(" schedule:\n");
202 for cron in crons {
203 out.push_str(&format!(" - cron: {}\n", yaml_flow_scalar(cron)));
204 }
205 }
206 out
207}
208
209fn record_outcome_degradations(site: &str, outcomes: &[Outcome], degradations: &mut Vec<Degradation>) {
210 for o in outcomes {
211 let (feature, detail) = match o {
212 Outcome::Publish { .. } => (
213 "outcome:publish",
214 "content-addressed release publish is a native QED facility (atomic-release \
215 model); GHA does not publish — run `yah qed run` to publish",
216 ),
217 Outcome::WardenDeploy { .. } => (
218 "outcome:yubaba-deploy",
219 "yubaba deploy is native; GHA cannot perform it — delegate to `yah qed run`",
220 ),
221 Outcome::AlmanacRun { .. } => (
222 "outcome:almanac-run",
223 "almanac dispatch is native; GHA cannot perform it — delegate to `yah qed run`",
224 ),
225 Outcome::Provider { .. } => (
226 "outcome:provider",
227 "vendor release adapter (notarize/sparkle/…) is native; GHA cannot perform it — \
228 delegate to `yah qed run`",
229 ),
230 };
231 degradations.push(Degradation {
232 site: site.into(),
233 feature: feature.into(),
234 detail: detail.into(),
235 });
236 }
237}
238
239fn render_faithful_steps(steps: &[QedStep], degradations: &mut Vec<Degradation>) -> String {
243 let mut out = String::new();
244 out.push_str(" # QED runs in-camp with the source already present; GHA needs checkout.\n");
247 out.push_str(" - name: Checkout\n");
248 out.push_str(" uses: actions/checkout@v4\n");
249
250 for step in steps {
251 if !step.produces.is_empty() {
252 degradations.push(Degradation {
253 site: step.name.clone(),
254 feature: "produces".into(),
255 detail: format!(
256 "step `{}` declares release artifacts; GHA builds them but does not publish \
257 (content-addressed release is native) — run `yah qed run` to publish",
258 step.name
259 ),
260 });
261 }
262
263 out.push_str(&format!(" - name: {}\n", yaml_flow_scalar(&step.name)));
264 out.push_str(&render_run(&step.argv));
265 if let Some(cwd) = &step.cwd {
266 out.push_str(&format!(" working-directory: {}\n", yaml_flow_scalar(cwd)));
267 }
268 if let Some(timeout) = step.timeout {
269 let minutes = timeout.div_ceil(60).max(1);
271 out.push_str(&format!(" timeout-minutes: {minutes}\n"));
272 }
273 match &step.on_fail {
274 OnFail::Continue => out.push_str(" continue-on-error: true\n"),
275 OnFail::Retry { max } => degradations.push(Degradation {
276 site: step.name.clone(),
277 feature: "on-fail:retry".into(),
278 detail: format!(
279 "step `{}` retries up to {max}× on failure; GHA has no built-in step retry \
280 (the step runs once)",
281 step.name
282 ),
283 }),
284 OnFail::Abort => {}
285 }
286 if !step.env.is_empty() {
287 out.push_str(" env:\n");
288 let mut keys: Vec<&String> = step.env.keys().collect();
290 keys.sort();
291 for k in keys {
292 out.push_str(&format!(" {}: {}\n", k, yaml_flow_scalar(&step.env[k])));
293 }
294 }
295 }
296 out
297}
298
299fn render_shim_steps(pipeline_name: &str) -> String {
302 let mut out = String::new();
303 out.push_str(" - name: Checkout\n");
304 out.push_str(" uses: actions/checkout@v4\n");
305 out.push_str(" # This pipeline has native QED steps with no GHA equivalent; GitHub\n");
306 out.push_str(" # delegates the whole run to the `yah` CLI (requires `yah` on PATH).\n");
307 out.push_str(" - name: Run QED pipeline\n");
308 out.push_str(&render_run(&[
309 "yah".to_string(),
310 "qed".to_string(),
311 "run".to_string(),
312 pipeline_name.to_string(),
313 ]));
314 out
315}
316
317fn render_run(argv: &[String]) -> String {
320 if argv.is_empty() {
321 return " run: ':'\n".to_string();
324 }
325 let cmd = argv.iter().map(|a| sh_quote(a)).collect::<Vec<_>>().join(" ");
326 format!(" run: {}\n", yaml_flow_scalar(&cmd))
327}
328
329fn render_workflow(
332 pipeline: &Pipeline,
333 job_id: &str,
334 on_block: &str,
335 steps_yaml: &str,
336 degradations: &[Degradation],
337 shimmed: bool,
338) -> String {
339 let mut out = String::new();
340 out.push_str("# @qed:exported (R533-F8, W224) — QED → GitHub Actions, export-with-degradation.\n");
341 out.push_str("# This is a best-effort FALLBACK CI, NOT a faithful mirror of the QED pipeline.\n");
342 if shimmed {
343 out.push_str("# Mode: wholesale shim (native steps delegate to `yah qed run`).\n");
344 } else {
345 out.push_str("# Mode: faithful (portable subprocess steps mapped to run: steps).\n");
346 }
347 if degradations.is_empty() {
348 out.push_str("# No degradations: this pipeline is fully portable.\n");
349 } else {
350 out.push_str(&format!("# {} degradation(s):\n", degradations.len()));
351 for d in degradations {
352 out.push_str(&format!("# - [{}] {}: {}\n", d.feature, d.site, d.detail));
353 }
354 }
355 out.push('\n');
356
357 out.push_str(&format!("name: {}\n", yaml_flow_scalar(&pipeline.label)));
358 out.push_str("on:\n");
359 out.push_str(on_block);
360 out.push_str("jobs:\n");
361 out.push_str(&format!(" {job_id}:\n"));
362 out.push_str(" runs-on: ubuntu-latest\n");
363 out.push_str(" steps:\n");
364 out.push_str(steps_yaml);
365 out
366}
367
368fn sanitize_job_id(name: &str) -> String {
371 let mut s: String = name
372 .chars()
373 .map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '-' })
374 .collect();
375 if s.is_empty() {
376 return "qed".to_string();
377 }
378 let first = s.chars().next().unwrap();
379 if !(first.is_ascii_alphabetic() || first == '_') {
380 s.insert(0, 'j');
381 s.insert(1, '-');
382 }
383 s
384}
385
386fn sh_quote(token: &str) -> String {
389 let safe = !token.is_empty()
390 && token
391 .chars()
392 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.' | '/' | '=' | ':' | '@' | '+' | ','));
393 if safe {
394 token.to_string()
395 } else {
396 format!("'{}'", token.replace('\'', "'\\''"))
398 }
399}
400
401fn yaml_flow_scalar(s: &str) -> String {
405 if is_plain_safe(s) {
406 s.to_string()
407 } else {
408 format!("'{}'", s.replace('\'', "''"))
409 }
410}
411
412fn is_plain_safe(s: &str) -> bool {
413 if s.is_empty() {
414 return false;
415 }
416 const RESERVED: &[&str] = &[
418 "true", "false", "yes", "no", "on", "off", "null", "~", "True", "False",
419 "Yes", "No", "On", "Off", "Null", "NULL", "TRUE", "FALSE",
420 ];
421 if RESERVED.contains(&s) {
422 return false;
423 }
424 if s.chars().all(|c| c.is_ascii_digit() || matches!(c, '.' | '-' | '+' | 'e' | 'E')) {
426 return false;
427 }
428 let first = s.chars().next().unwrap();
429 if matches!(
431 first,
432 '!' | '&' | '*' | '-' | '?' | '{' | '}' | '[' | ']' | ',' | '#' | '|'
433 | '>' | '@' | '`' | '"' | '\'' | '%' | ' '
434 ) {
435 return false;
436 }
437 if s.ends_with(' ') {
438 return false;
439 }
440 if s.contains(": ") || s.ends_with(':') || s.contains(" #") || s.contains('\n') {
443 return false;
444 }
445 s.chars().all(|c| {
446 c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.' | '/' | '=' | ':' | '@' | '+' | ' ')
447 })
448}
449
450#[cfg(test)]
451mod tests {
452 use super::*;
453 use crate::types::{ProducedArtifact, RunStatus};
454 use std::collections::HashMap;
455
456 fn step(name: &str, argv: &[&str]) -> QedStep {
457 QedStep {
458 background: false,
459 background_until: None,
460 wait_for: None,
461 name: name.into(),
462 argv: argv.iter().map(|s| s.to_string()).collect(),
463 ..base_step()
464 }
465 }
466
467 fn base_step() -> QedStep {
468 toml::from_str(r#"name = "x""#).expect("minimal QedStep")
470 }
471
472 fn pipeline(name: &str, steps: Vec<QedStep>) -> Pipeline {
473 Pipeline {
474 name: name.into(),
475 label: name.into(),
476 steps,
477 params: HashMap::new(),
478 on_success: Vec::new(),
479 on_fail: Vec::new(),
480 triggers: Vec::new(),
481 concurrency_key: None,
482 placement: Placement::Anywhere,
483 workspace: crate::types::WorkspaceMode::default(),
484 wraps: None,
485 matrix: None,
486 toolchain: None,
487 binds: Vec::new(),
488 on_change: Vec::new(),
489 finally: Vec::new(),
490 }
491 }
492
493 fn assert_well_formed(yaml: &str) {
497 assert!(yaml.contains("\nname: "), "has name:");
498 assert!(yaml.contains("\non:\n"), "has on:");
499 assert!(yaml.contains("\njobs:\n"), "has jobs:");
500 assert!(yaml.contains("runs-on: ubuntu-latest"), "has runs-on");
501 assert!(yaml.contains("NOT a faithful mirror"), "never-lossless header");
503 }
504
505 #[test]
506 fn portable_pipeline_renders_faithfully() {
507 let p = pipeline("check", vec![
508 step("Typecheck", &["cargo", "check", "--workspace"]),
509 step("Test", &["cargo", "test"]),
510 ]);
511 let r = export_pipeline(&p);
512 assert!(!r.shimmed, "all-subprocess pipeline renders faithfully");
513 assert!(r.degradations.is_empty(), "no degradations for a portable pipeline: {:?}", r.degradations);
514 assert!(!r.is_lossy());
515 assert_well_formed(&r.yaml);
516 assert!(r.yaml.contains("run: cargo check --workspace"));
518 assert!(r.yaml.contains("uses: actions/checkout@v4"));
519 assert!(r.yaml.contains("workflow_dispatch:"));
520 assert!(r.yaml.contains("\n check:\n"), "job id from pipeline name");
521 }
522
523 #[test]
524 fn native_kind_degrades_to_wholesale_shim() {
525 let mut img = step("Build image", &[]);
526 img.kind = StepKind::BuildImage;
527 let p = pipeline("release-build", vec![
528 step("Compile", &["cargo", "build", "--release"]),
529 img,
530 ]);
531 let r = export_pipeline(&p);
532 assert!(r.shimmed, "a build-image step forces the wholesale shim");
533 assert!(r.is_lossy());
534 assert!(r.yaml.contains("run: yah qed run release-build"));
537 assert!(!r.yaml.contains("cargo build --release"), "shim mode does not render subprocess steps");
538 assert!(r.degradations.iter().any(|d| d.feature == "step-kind:build-image"));
539 }
540
541 #[test]
542 fn outcomes_degrade_but_steps_still_render() {
543 let mut p = pipeline("release", vec![step("Build", &["cargo", "build"])]);
544 p.on_success = vec![Outcome::Publish {
545 provider: "r2".into(),
546 bucket: "yah-releases".into(),
547 prefix: None,
548 base_url: None,
549 }];
550 let r = export_pipeline(&p);
551 assert!(!r.shimmed, "subprocess-only steps still render faithfully");
552 assert!(r.yaml.contains("run: cargo build"));
553 assert!(r.is_lossy(), "the publish outcome is a degradation");
554 assert!(r.degradations.iter().any(|d| d.feature == "outcome:publish"));
555 assert!(r.yaml.to_lowercase().contains("publish"));
557 }
558
559 #[test]
560 fn triggers_map_and_pipeline_chain_degrades() {
561 let mut p = pipeline("nightly", vec![step("Smoke", &["./smoke.sh"])]);
562 p.triggers = vec![
563 Trigger::Tag { pattern: "v*.*.*".into() },
564 Trigger::Schedule { cron: "0 0 * * *".into() },
565 Trigger::Pipeline { id: "build".into(), status: RunStatus::Success },
566 ];
567 let r = export_pipeline(&p);
568 assert!(r.yaml.contains("push:"));
569 assert!(r.yaml.contains("tags:"));
570 assert!(r.yaml.contains("- 'v*.*.*'"), "glob tag is quoted");
571 assert!(r.yaml.contains("- cron: '0 0 * * *'"));
572 assert!(r.yaml.contains("workflow_run:"));
573 assert!(r.degradations.iter().any(|d| d.feature == "trigger:pipeline-chain"));
574 }
575
576 #[test]
577 fn empty_triggers_default_to_workflow_dispatch() {
578 let p = pipeline("check", vec![step("Lint", &["cargo", "clippy"])]);
579 let r = export_pipeline(&p);
580 assert!(r.yaml.contains("workflow_dispatch:"));
581 }
582
583 #[test]
584 fn local_only_placement_is_a_degradation() {
585 let mut p = pipeline("install", vec![step("Install", &["./install.sh"])]);
586 p.placement = Placement::LocalOnly;
587 let r = export_pipeline(&p);
588 assert!(r.degradations.iter().any(|d| d.feature == "placement:local-only"));
589 }
590
591 #[test]
592 fn retry_and_continue_on_fail_handled() {
593 let mut retry = step("Flaky", &["./flaky.sh"]);
594 retry.on_fail = OnFail::Retry { max: 3 };
595 let mut cont = step("Best effort", &["./maybe.sh"]);
596 cont.on_fail = OnFail::Continue;
597 let p = pipeline("ci", vec![retry, cont]);
598 let r = export_pipeline(&p);
599 assert!(r.yaml.contains("continue-on-error: true"), "Continue maps natively");
600 assert!(r.degradations.iter().any(|d| d.feature == "on-fail:retry"), "Retry degrades");
601 }
602
603 #[test]
604 fn produces_degrades_but_step_renders() {
605 let mut s = step("Build", &["cargo", "build", "--release"]);
606 s.produces = vec![ProducedArtifact {
607 binary: "yah".into(),
608 path: "target/release/yah".into(),
609 triple: None,
610 }];
611 let p = pipeline("build", vec![s]);
612 let r = export_pipeline(&p);
613 assert!(!r.shimmed);
614 assert!(r.yaml.contains("run: cargo build --release"));
615 assert!(r.degradations.iter().any(|d| d.feature == "produces"));
616 }
617
618 #[test]
619 fn env_cwd_timeout_render_on_steps() {
620 let mut s = step("Build", &["make"]);
621 s.cwd = Some("crates/app".into());
622 s.timeout = Some(90); s.env.insert("RUSTFLAGS".into(), "-D warnings".into());
624 let p = pipeline("build", vec![s]);
625 let r = export_pipeline(&p);
626 assert!(r.yaml.contains("working-directory: crates/app"));
627 assert!(r.yaml.contains("timeout-minutes: 2"));
628 assert!(r.yaml.contains("RUSTFLAGS: '-D warnings'"), "value with space + leading - is quoted");
629 }
630
631 #[test]
632 fn yaml_quoting_protects_reserved_and_special_scalars() {
633 assert_eq!(yaml_flow_scalar("cargo"), "cargo");
634 assert_eq!(yaml_flow_scalar("on"), "'on'");
635 assert_eq!(yaml_flow_scalar("true"), "'true'");
636 assert_eq!(yaml_flow_scalar("1.0"), "'1.0'");
637 assert_eq!(yaml_flow_scalar("a: b"), "'a: b'");
638 assert_eq!(yaml_flow_scalar("v*.*.*"), "'v*.*.*'");
639 assert_eq!(yaml_flow_scalar("it's"), "'it''s'");
640 }
641
642 #[test]
646 fn emitted_yaml_parses_as_a_real_gha_workflow() {
647 let p = pipeline("check", vec![
649 step("Build", &["cargo", "build"]),
650 step("Test", &["cargo", "test", "--workspace"]),
651 ]);
652 let r = export_pipeline(&p);
653 let wf = yah_qed_gha::parse_workflow(&r.yaml).expect("faithful export is valid GHA");
654 let job = wf.jobs.get("check").expect("job named after pipeline");
655 assert_eq!(job.steps.len(), 3, "checkout + 2 run steps");
656
657 let mut img = step("Build image", &[]);
659 img.kind = StepKind::BuildImage;
660 let p2 = pipeline("release-build", vec![step("Compile", &["cargo", "build"]), img]);
661 let r2 = export_pipeline(&p2);
662 let wf2 = yah_qed_gha::parse_workflow(&r2.yaml).expect("shim export is valid GHA");
663 let job2 = wf2.jobs.get("release-build").expect("job");
664 assert_eq!(job2.steps.len(), 2, "checkout + the yah shim");
665 }
666
667 #[test]
670 fn emitted_triggers_and_env_parse_back() {
671 let mut s = step("Build", &["make"]);
672 s.env.insert("RUSTFLAGS".into(), "-D warnings".into());
673 let mut p = pipeline("nightly", vec![s]);
674 p.triggers = vec![
675 Trigger::Tag { pattern: "v*.*.*".into() },
676 Trigger::Schedule { cron: "0 0 * * *".into() },
677 ];
678 let r = export_pipeline(&p);
679 let wf = yah_qed_gha::parse_workflow(&r.yaml).expect("valid GHA");
680 assert!(wf.triggers.push.as_ref().map(|p| !p.tags.is_empty()).unwrap_or(false));
681 assert_eq!(wf.triggers.schedule.len(), 1);
682 }
683
684 #[test]
685 fn job_id_is_sanitized() {
686 assert_eq!(sanitize_job_id("release-build"), "release-build");
687 assert_eq!(sanitize_job_id("123go"), "j-123go");
688 assert_eq!(sanitize_job_id("a b/c"), "a-b-c");
689 }
690}