//! Every `run:` block in a Gitea workflow must be valid shell. //! //! Runs 491 through 496 all failed on a single apostrophe. A comment inside a //! `sh -c '…'` block read `cm-api's vm_tool_gate`, which closed the quote, and //! the step died with "unexpected EOF while looking for matching quote" — //! *before running anything*, which is why no log ever appeared and why three //! separate theories were floated to explain it. //! //! The cost was entirely diagnostic: the workflow is not compiled, not linted, //! and its only feedback is a red build with a log this deployment cannot read. //! `bash -n` answers it in milliseconds, so the check belongs where it runs //! before the push rather than after. //! //! Skips silently if `bash` is unavailable. The test exists to catch a mistake, //! not to fail a machine for lacking a shell it probably has. use std::process::Command; fn repo_root() -> std::path::PathBuf { std::path::Path::new(env!("CARGO_MANIFEST_DIR")) .join("../..") .canonicalize() .expect("repo root") } /// Every `run: |` block, as (file, first line number, script). /// /// Line-based rather than a YAML parse: the workspace has no YAML dependency, /// and a block scalar's rule — "the body is the lines indented deeper than the /// key" — is simple enough to apply directly. fn run_blocks(text: &str, file: &str) -> Vec<(String, usize, String)> { let lines: Vec<&str> = text.lines().collect(); let mut out = Vec::new(); let mut i = 0; while i < lines.len() { let line = lines[i]; let trimmed = line.trim_start(); if trimmed.starts_with("run:") && trimmed.trim_end().ends_with('|') { let key_indent = line.len() - trimmed.len(); let mut body: Vec<&str> = Vec::new(); let mut j = i + 1; let mut body_indent = usize::MAX; while j < lines.len() { let l = lines[j]; if l.trim().is_empty() { body.push(""); j += 1; continue; } let ind = l.len() - l.trim_start().len(); if ind <= key_indent { break; } body_indent = body_indent.min(ind); body.push(l); j += 1; } let script = body .iter() .map(|l| { if l.len() >= body_indent { &l[body_indent..] } else { "" } }) .collect::>() .join("\n"); out.push((file.to_string(), i + 1, script)); i = j; continue; } i += 1; } out } #[test] fn every_workflow_run_block_is_valid_shell() { if Command::new("bash").arg("-c").arg("true").status().is_err() { eprintln!("bash unavailable — skipping workflow shell syntax check"); return; } let dir = repo_root().join(".gitea/workflows"); let entries = std::fs::read_dir(&dir).expect("workflows dir"); let mut checked = 0usize; let mut broken: Vec = Vec::new(); for e in entries { let path = e.expect("entry").path(); if path.extension().and_then(|x| x.to_str()) != Some("yml") { continue; } let name = path.file_name().unwrap().to_string_lossy().to_string(); let text = std::fs::read_to_string(&path).expect("read workflow"); for (file, line, script) in run_blocks(&text, &name) { // `${{ … }}` is Gitea's, not the shell's. Substituted with a // placeholder so its braces do not read as shell syntax — the point // is to catch OUR quoting, not to evaluate their templating. let mut cleaned = String::new(); let mut rest = script.as_str(); while let Some(start) = rest.find("${{") { cleaned.push_str(&rest[..start]); cleaned.push_str("PLACEHOLDER"); rest = match rest[start..].find("}}") { Some(end) => &rest[start + end + 2..], None => "", }; } cleaned.push_str(rest); let tmp = std::env::temp_dir().join(format!( "cm-wf-{}-{}-{}.sh", std::process::id(), file.replace('.', "_"), line )); std::fs::write(&tmp, &cleaned).expect("write temp script"); let out = Command::new("bash") .arg("-n") .arg(&tmp) .output() .expect("run bash -n"); let _ = std::fs::remove_file(&tmp); checked += 1; if !out.status.success() { broken.push(format!( "{file}:{line} — {}", String::from_utf8_lossy(&out.stderr).trim() )); } } } assert!(checked > 0, "found no run: blocks — the extractor is broken, \ which would make this test pass forever"); assert!( broken.is_empty(), "{} workflow shell block(s) will not parse. The step dies before it \ runs anything, so CI reports a failure with an empty log:\n {}", broken.len(), broken.join("\n ") ); }