huskies: merge 1189 story Gitea Actions workflow: build the sled artifact on master merges and publish to the dev channel

This commit is contained in:
Huskies Agent
2026-07-17 13:32:30 +00:00
parent 3ed3fdd6b0
commit bb5d7879ff
8 changed files with 538 additions and 0 deletions
+307
View File
@@ -0,0 +1,307 @@
//! Tests for `script/ci-publish-artifact` and `.gitea/workflows/release-artifact.yml`
//! (story 1189). Exercises the publish script end-to-end as a subprocess against a
//! minimal local HTTP stub rather than a live channel host — this crate has no
//! production code path that talks to the dev channel itself.
use std::collections::{HashMap, VecDeque};
use std::io::{Read, Write};
use std::net::{TcpListener, TcpStream};
use std::path::PathBuf;
use std::process::Command;
use std::sync::{Arc, Mutex};
use std::thread;
/// One request as observed by the stub: method, path, and the two headers
/// the publish script is required to send.
struct ObservedRequest {
method: String,
path: String,
authorization: Option<String>,
git_hash: Option<String>,
}
fn repo_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("..")
}
fn script_path() -> PathBuf {
repo_root().join("script/ci-publish-artifact")
}
/// Read one HTTP/1.1 request off `stream`: request line, headers (lower-cased
/// keys), and body (read exactly `Content-Length` bytes, defaulting to none).
fn read_request(stream: &mut TcpStream) -> (String, String, HashMap<String, String>, Vec<u8>) {
let mut header_bytes = Vec::new();
let mut byte = [0u8; 1];
loop {
stream.read_exact(&mut byte).expect("read request byte");
header_bytes.push(byte[0]);
if header_bytes.ends_with(b"\r\n\r\n") {
break;
}
}
let header_text = String::from_utf8_lossy(&header_bytes);
let mut lines = header_text.lines();
let request_line = lines.next().unwrap_or("");
let mut parts = request_line.split_whitespace();
let method = parts.next().unwrap_or("").to_string();
let path = parts.next().unwrap_or("").to_string();
let mut headers = HashMap::new();
for line in lines {
if let Some((k, v)) = line.split_once(':') {
headers.insert(k.trim().to_lowercase(), v.trim().to_string());
}
}
let content_length: usize = headers
.get("content-length")
.and_then(|v| v.parse().ok())
.unwrap_or(0);
let mut body = vec![0u8; content_length];
if content_length > 0 {
stream.read_exact(&mut body).expect("read request body");
}
(method, path, headers, body)
}
fn write_response(stream: &mut TcpStream, status: u16, body: &str) {
let reason = match status {
200 => "OK",
401 => "Unauthorized",
403 => "Forbidden",
404 => "Not Found",
500 => "Internal Server Error",
_ => "Unknown",
};
let response = format!(
"HTTP/1.1 {status} {reason}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
let _ = stream.write_all(response.as_bytes());
let _ = stream.flush();
}
/// Spawn a background thread serving `responses` in order, one per accepted
/// connection; once exhausted, further requests get a 500 fallback (a test
/// bug, not an expected script behaviour). Returns the port and a log of
/// every request observed, in arrival order.
fn spawn_stub(responses: Vec<(u16, &'static str)>) -> (u16, Arc<Mutex<Vec<ObservedRequest>>>) {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind stub listener");
let port = listener.local_addr().expect("stub local addr").port();
let queue = Arc::new(Mutex::new(responses.into_iter().collect::<VecDeque<_>>()));
let log: Arc<Mutex<Vec<ObservedRequest>>> = Arc::new(Mutex::new(Vec::new()));
let log_writer = Arc::clone(&log);
thread::spawn(move || {
for stream in listener.incoming() {
let Ok(mut stream) = stream else { break };
let (method, path, headers, _body) = read_request(&mut stream);
log_writer.lock().unwrap().push(ObservedRequest {
method,
path,
authorization: headers.get("authorization").cloned(),
git_hash: headers.get("x-git-hash").cloned(),
});
let (status, body) = queue
.lock()
.unwrap()
.pop_front()
.unwrap_or((500, "no more stub responses queued"));
write_response(&mut stream, status, body);
}
});
(port, log)
}
struct RunResult {
success: bool,
stderr: String,
}
fn run_publish_script(port: u16, git_hash: &str) -> RunResult {
let dir = tempfile::tempdir().expect("tempdir");
let artifact_path = dir.path().join("huskies-linux-arm64");
std::fs::write(&artifact_path, b"fake sled binary").expect("write fake artifact");
let output = Command::new("bash")
.arg(script_path())
.arg(&artifact_path)
.arg(git_hash)
.env("HUSKIES_CHANNEL_URL", format!("http://127.0.0.1:{port}"))
.env("HUSKIES_CHANNEL_TOKEN", "test-token")
.env("HUSKIES_CI_PUBLISH_BACKOFF_SECS", "1")
.output()
.expect("run script/ci-publish-artifact");
RunResult {
success: output.status.success(),
stderr: String::from_utf8_lossy(&output.stderr).to_string(),
}
}
// ── Script behaviour ─────────────────────────────────────────────────────
#[test]
fn publish_succeeds_and_verifies_manifest() {
let (port, log) = spawn_stub(vec![
(200, r#"{"status":"ok"}"#),
(200, r#"{"git_hash":"abc1234"}"#),
]);
let result = run_publish_script(port, "abc1234");
assert!(result.success, "expected success: {}", result.stderr);
let requests = log.lock().unwrap();
assert_eq!(requests.len(), 2, "expected one upload + one manifest GET");
assert_eq!(requests[0].method, "POST");
assert_eq!(
requests[0].authorization.as_deref(),
Some("Bearer test-token")
);
assert_eq!(requests[0].git_hash.as_deref(), Some("abc1234"));
assert_eq!(requests[1].method, "GET");
assert_eq!(requests[1].path, "/manifest.json");
}
#[test]
fn publish_fails_immediately_on_auth_failure_without_retry() {
let (port, log) = spawn_stub(vec![(401, r#"{"error":"bad token"}"#)]);
let result = run_publish_script(port, "abc1234");
assert!(!result.success, "auth failure must not succeed");
assert!(
result.stderr.contains("authentication failed"),
"stderr should explain the auth failure: {}",
result.stderr
);
let requests = log.lock().unwrap();
assert_eq!(
requests.len(),
1,
"a 401 must not be retried, and must not reach the manifest check"
);
}
#[test]
fn publish_retries_transient_failure_then_succeeds() {
let (port, log) = spawn_stub(vec![
(500, "boom"),
(200, r#"{"status":"ok"}"#),
(200, r#"{"git_hash":"abc1234"}"#),
]);
let result = run_publish_script(port, "abc1234");
assert!(
result.success,
"expected eventual success: {}",
result.stderr
);
let requests = log.lock().unwrap();
assert_eq!(
requests.len(),
3,
"expected the failed upload, the retried upload, and the manifest GET"
);
assert_eq!(requests[0].method, "POST");
assert_eq!(requests[1].method, "POST");
assert_eq!(requests[2].method, "GET");
}
#[test]
fn publish_fails_on_manifest_mismatch() {
let (port, log) = spawn_stub(vec![
(200, r#"{"status":"ok"}"#),
(200, r#"{"git_hash":"deadbee"}"#),
(200, r#"{"git_hash":"deadbee"}"#),
(200, r#"{"git_hash":"deadbee"}"#),
]);
let result = run_publish_script(port, "abc1234");
assert!(!result.success, "manifest mismatch must fail");
assert!(
result.stderr.contains("manifest mismatch"),
"stderr should explain the mismatch: {}",
result.stderr
);
let requests = log.lock().unwrap();
assert_eq!(
requests.len(),
4,
"expected the upload plus 3 manifest-check attempts"
);
}
// ── Workflow yaml structure ──────────────────────────────────────────────
#[test]
fn workflow_yaml_triggers_on_master_push_and_targets_arm64_mac() {
let path = repo_root().join(".gitea/workflows/release-artifact.yml");
let content = std::fs::read_to_string(&path).expect("read release-artifact.yml");
let value: serde_yaml::Value = serde_yaml::from_str(&content).expect("valid yaml");
let branches = value["on"]["push"]["branches"]
.as_sequence()
.expect("on.push.branches sequence")
.iter()
.map(|v| v.as_str().unwrap_or_default().to_string())
.collect::<Vec<_>>();
assert_eq!(branches, vec!["master".to_string()]);
let jobs = value["jobs"].as_mapping().expect("jobs mapping");
let (_, job) = jobs.iter().next().expect("at least one job defined");
let runs_on = job["runs-on"].as_sequence().expect("runs-on sequence");
let runs_on: Vec<&str> = runs_on.iter().filter_map(|v| v.as_str()).collect();
assert_eq!(runs_on, vec!["arm64-mac"]);
let steps = job["steps"].as_sequence().expect("steps sequence");
let calls_publish_script = steps.iter().any(|step| {
step.get("run")
.and_then(|r| r.as_str())
.is_some_and(|s| s.contains("script/ci-publish-artifact"))
});
assert!(
calls_publish_script,
"a step must invoke script/ci-publish-artifact"
);
let references_channel_secrets = steps.iter().any(|step| {
step.get("env")
.and_then(|e| e.as_mapping())
.is_some_and(|env| {
env.values().any(|v| {
v.as_str().is_some_and(|s| {
s.contains("secrets.HUSKIES_CHANNEL_URL")
|| s.contains("secrets.HUSKIES_CHANNEL_TOKEN")
})
})
})
});
assert!(
references_channel_secrets,
"workflow must reference the channel secrets via ${{{{ secrets.* }}}}"
);
}
#[test]
fn readme_documents_runner_label_and_secrets() {
let path = repo_root().join(".gitea/workflows/README.md");
let content = std::fs::read_to_string(&path).expect("read .gitea/workflows/README.md");
assert!(
content.contains("arm64-mac"),
"README must document the runner label"
);
assert!(
content.contains("HUSKIES_CHANNEL_URL"),
"README must document HUSKIES_CHANNEL_URL"
);
assert!(
content.contains("HUSKIES_CHANNEL_TOKEN"),
"README must document HUSKIES_CHANNEL_TOKEN"
);
}