Precheck Dockerfile, sanitize image tag, show SHA in run header
Two papercuts in docker mode plus a missing piece of run-context. A
missing `.quire/Dockerfile` previously surfaced as docker's noisy
"invalid reference format" error because `repo_segment` produced tag
components starting with `.` (tempdir names like `.tmpXyZ`) which
docker rejects. Now we precheck for the Dockerfile and surface
`Error::DockerfileMissing`, `repo_segment` lowercases plus strips
leading non-alphanumerics so docker tags are valid even when the runs
base is a tempdir, and `quire ci run`'s header includes the resolved
short SHA so error messages don't need to repeat it.

Assisted-by: Claude Opus 4.7 via Claude Code
change pzuyytxrymtqzmvqwlnmpmoowxzsqnoz
commit 280c4ce6baa346b01e4ea6f65d194628b290eca2
author Alpha Chen <alpha@kejadlen.dev>
date
parent yyyslsxy
diff --git a/src/bin/quire/commands/ci.rs b/src/bin/quire/commands/ci.rs
index 1f75d54..7954095 100644
--- a/src/bin/quire/commands/ci.rs
+++ b/src/bin/quire/commands/ci.rs
@@ -78,7 +78,12 @@ pub async fn run(
     };
 
     let run = runs.create(&meta)?;
-    println!("Run {}: executing at {}", run.id(), commit.display);
+    println!(
+        "Run {}: executing at {} ({})",
+        run.id(),
+        commit.display,
+        &commit.sha[..commit.sha.len().min(12)],
+    );
 
     let workspace = tmp.path().join("workspace");
     quire::ci::materialize_workspace(&repo_path.join(".git"), &commit.sha, &workspace)
diff --git a/src/ci/run.rs b/src/ci/run.rs
index 150f7e3..1fcb1cc 100644
--- a/src/ci/run.rs
+++ b/src/ci/run.rs
@@ -455,6 +455,9 @@ impl Run {
                 self.write_container_record(&record)?;
 
                 let dockerfile = workspace.join(".quire/Dockerfile");
+                if !dockerfile.exists() {
+                    return Err(Error::DockerfileMissing);
+                }
                 let tag = format!("quire-ci/{}:{}", repo_segment(&self.base), self.id);
 
                 crate::ci::docker::docker_build(&dockerfile, workspace, &tag)?;
@@ -594,14 +597,34 @@ impl Run {
     }
 }
 
-/// Take the final path component of a runs base (`runs/<repo>/`) for
-/// use as the tag segment in `quire-ci/<segment>:<id>`. Falls back to
-/// `repo` when the path has no name or it isn't UTF-8.
+/// Take the final path component of a runs base (`runs/<repo>/`) and
+/// sanitize it for use as the tag segment in `quire-ci/<segment>:<id>`.
+/// Docker reference components match `[a-z0-9]+(?:[._-][a-z0-9]+)*`,
+/// so we lowercase, replace any other character with `_`, and strip
+/// leading non-alphanumerics (e.g. tempdir names like `.tmpXyZ`).
+/// Falls back to `repo` when the result would be empty.
 fn repo_segment(base: &Path) -> String {
-    base.file_name()
-        .and_then(|s| s.to_str())
-        .map(str::to_owned)
-        .unwrap_or_else(|| "repo".to_string())
+    let Some(raw) = base.file_name().and_then(|s| s.to_str()) else {
+        return "repo".to_string();
+    };
+    let sanitized: String = raw
+        .to_lowercase()
+        .chars()
+        .map(|c| {
+            if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') {
+                c
+            } else {
+                '_'
+            }
+        })
+        .collect::<String>()
+        .trim_start_matches(|c: char| !c.is_ascii_alphanumeric())
+        .to_string();
+    if sanitized.is_empty() {
+        "repo".to_string()
+    } else {
+        sanitized
+    }
 }
 
 /// Materialize a working tree at `sha` into `workspace` via
@@ -1642,6 +1665,17 @@ mod tests {
         assert_eq!(repo_segment(Path::new("")), "repo");
     }
 
+    #[test]
+    fn repo_segment_sanitizes_for_docker_tags() {
+        // Docker rejects tags whose component starts with `.` or `-` —
+        // tempdir names produced by `tempfile::tempdir()` start with `.`.
+        assert_eq!(repo_segment(Path::new("/tmp/.tmpAbCdEf")), "tmpabcdef");
+        // Uppercase is rejected; lowercase fine.
+        assert_eq!(repo_segment(Path::new("MyRepo.git")), "myrepo.git");
+        // Other invalid characters become underscores.
+        assert_eq!(repo_segment(Path::new("repo with spaces")), "repo_with_spaces");
+    }
+
     #[test]
     #[ignore = "requires docker"]
     fn execute_docker_mode_runs_jobs_in_container() {
diff --git a/src/error.rs b/src/error.rs
index 3702f9b..6a2e703 100644
--- a/src/error.rs
+++ b/src/error.rs
@@ -43,6 +43,9 @@ pub enum Error {
     #[error("docker is not available — install docker and ensure the daemon is running")]
     DockerUnavailable,
 
+    #[error("missing .quire/Dockerfile")]
+    DockerfileMissing,
+
     #[error("workspace materialization failed")]
     WorkspaceMaterializationFailed {
         #[source]