Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t.sh
294 linesCodeBlameRaw
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
5use std::io::{BufRead, BufReader, Read, Write};
6use std::net::TcpListener;
7use std::path::{Path, PathBuf};
8use std::process::Command;
9use std::sync::{Arc, Mutex};
10
11use serde_json::{Value, json};
12
13fn runner() -> Command {
14 Command::new(env!("CARGO_BIN_EXE_g1t-runner"))
15}
16
17/// A fresh checkout holding `files`.
18fn 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]
31fn 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.
42fn 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 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." } })
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
90fn 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]
108fn 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]
134fn 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]
151fn 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}
156
157#[test]
158fn a_container_is_built_as_its_site_where_containers_are_switched_off() {
159 let (api, seen) = fake_api();
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>")]);
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));
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}");
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");
171 assert_eq!(seen.last().unwrap().1["detected"], "static");
172 drop(seen);
173 // A site beside a Dockerfile that runs a server: the site, with no
174 // image asked for, saying the Dockerfile is not used.
175 let (api, seen) = fake_api();
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 );
180 let output = deploy(&files, &api, &[]);
181 let stdout = String::from_utf8_lossy(&output.stdout);
182 assert!(output.status.success(), "{stdout}");
183 assert!(stdout.contains("Built as a plain HTML site; Dockerfile is not used (a container is only the fallback"), "{stdout}");
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);
188 // A server with nothing else to serve fails, saying so.
189 let server = checkout("container-only", &[("go.mod", "module x\n"), ("main.go", "package main\n")]);
190 let output = deploy(&server, &api, &[]);
191 assert_eq!(output.status.code(), Some(1));
192 assert!(String::from_utf8_lossy(&output.stdout).contains("Docker deployments are switched off"));
193 // Build as Static site skips the server altogether.
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
200install=
201build=docker build -f Dockerfile (written by g1t) .
202output-dir=
203framework=go
204dockerfile=
205port=8080
206"
207 );
208}
209
210/// Whether `npx` can run here: the Node.js tests below need it, and the
211/// network to install their packages.
212fn 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.
218static NPX: Mutex<()> = Mutex::new(());
219
220#[test]
221#[ignore = "installs Express and wrangler from npm, and runs the Worker with wrangler dev"]
222fn 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"]
259fn 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}