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] | |
| Merge branch 'worktree-agent-a4d0c5ee9c2f877aa' | 158 | fn a_container_is_built_as_its_site_where_containers_are_switched_off() { |
| 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. | 159 | let (api, seen) = fake_api(); |
| Merge branch 'worktree-agent-ae1b1f625cfdcab1b' | 160 | // A Go server beside a folder of files: a container, else the files. |
| 161 | let dir = checkout("container-site", &[("go.mod", "module x\n"), ("main.go", "package main\n"), ("public/index.html", "<h1>hello</h1>")]); | |
| 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. | 162 | let output = deploy(&dir, &api, &[]); |
| 163 | let stdout = String::from_utf8_lossy(&output.stdout); | |
| 164 | assert!(output.status.success(), "{stdout} | |
| 165 | {}", String::from_utf8_lossy(&output.stderr)); | |
| Merge branch 'worktree-agent-ae1b1f625cfdcab1b' | 166 | assert!(stdout.contains("Building it as a Go server, not as a site of `public`"), "{stdout}"); |
| 167 | assert!(stdout.contains("as a static site instead"), "{stdout}"); | |
| 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. | 168 | let seen = seen.lock().unwrap(); |
| 169 | let steps: Vec<&str> = seen.iter().map(|(path, _)| path.as_str()).collect(); | |
| 170 | assert_eq!(steps[1], "/deployments/jobs/dpl_1/registry"); | |
| Merge branch 'worktree-agent-ae1b1f625cfdcab1b' | 171 | assert_eq!(seen.last().unwrap().1["detected"], "static"); |
| Merge branch 'worktree-agent-a4d0c5ee9c2f877aa' | 172 | drop(seen); |
| Merge branch 'worktree-agent-ae1b1f625cfdcab1b' | 173 | // A site beside a Dockerfile that runs a server: the site, with no |
| 174 | // image asked for, saying the Dockerfile is not used. | |
| Merge branch 'worktree-agent-a4d0c5ee9c2f877aa' | 175 | let (api, seen) = fake_api(); |
| Merge branch 'worktree-agent-ae1b1f625cfdcab1b' | 176 | let files = checkout( |
| 177 | "site-and-dockerfile", | |
| 178 | &[("index.html", "<h1>hello</h1>"), ("server.js", ""), ("Dockerfile", "FROM node:22-slim\nCOPY . .\nCMD [\"node\", \"server.js\"]\n")], | |
| 179 | ); | |
| Merge branch 'worktree-agent-a4d0c5ee9c2f877aa' | 180 | let output = deploy(&files, &api, &[]); |
| 181 | let stdout = String::from_utf8_lossy(&output.stdout); | |
| 182 | assert!(output.status.success(), "{stdout}"); | |
| Merge branch 'worktree-agent-ae1b1f625cfdcab1b' | 183 | assert!(stdout.contains("Built as a plain HTML site; Dockerfile is not used (a container is only the fallback"), "{stdout}"); |
| Merge branch 'worktree-agent-a4d0c5ee9c2f877aa' | 184 | let seen = seen.lock().unwrap(); |
| 185 | assert!(!seen.iter().any(|(path, _)| path.ends_with("/registry"))); | |
| 186 | assert_eq!(seen.last().unwrap().1["detected"], "html"); | |
| 187 | drop(seen); | |
| 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. | 188 | // A server with nothing else to serve fails, saying so. |
| Merge branch 'worktree-agent-ae1b1f625cfdcab1b' | 189 | let server = checkout("container-only", &[("go.mod", "module x\n"), ("main.go", "package main\n")]); |
| 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. | 190 | let output = deploy(&server, &api, &[]); |
| 191 | assert_eq!(output.status.code(), Some(1)); | |
| Merge branch 'worktree-agent-a4d0c5ee9c2f877aa' | 192 | assert!(String::from_utf8_lossy(&output.stdout).contains("Docker deployments are switched off")); |
| Merge branch 'worktree-agent-ae1b1f625cfdcab1b' | 193 | // Build as Static site skips the server altogether. |
| 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. | 194 | let output = deploy(&dir, &api, &[("BUILD_AS", "static")]); |
| 195 | assert!(output.status.success()); | |
| 196 | let detect = runner().arg("detect").current_dir(&server).env_remove("GIT_REMOTE").output().unwrap(); | |
| 197 | assert_eq!( | |
| 198 | String::from_utf8_lossy(&detect.stdout), | |
| 199 | "kind=container | |
| 200 | install= | |
| 201 | build=docker build -f Dockerfile (written by g1t) . | |
| 202 | output-dir= | |
| Merge branch 'worktree-agent-ae1b1f625cfdcab1b' | 203 | framework=go |
| 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. | 204 | dockerfile= |
| 205 | port=8080 | |
| 206 | " | |
| 207 | ); | |
| 208 | } | |
| Merge branch 'worktree-agent-ae1b1f625cfdcab1b' | 209 | |
| 210 | /// Whether `npx` can run here: the Node.js tests below need it, and the | |
| 211 | /// network to install their packages. | |
| 212 | fn has_npx() -> bool { | |
| 213 | Command::new(if cfg!(windows) { "npx.cmd" } else { "npx" }).arg("--version").output().is_ok_and(|output| output.status.success()) | |
| 214 | } | |
| 215 | ||
| 216 | /// One of the Node.js tests at a time: two `npx --yes wrangler@4` installing | |
| 217 | /// into an empty npx cache at once leave it broken for both. | |
| 218 | static NPX: Mutex<()> = Mutex::new(()); | |
| 219 | ||
| 220 | #[test] | |
| 221 | #[ignore = "installs Express and wrangler from npm, and runs the Worker with wrangler dev"] | |
| 222 | fn an_express_server_deploys_as_a_worker() { | |
| 223 | let _one = NPX.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); | |
| 224 | if !has_npx() { | |
| 225 | return; | |
| 226 | } | |
| 227 | let (api, seen) = fake_api(); | |
| 228 | let dir = checkout( | |
| 229 | "express-worker", | |
| 230 | &[ | |
| 231 | ("package.json", r#"{ "private": true, "scripts": { "start": "node server.js" }, "dependencies": { "express": "5.1.0" } }"#), | |
| 232 | ( | |
| 233 | "server.js", | |
| 234 | "const express = require('express');\nconst app = express();\napp.use(express.static('public'));\napp.get('/api', (req, res) => res.json({ ok: true, url: process.env.PUBLIC_URL }));\napp.listen(process.env.PORT || 3000, '0.0.0.0');\n", | |
| 235 | ), | |
| 236 | ("public/index.html", "<h1>hello</h1>"), | |
| 237 | ], | |
| 238 | ); | |
| 239 | let output = deploy(&dir, &api, &[]); | |
| 240 | let stdout = String::from_utf8_lossy(&output.stdout); | |
| 241 | assert!(output.status.success(), "{stdout}\n{}", String::from_utf8_lossy(&output.stderr)); | |
| 242 | // The build's log, for `--nocapture`. | |
| 243 | eprintln!("{stdout}"); | |
| 244 | assert!(stdout.contains("Running this Express server as a Worker, from `server.js`"), "{stdout}"); | |
| 245 | assert!(stdout.contains("It started and answered a request, as a Worker."), "{stdout}"); | |
| 246 | assert!(stdout.contains("Serving `public` as the Worker's static files"), "{stdout}"); | |
| 247 | let seen = seen.lock().unwrap(); | |
| 248 | assert!(!seen.iter().any(|(path, _)| path.ends_with("/registry"))); | |
| 249 | let finish = &seen.last().unwrap().1; | |
| 250 | assert_eq!((finish["detected"].as_str(), finish["framework"].as_str()), (Some("workers"), Some("node"))); | |
| 251 | assert_eq!(finish["worker"]["mainModule"], "g1t-entry.js"); | |
| 252 | assert_eq!(finish["worker"]["compatibilityFlags"], json!(["nodejs_compat"])); | |
| 253 | let manifest = seen.iter().find(|(path, _)| path.ends_with("/session")).unwrap().1["manifest"].as_object().unwrap().clone(); | |
| 254 | assert_eq!(manifest.keys().collect::<Vec<_>>(), ["/index.html"]); | |
| 255 | } | |
| 256 | ||
| 257 | #[test] | |
| 258 | #[ignore = "installs packages from npm and bundles them with wrangler"] | |
| 259 | fn a_server_that_cannot_run_as_a_worker_falls_back_to_a_container() { | |
| 260 | let _one = NPX.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); | |
| 261 | if !has_npx() { | |
| 262 | return; | |
| 263 | } | |
| 264 | let (api, seen) = fake_api(); | |
| 265 | // A module the bundle cannot find: wrangler fails, and the container is | |
| 266 | // next (switched off here, so the build fails saying both). | |
| 267 | let dir = checkout( | |
| 268 | "worker-fallback", | |
| 269 | &[ | |
| 270 | ("package.json", r#"{ "private": true, "scripts": { "start": "node server.js" }, "dependencies": { "express": "5.1.0" } }"#), | |
| 271 | ("server.js", "require('./missing-module');\nrequire('express')().listen(3000);\n"), | |
| 272 | ], | |
| 273 | ); | |
| 274 | let output = deploy(&dir, &api, &[]); | |
| 275 | let stdout = String::from_utf8_lossy(&output.stdout); | |
| 276 | assert_eq!(output.status.code(), Some(1), "{stdout}"); | |
| 277 | assert!(stdout.contains("Could not run as a Worker: `npx --yes wrangler@4 deploy --dry-run"), "{stdout}"); | |
| 278 | assert!(stdout.contains("; deploying as a container."), "{stdout}"); | |
| 279 | assert!(stdout.contains("Docker deployments are switched off"), "{stdout}"); | |
| 280 | assert!(seen.lock().unwrap().iter().any(|(path, _)| path.ends_with("/registry"))); | |
| 281 | // A top-level await that never settles bundles, and fails as it starts. | |
| 282 | let (api, _) = fake_api(); | |
| 283 | let unsettled = checkout( | |
| 284 | "worker-unsettled", | |
| 285 | &[ | |
| 286 | ("package.json", r#"{ "private": true, "type": "module", "scripts": { "start": "node server.js" }, "dependencies": { "express": "5.1.0" } }"#), | |
| 287 | ("server.js", "import express from 'express';\nawait new Promise((resolve) => setTimeout(resolve, 10));\nexpress().listen(3000);\n"), | |
| 288 | ], | |
| 289 | ); | |
| 290 | let output = deploy(&unsettled, &api, &[]); | |
| 291 | let stdout = String::from_utf8_lossy(&output.stdout); | |
| 292 | assert_eq!(output.status.code(), Some(1), "{stdout}"); | |
| 293 | assert!(stdout.contains("Could not run as a Worker: it failed as it started: Uncaught"), "{stdout}"); | |
| 294 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.