Files
clawmates/crates/cm-api/tests/workflow_shell_syntax.rs
T
Omar SobhandClaude Opus 5 8f988739ec
deploy / build (push) Canceled after 0s
deploy / test (push) Canceled after 42s
fix(ci): an apostrophe in a comment killed six runs
Runs 491 through 496 failed on one character.

The Rust step is a `docker run … sh -c '…'`. A comment inside that
single-quoted block read `cm-api's vm_tool_gate`, and the apostrophe closed
the quote. Bash died with "unexpected EOF while looking for matching quote"
BEFORE running anything — which is why no log ever appeared, why the
breadcrumb showed the step entered and produced nothing, and why three
separate theories were floated to explain an empty failure.

I introduced it in the commit that installed nodejs, so the fix for run 490
broke every run after it.

Run 490 itself was the stomping: it overlapped run 491, which began by
removing the shared `cm-ci-pg` container out from under it. That is fixed
too, and was a real defect — it was simply not the cause of 491+.

`bash -n` answers this in milliseconds and nothing was running it: a
workflow is not compiled, not linted, and its only feedback is a red build
with a log this deployment cannot read. `tests/workflow_shell_syntax.rs`
now extracts every `run:` block and syntax-checks it, so the failure shows
up before the push rather than six runs later. Gitea's `${{ … }}` is
replaced with a placeholder first — the point is to check OUR quoting, not
to evaluate their templating. Negative control: restoring the apostrophe
fails the test with the file and line.

The block also carries a standing NO APOSTROPHES warning, because the next
person to write a comment there will not be thinking about quoting.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-20 07:44:18 -07:00

145 lines
5.4 KiB
Rust

//! 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::<Vec<_>>()
.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<String> = 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 ")
);
}