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 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r9 | 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" } }) | |
| Merge GitHub pull request #8 (claude/cool-newton-7nm8r9): Docker deployments on g1t.page behind a switch that stays off until Cloudflare's container APIs are checked live, workflows a workspace manages in its repositories, tarball and zipball downloads, refs/pull/* kept to g1t alone, framework detection for g1t.page builds, and a workflow's event payload matching GitHub's. | 72 | } else if path.ends_with("/registry") { |
| 73 | // As the deployments service answers while it runs no containers. | |
| 74 | json!({ "ok": false, "error": { "code": "not_enabled", "message": "Docker deployments are not on." } }) | |
| Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r9 | 75 | } else { |
| 76 | json!({ "ok": true, "value": true }) | |
| 77 | }; | |
| 78 | kept.lock().unwrap().push((path, body)); | |
| 79 | let text = answer.to_string(); | |
| 80 | let _ = write!( | |
| 81 | stream, | |
| 82 | "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{text}", | |
| 83 | text.len() | |
| 84 | ); | |
| 85 | } | |
| 86 | }); | |
| 87 | (base, seen) | |
| 88 | } | |
| 89 | ||
| 90 | fn deploy(dir: &Path, api: &str, extra: &[(&str, &str)]) -> std::process::Output { | |
| 91 | let mut command = runner(); | |
| 92 | command | |
| 93 | .arg("deploy") | |
| 94 | .current_dir(dir) | |
| 95 | .env_remove("GIT_REMOTE") | |
| 96 | .env("G1T_API", api) | |
| 97 | .env("DEPLOY_ID", "dpl_1") | |
| 98 | .env("DEPLOY_TOKEN", "build-token-0123456789") | |
| 99 | .env("BUILD_SECRETS", r#"{"API_KEY":"very-secret-value"}"#) | |
| 100 | .env("BUILD_ENV", r#"{"PUBLIC_URL":"https://example.com"}"#); | |
| 101 | for (name, value) in extra { | |
| 102 | command.env(name, value); | |
| 103 | } | |
| 104 | command.output().unwrap() | |
| 105 | } | |
| 106 | ||
| 107 | #[test] | |
| 108 | fn deploy_builds_the_checkout_it_runs_in_without_cloning() { | |
| 109 | let (api, seen) = fake_api(); | |
| 110 | let dir = checkout("deploy", &[("site/index.html", "<h1>hello</h1>"), ("site/about.html", "<p>about</p>"), ("README.md", "not served")]); | |
| 111 | let output = deploy(&dir, &api, &[("ROOT_DIR", "site")]); | |
| 112 | let stdout = String::from_utf8_lossy(&output.stdout); | |
| 113 | assert!(output.status.success(), "{stdout}\n{}", String::from_utf8_lossy(&output.stderr)); | |
| 114 | // Its log is the step's too. | |
| 115 | assert!(stdout.contains("as it is checked out"), "{stdout}"); | |
| 116 | assert!(stdout.contains("Uploading 2 files."), "{stdout}"); | |
| 117 | let seen = seen.lock().unwrap(); | |
| 118 | let steps: Vec<&str> = seen.iter().map(|(path, _)| path.as_str()).collect(); | |
| 119 | assert_eq!(steps, ["/deployments/jobs/dpl_1/started", "/deployments/jobs/dpl_1/session", "/deployments/jobs/dpl_1/finish"]); | |
| 120 | for (_, body) in seen.iter() { | |
| 121 | assert_eq!(body["token"], "build-token-0123456789"); | |
| 122 | } | |
| 123 | let manifest = seen[1].1["manifest"].as_object().unwrap(); | |
| 124 | let mut files: Vec<&String> = manifest.keys().collect(); | |
| 125 | files.sort(); | |
| 126 | assert_eq!(files, ["/about.html", "/index.html"]); | |
| 127 | let finish = &seen[2].1; | |
| 128 | assert_eq!(finish["detected"], "html"); | |
| 129 | assert_eq!(finish["completionJwt"], "complete-me"); | |
| 130 | assert!(!finish["log"].as_str().unwrap().contains("build-token-0123456789")); | |
| 131 | } | |
| 132 | ||
| 133 | #[test] | |
| 134 | fn a_failed_build_is_reported_and_fails_the_step() { | |
| 135 | let (api, seen) = fake_api(); | |
| 136 | let dir = checkout("fail", &[("README.md", "nothing to serve")]); | |
| 137 | let output = deploy(&dir, &api, &[]); | |
| 138 | assert_eq!(output.status.code(), Some(1)); | |
| 139 | let stdout = String::from_utf8_lossy(&output.stdout); | |
| 140 | assert!(stdout.contains("found nothing to serve"), "{stdout}"); | |
| 141 | let seen = seen.lock().unwrap(); | |
| 142 | assert_eq!(seen.last().unwrap().0, "/deployments/jobs/dpl_1/fail"); | |
| 143 | // A missing root directory says so. | |
| 144 | drop(seen); | |
| 145 | let missing = deploy(&dir, &api, &[("ROOT_DIR", "apps/web")]); | |
| 146 | assert_eq!(missing.status.code(), Some(1)); | |
| 147 | assert!(String::from_utf8_lossy(&missing.stdout).contains("does not exist in this checkout")); | |
| 148 | } | |
| 149 | ||
| 150 | #[test] | |
| 151 | fn deploy_needs_its_build() { | |
| 152 | let output = runner().arg("deploy").env_remove("DEPLOY_ID").env_remove("DEPLOY_TOKEN").output().unwrap(); | |
| 153 | assert_eq!(output.status.code(), Some(2)); | |
| 154 | assert!(String::from_utf8_lossy(&output.stderr).contains("DEPLOY_ID")); | |
| 155 | } | |
| Merge GitHub pull request #8 (claude/cool-newton-7nm8r9): Docker deployments on g1t.page behind a switch that stays off until Cloudflare's container APIs are checked live, workflows a workspace manages in its repositories, tarball and zipball downloads, refs/pull/* kept to g1t alone, framework detection for g1t.page builds, and a workflow's event payload matching GitHub's. | 156 | |
| 157 | #[test] | |
| 158 | fn a_container_is_built_as_its_site_while_g1t_page_runs_none() { | |
| 159 | let (api, seen) = fake_api(); | |
| 160 | let dir = checkout("container-site", &[("index.html", "<h1>hello</h1>"), ("Dockerfile", "FROM nginx")]); | |
| 161 | let output = deploy(&dir, &api, &[]); | |
| 162 | let stdout = String::from_utf8_lossy(&output.stdout); | |
| 163 | assert!(output.status.success(), "{stdout} | |
| 164 | {}", String::from_utf8_lossy(&output.stderr)); | |
| 165 | assert!(stdout.contains("as a plain HTML site instead"), "{stdout}"); | |
| 166 | let seen = seen.lock().unwrap(); | |
| 167 | let steps: Vec<&str> = seen.iter().map(|(path, _)| path.as_str()).collect(); | |
| 168 | assert_eq!(steps[1], "/deployments/jobs/dpl_1/registry"); | |
| 169 | assert_eq!(seen.last().unwrap().1["detected"], "html"); | |
| 170 | // A server with nothing else to serve fails, saying so. | |
| 171 | drop(seen); | |
| 172 | let server = checkout("container-only", &[("package.json", r#"{ "scripts": { "start": "node index.js" }, "dependencies": { "express": "5" } }"#)]); | |
| 173 | let output = deploy(&server, &api, &[]); | |
| 174 | assert_eq!(output.status.code(), Some(1)); | |
| 175 | assert!(String::from_utf8_lossy(&output.stdout).contains("Docker deployments are not switched on")); | |
| 176 | // Build as Static site skips the Dockerfile altogether. | |
| 177 | let output = deploy(&dir, &api, &[("BUILD_AS", "static")]); | |
| 178 | assert!(output.status.success()); | |
| 179 | let detect = runner().arg("detect").current_dir(&server).env_remove("GIT_REMOTE").output().unwrap(); | |
| 180 | assert_eq!( | |
| 181 | String::from_utf8_lossy(&detect.stdout), | |
| 182 | "kind=container | |
| 183 | install= | |
| 184 | build=docker build -f Dockerfile (written by g1t) . | |
| 185 | output-dir= | |
| 186 | framework=node | |
| 187 | dockerfile= | |
| 188 | port=8080 | |
| 189 | " | |
| 190 | ); | |
| 191 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.