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
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]