fix(ci): an apostrophe in a comment killed six runs
deploy / build (push) Canceled after 0s
deploy / test (push) Canceled after 42s

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]>
This commit is contained in:
Omar Sobh
2026-08-20 07:44:18 -07:00
co-authored by Claude Opus 5
parent d23f30e929
commit 8f988739ec
2 changed files with 154 additions and 4 deletions
+10 -4
View File
@@ -133,8 +133,14 @@ jobs:
-v /tmp/ci-logs:/cilog \ -v /tmp/ci-logs:/cilog \
rust:1.96-slim \ rust:1.96-slim \
sh -c 'set -e sh -c 'set -e
# NO APOSTROPHES BELOW THIS LINE. Everything here is inside a
# single-quoted sh -c, so one apostrophe in a COMMENT closes the
# quote and the step dies with "unexpected EOF while looking for
# matching quote" — before running anything, which is why no log
# ever appeared. Runs 491 through 496 failed on the word
# "cm-api" followed by an apostrophe-s.
apt-get update -qq apt-get update -qq
# nodejs: cm-api's vm_tool_gate shell tests EXECUTE the generated # nodejs: the vm_tool_gate shell tests in cm-api EXECUTE the generated
# PreToolUse hook, which parses its JSON payload with node (no jq # PreToolUse hook, which parses its JSON payload with node (no jq
# in the runtime image; node is guaranteed there because Claude # in the runtime image; node is guaranteed there because Claude
# Code is a node program). Without it the hook takes its # Code is a node program). Without it the hook takes its
@@ -143,10 +149,10 @@ jobs:
apt-get install -y -qq pkg-config libssl-dev cmake git nodejs >/dev/null apt-get install -y -qq pkg-config libssl-dev cmake git nodejs >/dev/null
git config --global url."https://oauth2:[email protected]/".insteadOf "https://git.redclaw.dev/" git config --global url."https://oauth2:[email protected]/".insteadOf "https://git.redclaw.dev/"
# Full output to a host-mounted file, then the tail, then exit # Full output to a host-mounted file, then the tail, then exit
# with CARGO's status. Piping cargo into `tail` would report # with the cargo status. Piping cargo into `tail` would report
# tail's exit code — a green job over a red suite. The log # the exit code of tail — a green job over a red suite. The log
# survives the container so a failure is diagnosable at all: # survives the container so a failure is diagnosable at all:
# Gitea's actions-log API returns 403 for our token, and three # the Gitea actions-log API returns 403 for our token, and three
# failed runs were debugged blind before this existed. # failed runs were debugged blind before this existed.
set +e set +e
cargo test --workspace > /cilog/rust.log 2>&1 cargo test --workspace > /cilog/rust.log 2>&1
@@ -0,0 +1,144 @@
//! 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 ")
);
}