Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date. | 1 | //! `g1t-runner deploy` and `g1t-runner detect` as a workflow step runs |
| 2 | //! them: in a checkout of its own, with nothing cloned, reporting to an API | |
| 3 | //! (here a fake one on localhost) with the build's token. | |
| 4 | ||
| 5 | use std::io::{BufRead, BufReader, Read, Write}; | |
| 6 | use std::net::TcpListener; | |
| 7 | use std::path::{Path, PathBuf}; | |
| 8 | use std::process::Command; | |
| 9 | use std::sync::{Arc, Mutex}; | |
| 10 | ||
| 11 | use serde_json::{Value, json}; | |
| 12 | ||
| 13 | fn runner() -> Command { | |
| 14 | Command::new(env!("CARGO_BIN_EXE_g1t-runner")) | |
| 15 | } | |
| 16 | ||
| 17 | /// A fresh checkout holding `files`. | |
| 18 | fn checkout(name: &str, files: &[(&str, &str)]) -> PathBuf { | |
| 19 | let dir = std::env::temp_dir().join(format!("g1t-runner-cli-{name}-{}", std::process::id())); | |
| 20 | let _ = std::fs::remove_dir_all(&dir); | |
| 21 | std::fs::create_dir_all(&dir).unwrap(); | |
| 22 | for (path, text) in files { | |
| 23 | let path = dir.join(path); | |
| 24 | std::fs::create_dir_all(path.parent().unwrap()).unwrap(); | |
| 25 | std::fs::write(path, text).unwrap(); | |
| 26 | } | |
| 27 | dir | |
| 28 | } | |
| 29 | ||
| 30 | #[test] | |
| 31 | fn detect_prints_what_the_build_would_find() { | |
| 32 | let dir = checkout("detect", &[("site/index.html", "<h1>hello</h1>")]); | |
| 33 | let output = runner().arg("detect").args(["--root-dir", "site"]).current_dir(&dir).env_remove("GIT_REMOTE").output().unwrap(); | |
| 34 | assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr)); | |
| 35 | assert_eq!(String::from_utf8_lossy(&output.stdout), "kind=html\ninstall=\nbuild=\noutput-dir=.\n"); | |
| 36 | let unknown = runner().args(["detect", "--nope"]).current_dir(&dir).output().unwrap(); | |
| 37 | assert_eq!(unknown.status.code(), Some(2)); | |
| 38 | } | |
| 39 | ||
| 40 | /// The API's `/deployments/jobs/<id>/<step>`: answers each step as the | |
| 41 | /// deployments service does, and keeps what it was sent. | |
| 42 | fn fake_api() -> (String, Arc<Mutex<Vec<(String, Value)>>>) { | |
| 43 | let listener = TcpListener::bind("127.0.0.1:0").unwrap(); | |
| 44 | let base = format!("http://{}", listener.local_addr().unwrap()); | |
| 45 | let seen = Arc::new(Mutex::new(Vec::new())); | |
| 46 | let kept = seen.clone(); | |
| 47 | std::thread::spawn(move || { | |
| 48 | for stream in listener.incoming() { | |
| 49 | let Ok(mut stream) = stream else { continue }; | |
| 50 | let mut reader = BufReader::new(stream.try_clone().unwrap()); | |
| 51 | let mut first = String::new(); | |
| 52 | reader.read_line(&mut first).unwrap(); | |
| 53 | let path = first.split_whitespace().nth(1).unwrap_or_default().to_owned(); | |
| 54 | let mut length = 0; | |
| 55 | loop { | |
| 56 | let mut line = String::new(); | |
| 57 | reader.read_line(&mut line).unwrap(); | |
| 58 | if line.trim().is_empty() { | |
| 59 | break; | |
| 60 | } | |
| 61 | if let Some((name, value)) = line.split_once(':') | |
| 62 | && name.eq_ignore_ascii_case("content-length") | |
| 63 | { | |
| 64 | length = value.trim().parse().unwrap_or(0); | |
| 65 | } | |
| 66 | } | |
| 67 | let mut body = vec![0; length]; | |
| 68 | reader.read_exact(&mut body).unwrap(); | |
| 69 | let body: Value = serde_json::from_slice(&body).unwrap_or(Value::Null); | |
| 70 | let answer = if path.ends_with("/session") { | |
| 71 | json!({ "ok": true, "value": { "jwt": "complete-me", "buckets": [], "upload_url": "http://127.0.0.1:9/never" } }) | |
| 72 | } else { | |
| 73 | json!({ "ok": true, "value": true }) | |
| 74 | }; | |
| 75 | kept.lock().unwrap().push((path, body)); | |
| 76 | let text = answer.to_string(); | |
| 77 | let _ = write!( | |
| 78 | stream, | |
| 79 | "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{text}", | |
| 80 | text.len() | |
| 81 | ); | |
| 82 | } | |
| 83 | }); | |
| 84 | (base, seen) | |
| 85 | } | |
| 86 | ||
| 87 | fn deploy(dir: &Path, api: &str, extra: &[(&str, &str)]) -> std::process::Output { | |
| 88 | let mut command = runner(); | |
| 89 | command | |
| 90 | .arg("deploy") | |
| 91 | .current_dir(dir) | |
| 92 | .env_remove("GIT_REMOTE") | |
| 93 | .env("G1T_API", api) | |
| 94 | .env("DEPLOY_ID", "dpl_1") | |
| 95 | .env("DEPLOY_TOKEN", "build-token-0123456789") | |
| 96 | .env("BUILD_SECRETS", r#"{"API_KEY":"very-secret-value"}"#) | |
| 97 | .env("BUILD_ENV", r#"{"PUBLIC_URL":"https://example.com"}"#); | |
| 98 | for (name, value) in extra { | |
| 99 | command.env(name, value); | |
| 100 | } | |
| 101 | command.output().unwrap() | |
| 102 | } | |
| 103 | ||
| 104 | #[test] | |
| 105 | fn deploy_builds_the_checkout_it_runs_in_without_cloning() { | |
| 106 | let (api, seen) = fake_api(); | |
| 107 | let dir = checkout("deploy", &[("site/index.html", "<h1>hello</h1>"), ("site/about.html", "<p>about</p>"), ("README.md", "not served")]); | |
| 108 | let output = deploy(&dir, &api, &[("ROOT_DIR", "site")]); | |
| 109 | let stdout = String::from_utf8_lossy(&output.stdout); | |
| 110 | assert!(output.status.success(), "{stdout}\n{}", String::from_utf8_lossy(&output.stderr)); | |
| 111 | // Its log is the step's too. | |
| 112 | assert!(stdout.contains("as it is checked out"), "{stdout}"); | |
| 113 | assert!(stdout.contains("Uploading 2 files."), "{stdout}"); | |
| 114 | let seen = seen.lock().unwrap(); | |
| 115 | let steps: Vec<&str> = seen.iter().map(|(path, _)| path.as_str()).collect(); | |
| 116 | assert_eq!(steps, ["/deployments/jobs/dpl_1/started", "/deployments/jobs/dpl_1/session", "/deployments/jobs/dpl_1/finish"]); | |
| 117 | for (_, body) in seen.iter() { | |
| 118 | assert_eq!(body["token"], "build-token-0123456789"); | |
| 119 | } | |
| 120 | let manifest = seen[1].1["manifest"].as_object().unwrap(); | |
| 121 | let mut files: Vec<&String> = manifest.keys().collect(); | |
| 122 | files.sort(); | |
| 123 | assert_eq!(files, ["/about.html", "/index.html"]); | |
| 124 | let finish = &seen[2].1; | |
| 125 | assert_eq!(finish["detected"], "html"); | |
| 126 | assert_eq!(finish["completionJwt"], "complete-me"); | |
| 127 | assert!(!finish["log"].as_str().unwrap().contains("build-token-0123456789")); | |
| 128 | } | |
| 129 | ||
| 130 | #[test] | |
| 131 | fn a_failed_build_is_reported_and_fails_the_step() { | |
| 132 | let (api, seen) = fake_api(); | |
| 133 | let dir = checkout("fail", &[("README.md", "nothing to serve")]); | |
| 134 | let output = deploy(&dir, &api, &[]); | |
| 135 | assert_eq!(output.status.code(), Some(1)); | |
| 136 | let stdout = String::from_utf8_lossy(&output.stdout); | |
| 137 | assert!(stdout.contains("found nothing to serve"), "{stdout}"); | |
| 138 | let seen = seen.lock().unwrap(); | |
| 139 | assert_eq!(seen.last().unwrap().0, "/deployments/jobs/dpl_1/fail"); | |
| 140 | // A missing root directory says so. | |
| 141 | drop(seen); | |
| 142 | let missing = deploy(&dir, &api, &[("ROOT_DIR", "apps/web")]); | |
| 143 | assert_eq!(missing.status.code(), Some(1)); | |
| 144 | assert!(String::from_utf8_lossy(&missing.stdout).contains("does not exist in this checkout")); | |
| 145 | } | |
| 146 | ||
| 147 | #[test] | |
| 148 | fn deploy_needs_its_build() { | |
| 149 | let output = runner().arg("deploy").env_remove("DEPLOY_ID").env_remove("DEPLOY_TOKEN").output().unwrap(); | |
| 150 | assert_eq!(output.status.code(), Some(2)); | |
| 151 | assert!(String::from_utf8_lossy(&output.stderr).contains("DEPLOY_ID")); | |
| 152 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.