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]>
145 lines
5.4 KiB
Rust
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 ")
|
|
);
|
|
}
|