ref:7d93289c6a038e660376202dc204e1e878e8eb94

feat: artifact upload after job execution

- Parse artifact_specs from claim response JSON - Glob-expand artifact paths relative to workspace - Upload via multipart POST to server artifact endpoint - Skip files over 100MB with warning - Log upload progress to build output Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
SHA: 7d93289c6a038e660376202dc204e1e878e8eb94
Author: Cole Christensen <cole.christensen@macmillan.com>
Date: 2026-03-14 14:28
Parents: 4f407be
5 files changed +217 -1
Type
Cargo.lock +31 −0
@@ -71,6 +71,7 @@
"dialoguer",
"dirs",
"futures",
"glob",
"hostname",
"libc",
"reqwest",
@@ -469,6 +470,12 @@
]
[[package]]
name = "glob"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
[[package]]
name = "hashbrown"
version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -849,6 +856,22 @@
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
[[package]]
name = "mime"
version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "mime_guess"
version = "2.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
dependencies = [
"mime",
"unicase",
]
[[package]]
name = "mio"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1131,6 +1154,7 @@
"base64",
"bytes",
"futures-core",
"futures-util",
"http",
"http-body",
"http-body-util",
@@ -1139,6 +1163,7 @@
"hyper-util",
"js-sys",
"log",
"mime_guess",
"percent-encoding",
"pin-project-lite",
"quinn",
@@ -1619,6 +1644,12 @@
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "unicase"
version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
[[package]]
name = "unicode-ident"
Cargo.toml +2 −1
@@ -11,7 +11,7 @@
[dependencies]
clap = { version = "4", features = ["derive"] }
reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false }
reqwest = { version = "0.12", features = ["json", "rustls-tls", "multipart"], default-features = false }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["full"] }
@@ -25,3 +25,4 @@
hostname = "0.4"
libc = "0.2"
futures = "0.3"
glob = "0.3"
src/runner/artifacts.rs +167 −0
@@ -1,0 +1,167 @@
use reqwest::header::{HeaderValue, AUTHORIZATION};
use reqwest::multipart;
use std::path::{Path, PathBuf};
use crate::runner::RunnerConfig;
/// Specification for an artifact to upload after job execution.
#[derive(Debug, Clone)]
pub struct ArtifactSpec {
pub name: String,
pub path: String,
}
const MAX_ARTIFACT_SIZE: u64 = 100 * 1024 * 1024; // 100MB
/// Parse artifact specs from the claim response JSON.
pub fn parse_specs(job: &serde_json::Value) -> Vec<ArtifactSpec> {
let Some(specs) = job.get("artifact_specs").and_then(|v| v.as_array()) else {
return Vec::new();
};
specs
.iter()
.filter_map(|spec| {
let name = spec.get("name")?.as_str()?.to_string();
let path = spec.get("path")?.as_str()?.to_string();
Some(ArtifactSpec { name, path })
})
.collect()
}
/// Upload all artifacts matching the specs from the workspace directory.
///
/// Resolves paths relative to workspace, supports glob patterns.
/// Skips files over MAX_ARTIFACT_SIZE with a warning.
/// Returns the number of successfully uploaded artifacts.
pub async fn upload_artifacts(
config: &RunnerConfig,
job_id: &str,
workspace: &Path,
specs: &[ArtifactSpec],
) -> u32 {
if specs.is_empty() {
return 0;
}
let client = reqwest::Client::new();
let mut uploaded = 0u32;
for spec in specs {
let files = resolve_paths(workspace, &spec.path);
if files.is_empty() {
eprintln!(
" [artifacts] Warning: no files matched '{}' for artifact '{}'",
spec.path, spec.name
);
continue;
}
for file_path in &files {
let artifact_name = if files.len() > 1 {
// Multiple files: use relative path as name
let rel = file_path
.strip_prefix(workspace)
.unwrap_or(file_path)
.to_string_lossy()
.to_string();
format!("{}/{}", spec.name, rel)
} else {
spec.name.clone()
};
match upload_single(&client, config, job_id, file_path, &artifact_name).await {
Ok(()) => {
eprintln!(" [artifacts] Uploaded '{}'", artifact_name);
uploaded += 1;
}
Err(e) => {
eprintln!(" [artifacts] Failed to upload '{}': {}", artifact_name, e);
}
}
}
}
uploaded
}
/// Resolve a path pattern relative to workspace.
/// Supports simple glob patterns (*, **).
fn resolve_paths(workspace: &Path, pattern: &str) -> Vec<PathBuf> {
let full_pattern = workspace.join(pattern);
let pattern_str = full_pattern.to_string_lossy();
// Check if it's a glob pattern
if pattern_str.contains('*') || pattern_str.contains('?') || pattern_str.contains('[') {
match glob::glob(&pattern_str) {
Ok(paths) => paths
.filter_map(Result::ok)
.filter(|p| p.is_file())
.collect(),
Err(e) => {
eprintln!(" [artifacts] Invalid glob pattern '{}': {}", pattern, e);
Vec::new()
}
}
} else {
// Literal path
let path = workspace.join(pattern);
if path.is_file() {
vec![path]
} else {
Vec::new()
}
}
}
/// Upload a single file as a multipart form to the artifact endpoint.
async fn upload_single(
client: &reqwest::Client,
config: &RunnerConfig,
job_id: &str,
file_path: &Path,
artifact_name: &str,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
// Check file size
let metadata = std::fs::metadata(file_path)?;
if metadata.len() > MAX_ARTIFACT_SIZE {
return Err(format!(
"file too large ({} bytes, max {} bytes)",
metadata.len(),
MAX_ARTIFACT_SIZE
)
.into());
}
let file_bytes = std::fs::read(file_path)?;
let url = config.api_url(&format!("/runners/jobs/{job_id}/artifacts"));
// Try multipart upload first
let file_part = multipart::Part::bytes(file_bytes)
.file_name(artifact_name.to_string())
.mime_str("application/octet-stream")?;
let form = multipart::Form::new()
.text("name", artifact_name.to_string())
.part("file", file_part);
let resp = client
.post(&url)
.header(
AUTHORIZATION,
HeaderValue::from_str(&config.auth_header())?,
)
.multipart(form)
.send()
.await?;
let status = resp.status();
if status.is_success() {
Ok(())
} else {
let body = resp.text().await.unwrap_or_default();
Err(format!("upload failed ({status}): {body}").into())
}
}
src/runner/loop_runner.rs +16 −0
@@ -1,3 +1,4 @@
use crate::runner::artifacts;
use crate::runner::executor::{self, ExecResult};
use crate::runner::heartbeat;
use crate::runner::log_reporter::LogReporter;
@@ -256,6 +257,21 @@
&log_reporter,
)
.await;
// Upload artifacts (before cleanup, while workspace still exists)
let artifact_specs = artifacts::parse_specs(job);
if !artifact_specs.is_empty() {
log_reporter
.append(&format!(
"Uploading {} artifact(s)...",
artifact_specs.len()
))
.await;
let count = artifacts::upload_artifacts(config, job_id, ws, &artifact_specs).await;
log_reporter
.append(&format!("Uploaded {count} artifact(s)"))
.await;
}
// Cleanup services
if let Some(ctx) = &svc_context {
src/runner/mod.rs +1 −0
@@ -1,3 +1,4 @@
pub mod artifacts;
pub mod config;
pub mod executor;
pub mod heartbeat;