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
2,343 linesCodeBlameRaw
1//! Builds one commit of a repository and hands the result to Cloudflare, as
2//! a preview of a pull request or as the repository's production.
3//!
4//! The sandbox never holds a Cloudflare credential that could touch anything
5//! else. The deployments service opens an upload for exactly the files
6//! this build produced and gives back a key that can only upload those;
7//! the sandbox uploads them with it, and sends the Worker's code to the
8//! service, which puts the app in place.
9//!
10//! What gets built:
11//!
12//! - A Workers project (a `wrangler.jsonc`, `wrangler.json` or
13//! `wrangler.toml`): bundled by `wrangler deploy --dry-run`, with its
14//! static assets, compatibility settings and `vars`. Other bindings (D1,
15//! KV, R2, Durable Objects…) are not provisioned yet; the deployment says
16//! which were left out.
17//! - A framework g1t knows (deploy/frameworks.rs: Vite, Astro, Next.js,
18//! SvelteKit, Nuxt, Hugo, Jekyll and the rest), found by the package's
19//! dependencies and config files: built with its own command and served
20//! from the folder it writes. A Cloudflare adapter's Worker (Astro's
21//! `dist/_worker.js`, SvelteKit's `.svelte-kit/cloudflare/_worker.js`) is
22//! bundled as a Workers project's is, without a Wrangler config of its
23//! own. A framework set up to need a server fails before anything is
24//! installed, saying what to change.
25//! - A container (deploy/container.rs): a project with a `Dockerfile` (or
26//! `Containerfile`, or the path it chose) and no framework that builds a
27//! static site or a Worker, a framework set up to need a server, or a
28//! Node.js, Python, Go, Ruby or PHP server, for which g1t writes a
29//! Dockerfile and shows it in the log. The image is built with Docker
30//! and pushed to Cloudflare's registry with a push-only login the
31//! deployments service gives this build alone, for minutes (the
32//! `registry` step); the service runs it.
33//! - Anything else: a static site. Its `build` script runs, and the first
34//! of `dist`, `build`, `out`, `public`, `_site` or `.output/public` that
35//! exists is served, or the repository itself if it has an `index.html`.
36//!
37//! The project's own **Build as** (`BUILD_AS`: auto, static, workers or
38//! docker), **Dockerfile** (`DOCKERFILE`), **Build command** and **Output
39//! directory** win over what is detected.
40//!
41//! Configuration comes from the environment:
42//!
43//! - `G1T_API`, `DEPLOY_ID`, `DEPLOY_TOKEN`: where and how to report.
44//! - `GIT_REMOTE`, `GIT_COMMIT`, `G1T_USER`, `G1T_TOKEN`: what to check out.
45//! - `ROOT_DIR`: where in the repository the project lives; empty for all
46//! of it. Everything below is relative to it.
47//! - `BUILD_COMMAND`, `OUTPUT_DIR`: the project's own choices, if any.
48//! - `BUILD_ENV`, `BUILD_SECRETS`: JSON objects of the repository's
49//! variables and secrets for deploy builds. Both are set for the build;
50//! secrets' values are redacted from its log.
51//!
52//! From a workflow step, `g1t-runner deploy` runs the same build in the
53//! job's own checkout: without `GIT_REMOTE` nothing is cloned, and the
54//! project is the current directory (`ROOT_DIR` below it). The build's id
55//! and token come from `POST /repos/{owner}/{repo}/deployments/builds`,
56//! which the `g1t/deploy` action calls (actions/deploy). Its output goes
57//! to the step's log as well as the deployment's. `g1t-runner detect`
58//! says what the build would find, as `key=value` lines for
59//! `$GITHUB_OUTPUT`, without building anything.
60
61use std::collections::{BTreeMap, BTreeSet};
62use std::path::{Path, PathBuf};
63use std::time::Instant;
64
65use anyhow::{Context, Result, bail};
66use base64::Engine;
67use base64::engine::general_purpose::STANDARD;
68use serde::{Deserialize, Serialize};
69use serde_json::{Value, json};
70use sha2::{Digest, Sha256};
71
72use crate::checks::{redact, run_command};
73use crate::{WORKDIR, auth_option, env, git};
74
75mod container;
76mod frameworks;
77
78use container::BuildAs;
79use frameworks::{Adapter, Framework};
80
81/// Where `wrangler deploy --dry-run` writes the bundle in a sandbox.
82const BUNDLE_DIR: &str = "/work/g1t-bundle";
83/// Cloudflare's limits on a Worker's static assets.
84const MAX_FILES: usize = 20_000;
85const MAX_FILE_BYTES: u64 = 25 * 1024 * 1024;
86/// How much of the build's output is kept for the deployment's log.
87const MAX_LOG_CHARS: usize = 20_000;
88/// Directories a static build usually writes to, in the order they are tried.
89const OUTPUT_DIRS: [&str; 6] = ["dist", "build", "out", "public", "_site", ".output/public"];
90/// Bindings a Workers project may declare that are not provisioned yet.
91const UNSUPPORTED_BINDINGS: [&str; 10] = [
92 "kv_namespaces",
93 "d1_databases",
94 "r2_buckets",
95 "durable_objects",
96 "services",
97 "queues",
98 "vectorize",
99 "hyperdrive",
100 "ai",
101 "workflows",
102];
103
104struct Reporter {
105 base: String,
106 token: String,
107}
108
109impl Reporter {
110 fn send(&self, step: &str, mut body: Value) -> Result<Value> {
111 body["token"] = self.token.clone().into();
112 match ureq::post(&format!("{}/{step}", self.base)).send_json(body) {
113 Ok(response) => Ok(response.into_json().unwrap_or(Value::Null)),
114 // A refusal (409 `not_enabled`, say) still says why in its
115 // body, which the step reads: ureq makes any status over 399
116 // an error.
117 Err(ureq::Error::Status(status, response)) => match response.into_json::<Value>() {
118 Ok(answer) if answer.get("ok").is_some() => Ok(answer),
119 _ => bail!("could not report `{step}` to g1t: status code {status}"),
120 },
121 Err(error) => Err(error).with_context(|| format!("could not report `{step}` to g1t")),
122 }
123 }
124}
125
126/// The build's log, kept to its end; also printed, in a workflow step.
127#[derive(Default)]
128struct Log {
129 text: String,
130 /// Each line to standard output as well, redacted.
131 echo: Option<Vec<String>>,
132}
133
134impl Log {
135 fn line(&mut self, line: &str) {
136 self.text.push_str(line);
137 self.text.push('\n');
138 if let Some(secrets) = &self.echo {
139 println!("{}", redact(line, secrets));
140 }
141 }
142
143 fn tail(&self) -> String {
144 let length = self.text.chars().count();
145 if length <= MAX_LOG_CHARS {
146 return self.text.clone();
147 }
148 let kept: String = self.text.chars().skip(length - MAX_LOG_CHARS).collect();
149 format!("… (earlier output not shown)\n{kept}")
150 }
151}
152
153/// Runs a command in the checkout, logging it; fails if it fails.
154fn step(log: &mut Log, command: &str, secrets: &[String]) -> Result<()> {
155 log.line(&format!("$ {command}"));
156 let result = run_command(command, &project_dir(), secrets);
157 if !result.output_text().is_empty() {
158 log.line(result.output_text());
159 }
160 if !result.passed {
161 bail!("`{command}` failed");
162 }
163 Ok(())
164}
165
166/// A Workers project's settings, from whichever config file it has.
167#[derive(Debug, Default, Deserialize)]
168struct WranglerConfig {
169 main: Option<String>,
170 compatibility_date: Option<String>,
171 #[serde(default)]
172 compatibility_flags: Vec<String>,
173 assets: Option<AssetsConfig>,
174 #[serde(default)]
175 vars: BTreeMap<String, Value>,
176 #[serde(flatten)]
177 rest: BTreeMap<String, Value>,
178}
179
180#[derive(Debug, Default, Deserialize)]
181struct AssetsConfig {
182 directory: Option<String>,
183 binding: Option<String>,
184 html_handling: Option<String>,
185 not_found_handling: Option<String>,
186}
187
188/// JSON with comments and trailing commas, as `wrangler.jsonc` allows.
189fn strip_jsonc(text: &str) -> String {
190 let mut out = String::with_capacity(text.len());
191 let mut chars = text.chars().peekable();
192 let mut in_string = false;
193 while let Some(c) = chars.next() {
194 if in_string {
195 out.push(c);
196 if c == '\\' {
197 if let Some(next) = chars.next() {
198 out.push(next);
199 }
200 } else if c == '"' {
201 in_string = false;
202 }
203 continue;
204 }
205 match (c, chars.peek()) {
206 ('"', _) => {
207 in_string = true;
208 out.push(c);
209 }
210 ('/', Some('/')) => {
211 for c in chars.by_ref() {
212 if c == '\n' {
213 out.push('\n');
214 break;
215 }
216 }
217 }
218 ('/', Some('*')) => {
219 chars.next();
220 let mut last = ' ';
221 for c in chars.by_ref() {
222 if last == '*' && c == '/' {
223 break;
224 }
225 last = c;
226 }
227 }
228 _ => out.push(c),
229 }
230 }
231 // Trailing commas before a closing bracket.
232 let mut cleaned = String::with_capacity(out.len());
233 let chars: Vec<char> = out.chars().collect();
234 let mut in_string = false;
235 let mut i = 0;
236 while i < chars.len() {
237 let c = chars[i];
238 if c == '"' && (i == 0 || chars[i - 1] != '\\') {
239 in_string = !in_string;
240 }
241 if c == ',' && !in_string {
242 let next = chars[i + 1..].iter().find(|c| !c.is_whitespace());
243 if matches!(next, Some('}') | Some(']')) {
244 i += 1;
245 continue;
246 }
247 }
248 cleaned.push(c);
249 i += 1;
250 }
251 cleaned
252}
253
254fn read_wrangler(dir: &Path) -> Result<Option<WranglerConfig>> {
255 for name in ["wrangler.jsonc", "wrangler.json"] {
256 let path = dir.join(name);
257 if path.exists() {
258 let text = std::fs::read_to_string(&path)?;
259 return Ok(Some(
260 serde_json::from_str(&strip_jsonc(&text)).with_context(|| format!("could not read {name}"))?,
261 ));
262 }
263 }
264 let path = dir.join("wrangler.toml");
265 if path.exists() {
266 let text = std::fs::read_to_string(&path)?;
267 return Ok(Some(toml::from_str(&text).context("could not read wrangler.toml")?));
268 }
269 Ok(None)
270}
271
272/// How to install the project's dependencies, judged by its lockfile.
273fn install_command(dir: &Path) -> Option<&'static str> {
274 if !dir.join("package.json").exists() {
275 return None;
276 }
277 Some(if dir.join("pnpm-lock.yaml").exists() {
278 "corepack enable && pnpm install --frozen-lockfile"
279 } else if dir.join("yarn.lock").exists() {
280 "corepack enable && yarn install"
281 } else if dir.join("bun.lockb").exists() || dir.join("bun.lock").exists() {
282 "npx --yes bun install"
283 } else if dir.join("package-lock.json").exists() {
284 "npm ci"
285 } else {
286 "npm install"
287 })
288}
289
290fn has_build_script(dir: &Path) -> bool {
291 std::fs::read_to_string(dir.join("package.json"))
292 .ok()
293 .and_then(|text| serde_json::from_str::<Value>(&text).ok())
294 .is_some_and(|package| package["scripts"]["build"].is_string())
295}
296
297/// One file of the site, as Cloudflare's asset upload names it.
298struct Asset {
299 path: String,
300 hash: String,
301 size: u64,
302 file: PathBuf,
303}
304
305fn content_type(path: &str) -> &'static str {
306 let extension = path.rsplit('.').next().unwrap_or("").to_ascii_lowercase();
307 match extension.as_str() {
308 "html" | "htm" => "text/html",
309 "css" => "text/css",
310 "js" | "mjs" => "application/javascript",
311 "json" | "map" => "application/json",
312 "svg" => "image/svg+xml",
313 "png" => "image/png",
314 "jpg" | "jpeg" => "image/jpeg",
315 "gif" => "image/gif",
316 "webp" => "image/webp",
317 "avif" => "image/avif",
318 "ico" => "image/x-icon",
319 "woff" => "font/woff",
320 "woff2" => "font/woff2",
321 "ttf" => "font/ttf",
322 "txt" => "text/plain",
323 "xml" => "application/xml",
324 "wasm" => "application/wasm",
325 "pdf" => "application/pdf",
326 "mp4" => "video/mp4",
327 "webm" => "video/webm",
328 _ => "application/octet-stream",
329 }
330}
331
332/// What is never served from the top of a site: a Pages-style Worker
333/// (`_worker.js`, its server code, which a Cloudflare adapter writes beside
334/// the site's files), `_routes.json`, and `.assetsignore`, with each name
335/// that `.assetsignore` lists, as wrangler leaves them out.
336fn ignored_at_root(root: &Path) -> BTreeSet<String> {
337 let mut ignored: BTreeSet<String> = ["_worker.js", "_routes.json", ".assetsignore"].map(str::to_owned).into();
338 if let Ok(text) = std::fs::read_to_string(root.join(".assetsignore")) {
339 ignored.extend(
340 text.lines()
341 .map(|line| line.trim().trim_matches('/'))
342 .filter(|line| !line.is_empty() && !line.starts_with('#') && !line.contains(['*', '/', '!']))
343 .map(str::to_owned),
344 );
345 }
346 ignored
347}
348
349/// Every file of the site at `root`, but for what never belongs in one.
350fn collect_site(root: &Path, skip_project: bool) -> Result<Vec<Asset>> {
351 let mut assets = Vec::new();
352 collect(root, root, skip_project, &ignored_at_root(root), &mut assets)?;
353 Ok(assets)
354}
355
356fn collect(root: &Path, dir: &Path, skip_project: bool, ignored: &BTreeSet<String>, out: &mut Vec<Asset>) -> Result<()> {
357 for entry in std::fs::read_dir(dir)? {
358 let entry = entry?;
359 let name = entry.file_name().to_string_lossy().into_owned();
360 let path = entry.path();
361 let kind = entry.file_type()?;
362 if name == ".git" || (skip_project && (name == "node_modules" || name.starts_with(".g1t"))) {
363 continue;
364 }
365 if dir == root && ignored.contains(&name) {
366 continue;
367 }
368 if kind.is_dir() {
369 collect(root, &path, skip_project, ignored, out)?;
370 continue;
371 }
372 if !kind.is_file() || name == "_headers" || name == "_redirects" {
373 continue;
374 }
375 let size = entry.metadata()?.len();
376 let relative = path
377 .strip_prefix(root)?
378 .to_string_lossy()
379 .replace('\\', "/");
380 if size > MAX_FILE_BYTES {
381 bail!("{relative} is larger than Cloudflare's 25 MiB limit for one file");
382 }
383 let bytes = std::fs::read(&path)?;
384 let digest = hex::encode(Sha256::digest(&bytes));
385 out.push(Asset {
386 path: format!("/{relative}"),
387 hash: digest[..32].to_owned(),
388 size,
389 file: path,
390 });
391 if out.len() > MAX_FILES {
392 bail!("the site has more than {MAX_FILES} files, Cloudflare's limit");
393 }
394 }
395 Ok(())
396}
397
398/// The upload, as the API sends it: `snake_case`, or `uploadUrl` from
399/// before its bodies were.
400#[derive(Deserialize)]
401struct UploadSession {
402 jwt: String,
403 #[serde(default)]
404 buckets: Vec<Vec<String>>,
405 #[serde(alias = "uploadUrl")]
406 upload_url: String,
407}
408
409/// Sends one bucket of files with the upload's key. The last bucket's
410/// answer carries the key that completes the upload.
411fn upload_bucket(session: &UploadSession, bucket: &[String], by_hash: &BTreeMap<&str, &Asset>) -> Result<Option<String>> {
412 let boundary = format!("g1t-{}", hex::encode(Sha256::digest(bucket.join(",").as_bytes()))[..24].to_owned());
413 let mut body: Vec<u8> = Vec::new();
414 for hash in bucket {
415 let asset = by_hash
416 .get(hash.as_str())
417 .with_context(|| format!("Cloudflare asked for a file this build does not have ({hash})"))?;
418 let bytes = std::fs::read(&asset.file)?;
419 body.extend_from_slice(
420 format!(
421 "--{boundary}\r\nContent-Disposition: form-data; name=\"{hash}\"; filename=\"{hash}\"\r\nContent-Type: {}\r\n\r\n",
422 content_type(&asset.path)
423 )
424 .as_bytes(),
425 );
426 body.extend_from_slice(STANDARD.encode(bytes).as_bytes());
427 body.extend_from_slice(b"\r\n");
428 }
429 body.extend_from_slice(format!("--{boundary}--\r\n").as_bytes());
430 let response = ureq::post(&session.upload_url)
431 .set("authorization", &format!("Bearer {}", session.jwt))
432 .set("content-type", &format!("multipart/form-data; boundary={boundary}"))
433 .send_bytes(&body);
434 let response = match response {
435 Ok(response) => response,
436 Err(ureq::Error::Status(code, response)) => {
437 bail!("Cloudflare refused the upload ({code}): {}", response.into_string().unwrap_or_default())
438 }
439 Err(error) => bail!("could not upload to Cloudflare: {error}"),
440 };
441 let answer: Value = response.into_json().unwrap_or(Value::Null);
442 Ok(answer["result"]["jwt"].as_str().map(str::to_owned))
443}
444
445#[derive(Serialize)]
446#[serde(rename_all = "camelCase")]
447struct Module {
448 name: String,
449 content_base64: String,
450 content_type: String,
451}
452
453/// The bundle `wrangler deploy --dry-run` wrote, main module first.
454fn bundle_modules(main: &str, bundle: &Path) -> Result<(String, Vec<Module>)> {
455 let stem = Path::new(main)
456 .file_stem()
457 .map(|stem| stem.to_string_lossy().into_owned())
458 .unwrap_or_else(|| "index".to_owned());
459 let mut modules = Vec::new();
460 let mut files = Vec::new();
461 collect_files(bundle, &mut files)?;
462 for file in files {
463 let name = file
464 .strip_prefix(bundle)?
465 .to_string_lossy()
466 .replace('\\', "/");
467 let kind = match name.rsplit('.').next().unwrap_or("") {
468 "js" | "mjs" => "application/javascript+module",
469 "wasm" => "application/wasm",
470 "map" | "md" => continue,
471 _ => "text/plain",
472 };
473 modules.push(Module {
474 content_base64: STANDARD.encode(std::fs::read(&file)?),
475 content_type: kind.to_owned(),
476 name,
477 });
478 }
479 let main_name = modules
480 .iter()
481 .map(|module| module.name.clone())
482 .find(|name| *name == format!("{stem}.js") || *name == format!("{stem}.mjs"))
483 .or_else(|| {
484 modules
485 .iter()
486 .find(|module| module.content_type == "application/javascript+module")
487 .map(|module| module.name.clone())
488 })
489 .context("wrangler wrote no JavaScript module")?;
490 Ok((main_name, modules))
491}
492
493fn collect_files(dir: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
494 for entry in std::fs::read_dir(dir)? {
495 let entry = entry?;
496 if entry.file_type()?.is_dir() {
497 collect_files(&entry.path(), out)?;
498 } else {
499 out.push(entry.path());
500 }
501 }
502 Ok(())
503}
504
505/// What a Workers config asks for that g1t.page does not provision, said
506/// as the deployment's warnings: bindings it runs without, and scheduled
507/// (`triggers.crons`) runs that never fire.
508fn config_warnings(config: &WranglerConfig) -> Vec<String> {
509 // An empty list, as a config a framework's build writes has for each
510 // kind of binding (`"d1_databases": []`), declares nothing.
511 fn declares(value: &Value) -> bool {
512 match value {
513 Value::Null => false,
514 Value::Array(items) => !items.is_empty(),
515 Value::Object(fields) => fields.values().any(declares),
516 _ => true,
517 }
518 }
519 let mut warnings = Vec::new();
520 for binding in UNSUPPORTED_BINDINGS {
521 if config.rest.get(binding).is_some_and(declares) {
522 warnings.push(format!("`{binding}` is not provisioned on g1t.page yet, so the app runs without it."));
523 }
524 }
525 let crons = config
526 .rest
527 .get("triggers")
528 .and_then(|triggers| triggers.get("crons"))
529 .and_then(Value::as_array)
530 .map_or(0, |crons| crons.iter().filter(|cron| cron.as_str().is_some_and(|c| !c.trim().is_empty())).count());
531 if crons > 0 {
532 let schedules = if crons == 1 { "schedule" } else { "schedules" };
533 warnings.push(format!(
534 "`triggers.crons` ({crons} {schedules}) is not set up on g1t.page yet, so the Worker's `scheduled` handler never runs."
535 ));
536 }
537 warnings
538}
539
540/// What was built: the Worker's code and settings, and where its site is.
541struct Built {
542 worker: Value,
543 assets_dir: Option<PathBuf>,
544 warnings: Vec<String>,
545 /// What kind of project it was found to be, for its settings page:
546 /// `workers`, `static` (a build's output), `html` (the files as they
547 /// are) or `container`.
548 detected: &'static str,
549 /// The framework it was found to be built with, if any (`astro`…), or
550 /// for a container what it was built from (`dockerfile`, `flask`…).
551 framework: Option<&'static str>,
552 /// For a container: the image pushed, its digest, port and size.
553 container: Option<Value>,
554}
555
556/// What a project without a Workers config is: a site it built, or its own
557/// files served as they are.
558fn static_kind(project: &Path, assets_dir: &Path, built: bool) -> &'static str {
559 if !built && assets_dir == project { "html" } else { "static" }
560}
561
562/// The command that bundles a Workers project, without deploying it,
563/// into `bundle` when given, by the config at `config` when given.
564fn wrangler_build(bundle: Option<&Path>, config: Option<&Path>) -> String {
565 let mut command = "npx --yes wrangler@4 deploy --dry-run".to_owned();
566 if let Some(config) = config {
567 command.push_str(&format!(" --config {}", config.display()));
568 }
569 if let Some(bundle) = bundle {
570 command.push_str(&format!(" --outdir {}", bundle.display()));
571 }
572 command
573}
574
575/// The config a framework's build wrote for wrangler, which wrangler reads
576/// in place of the project's own (`.wrangler/deploy/config.json`, as
577/// `@cloudflare/vite-plugin` writes it), with the folder its paths are
578/// relative to.
579fn redirected_config(dir: &Path) -> Result<Option<(WranglerConfig, PathBuf)>> {
580 let pointer = dir.join(".wrangler/deploy/config.json");
581 let Ok(text) = std::fs::read_to_string(&pointer) else { return Ok(None) };
582 let Some(path) = serde_json::from_str::<Value>(&text).ok().and_then(|v| v["configPath"].as_str().map(str::to_owned)) else {
583 return Ok(None);
584 };
585 let path = dir.join(".wrangler/deploy").join(path);
586 let path = canonical(&path).unwrap_or(path);
587 let text = std::fs::read_to_string(&path).with_context(|| format!("could not read {}", path.display()))?;
588 let config = serde_json::from_str(&strip_jsonc(&text)).with_context(|| format!("could not read {}", path.display()))?;
589 Ok(Some((config, path.parent().map_or_else(|| dir.to_path_buf(), Path::to_path_buf))))
590}
591
592/// The path, resolved, as a plain path. On Windows, `canonicalize` gives a
593/// verbatim one (`\\?\C:\…`), in which `/` and `..` are not read as
594/// separators or as the folder above: a config's `"directory": "../client"`
595/// joined to it would name no folder. A drive path loses the prefix; a
596/// network path keeps it, as it cannot do without.
597fn canonical(path: &Path) -> std::io::Result<PathBuf> {
598 let path = path.canonicalize()?;
599 #[cfg(windows)]
600 {
601 let text = path.to_string_lossy();
602 if let Some(rest) = text.strip_prefix(r"\\?\") {
603 if !rest.starts_with(r"UNC\") {
604 return Ok(PathBuf::from(rest));
605 }
606 }
607 }
608 Ok(path)
609}
610
611/// The entry of the Worker a framework's adapter wrote, once it has built:
612/// the file, or a directory's `index.js`.
613fn adapter_entry(dir: &Path, adapter: &Adapter) -> Option<PathBuf> {
614 let entry = dir.join(adapter.entry);
615 if entry.is_file() {
616 return Some(entry);
617 }
618 ["index.js", "index.mjs"].iter().map(|name| entry.join(name)).find(|index| index.is_file())
619}
620
621/// The first of the usual output directories a static build wrote (or the
622/// project's own `index.html`), relative to the project: `.` for itself.
623fn find_output(dir: &Path) -> Option<String> {
624 OUTPUT_DIRS
625 .iter()
626 .find(|name| {
627 let path = dir.join(name);
628 path.join("index.html").exists() || (path.is_dir() && **name != "public")
629 })
630 .map(|name| (*name).to_owned())
631 .or_else(|| dir.join("index.html").exists().then(|| ".".to_owned()))
632}
633
634/// What a build of the project at `dir` does, found before anything runs:
635/// what it is, how its dependencies are installed, what builds it, and
636/// where what it serves is. For a site with a build of its own, the output
637/// directory may only exist once it has run; then `output_dir` is `None`
638/// unless one was chosen or the framework says where it writes.
639#[derive(Debug, PartialEq, Eq)]
640pub(crate) struct Detected {
641 /// `workers`, `static`, `html`, `container`, or `none` when there is
642 /// nothing to serve.
643 pub kind: &'static str,
644 /// The framework it is built with, if g1t knows it.
645 pub framework: Option<Framework>,
646 pub install: Option<String>,
647 pub build: Option<String>,
648 pub output_dir: Option<String>,
649 /// Why it cannot be served as it is set up, and what to change: the
650 /// kind is then `none`.
651 pub problem: Option<String>,
652 /// What runs before wrangler bundles a Workers project; for anything
653 /// else, `build`.
654 pre_build: Option<String>,
655 /// For a container: its Dockerfile, or the one g1t writes.
656 pub container: Option<ContainerBuild>,
657 /// What it would be served as without its Dockerfile, when it is a
658 /// container only for having one: built so where Docker deployments
659 /// are switched off (`not_enabled` from the registry step).
660 fallback: Option<Box<Detected>>,
661 /// Why it is built as this and not as the other thing it could be (a
662 /// site beside a Dockerfile), for the build's log.
663 pub reason: Option<String>,
664}
665
666/// How a container is built.
667#[derive(Debug, Clone, PartialEq, Eq)]
668pub(crate) struct ContainerBuild {
669 /// The project's own Dockerfile, relative to the project; none when
670 /// g1t writes one.
671 pub dockerfile: Option<String>,
672 /// The Dockerfile g1t writes, for a server it recognises.
673 pub generated: Option<container::Server>,
674 /// What it listens on: its Dockerfile's `EXPOSE`, else 8080. `PORT`
675 /// is set to it.
676 pub port: u16,
677}
678
679impl Detected {
680 fn nothing(framework: Option<Framework>, install: Option<String>, problem: String) -> Detected {
681 Detected {
682 kind: "none",
683 framework,
684 install,
685 build: None,
686 output_dir: None,
687 problem: Some(problem),
688 pre_build: None,
689 container: None,
690 fallback: None,
691 reason: None,
692 }
693 }
694
695 /// The `framework` `g1t-runner detect` prints: the framework, else for
696 /// a container the server g1t wrote a Dockerfile for (`flask`…) or
697 /// `dockerfile`.
698 pub(crate) fn framework_id(&self) -> Option<&'static str> {
699 self.framework.as_ref().map(|f| f.id).or_else(|| {
700 self.container.as_ref().map(|c| c.generated.as_ref().map_or("dockerfile", |server| server.id))
701 })
702 }
703}
704
705/// Why nothing could be found to serve, and what to add.
706const NOTHING_TO_SERVE: &str = "found nothing to serve: no Workers config, no Dockerfile, no index.html, and none of dist, build, out, \
707 public, _site or .output/public. Add a `Dockerfile` to deploy it as a container, or set **Build command** and \
708 **Output directory** for a static site.";
709
710/// The project as a container: its own Dockerfile, else one g1t writes
711/// for `framework` or for a server it recognises. None when it has neither.
712fn as_container(dir: &Path, framework: Option<Framework>, own: Option<String>) -> Option<Detected> {
713 let build = match own {
714 Some(path) => {
715 let text = std::fs::read_to_string(dir.join(&path)).unwrap_or_default();
716 ContainerBuild { port: container::exposed_port(&text).unwrap_or(container::DEFAULT_PORT), dockerfile: Some(path), generated: None }
717 }
718 None => {
719 let server = match &framework {
720 // One set up for Workers (OpenNext's adapter) is not run as a server.
721 Some(framework) if framework.workers => None,
722 Some(framework) => container::for_framework(dir, framework.id),
723 None => container::server(dir),
724 }?;
725 ContainerBuild { dockerfile: None, generated: Some(server), port: container::DEFAULT_PORT }
726 }
727 };
728 let file = build.dockerfile.clone().unwrap_or_else(|| "Dockerfile (written by g1t)".to_owned());
729 Some(Detected {
730 kind: "container",
731 framework,
732 // The image installs what it needs: nothing runs before its build.
733 install: None,
734 build: Some(format!("docker build -f {file} .")),
735 output_dir: None,
736 problem: None,
737 pre_build: None,
738 container: Some(build),
739 fallback: None,
740 reason: None,
741 })
742}
743
744#[cfg(test)]
745pub(crate) fn detect(dir: &Path, custom_build: Option<&str>, chosen_output: Option<&str>) -> Result<Detected> {
746 detect_as(dir, custom_build, chosen_output, BuildAs::Auto, None)
747}
748
749/// What the project is, by its **Build as** setting (`build_as`) and its
750/// Dockerfile's path (`dockerfile`, when it chose one):
751///
752/// - **Docker**: a container, from its Dockerfile or one g1t writes.
753/// - **Static site**: built and served as files, whatever else it has.
754/// - **Workers**: its Wrangler config or a framework adapter's Worker.
755/// - **Auto**, in order: a Wrangler config (or a framework's Workers
756/// adapter) is a Workers project; a site beside a Dockerfile of the
757/// project's own is a container when the Dockerfile runs a server
758/// (`container::runs_server`: `CMD`, `ENTRYPOINT`, or `EXPOSE` on an
759/// image that is not only a file server), else the site, which costs
760/// nothing while no one visits; any other framework that builds a site
761/// is that site; a framework set up to need a server is a container
762/// when it has a Dockerfile, or when g1t can write one; a project with
763/// no framework g1t knows is a container when it has a Dockerfile or is
764/// a server g1t recognises (a Node.js, Python, Go, Ruby, PHP or Rust
765/// app), else a static site, else nothing, saying to add a Dockerfile.
766/// Each choice between a site and a container says why (`reason`).
767pub(crate) fn detect_as(
768 dir: &Path,
769 custom_build: Option<&str>,
770 chosen_output: Option<&str>,
771 build_as: BuildAs,
772 dockerfile: Option<&str>,
773) -> Result<Detected> {
774 let own = container::own_dockerfile(dir, dockerfile)?;
775 match build_as {
776 BuildAs::Docker => {
777 let framework = frameworks::detect(dir, read_wrangler(dir)?.is_some());
778 Ok(as_container(dir, framework.clone(), own).unwrap_or_else(|| {
779 Detected::nothing(
780 framework,
781 None,
782 "**Build as** is Docker, but this project has no Dockerfile and g1t doesn't recognise a server it can write \
783 one for. Add a `Dockerfile` (or set its path under Settings, Deployments)."
784 .to_owned(),
785 )
786 }))
787 }
788 BuildAs::Static => detect_site(dir, custom_build, chosen_output, true),
789 BuildAs::Workers => {
790 let site = detect_site(dir, custom_build, chosen_output, false)?;
791 if site.kind == "workers" {
792 return Ok(site);
793 }
794 Ok(Detected::nothing(
795 site.framework,
796 site.install,
797 "**Build as** is Workers, but this project has no Wrangler config (`wrangler.jsonc`, `wrangler.json` or \
798 `wrangler.toml`) and no framework adapter that writes a Worker."
799 .to_owned(),
800 ))
801 }
802 BuildAs::Auto => {
803 let site = detect_site(dir, custom_build, chosen_output, false)?;
804 if site.kind == "workers" {
805 return Ok(site);
806 }
807 // A site beside a Dockerfile of the project's own: the
808 // Dockerfile, when it runs a server; else the site, which g1t
809 // serves for nothing while no one visits.
810 let serves = site.problem.is_none() && (site.kind == "static" || site.kind == "html");
811 if serves && let Some(path) = &own {
812 let text = std::fs::read_to_string(dir.join(path)).unwrap_or_default();
813 let (server, why) = container::runs_server(&text);
814 let what = if site.kind == "html" { "plain HTML site" } else { "static site" };
815 if !server {
816 let mut site = site;
817 site.reason = Some(format!(
818 "Building it as a {what}, not from {path}: {why}, and g1t serves the site itself. \
819 Set **Build as** to Docker to run the container instead."
820 ));
821 return Ok(site);
822 }
823 let mut found = as_container(dir, site.framework.clone(), own.clone()).context("its Dockerfile was not read")?;
824 found.reason = Some(format!(
825 "Building it as a container from {path}, not as the {what} it also has: {why}. \
826 Set **Build as** to Static site to serve the site instead."
827 ));
828 found.fallback = Some(Box::new(site));
829 return Ok(found);
830 }
831 if site.framework.is_some() && site.problem.is_none() {
832 return Ok(site);
833 }
834 if let Some(problem) = site.problem.clone() {
835 // A framework set up to need a server.
836 let found = as_container(dir, site.framework.clone(), own);
837 return Ok(found.unwrap_or_else(|| {
838 Detected::nothing(site.framework, site.install, format!("{problem} Or add a `Dockerfile` to run it as a container."))
839 }));
840 }
841 // A Dockerfile with nothing else to serve, or a server g1t
842 // recognises, which it writes one for.
843 if let Some(mut found) = as_container(dir, None, own) {
844 if let (Some(server), Some(folder)) = (found.container.as_ref().and_then(|c| c.generated.as_ref()), &site.output_dir) {
845 found.reason = Some(format!(
846 "Building it as a {} server, not as a site of `{folder}`. Set **Build as** to Static site to serve only `{folder}` instead.",
847 server.name
848 ));
849 }
850 return Ok(found);
851 }
852 if site.kind == "none" {
853 return Ok(Detected::nothing(site.framework, site.install, NOTHING_TO_SERVE.to_owned()));
854 }
855 Ok(site)
856 }
857 }
858}
859
860/// What the project is as a static site or Workers project: `as_static`
861/// ignores its Wrangler config and any framework adapter, and builds it
862/// even when its framework is set up to need a server.
863fn detect_site(dir: &Path, custom_build: Option<&str>, chosen_output: Option<&str>, as_static: bool) -> Result<Detected> {
864 let custom_build = custom_build.map(str::trim).filter(|c| !c.is_empty());
865 let chosen_output = chosen_output.map(|d| d.trim().trim_matches('/')).filter(|d| !d.is_empty());
866 let config = if as_static { None } else { read_wrangler(dir)? };
867 let framework = frameworks::detect(dir, config.is_some());
868 let installs: Vec<&str> = [install_command(dir), framework.as_ref().and_then(|f| f.install)].into_iter().flatten().collect();
869 let install = (!installs.is_empty()).then(|| installs.join(" && "));
870 let framework_build = framework.as_ref().and_then(|f| f.build.clone());
871 if let Some(config) = config {
872 // A framework built for Workers builds before wrangler bundles what
873 // it wrote; any other project's config builds it, as it always has.
874 let pre_build = custom_build
875 .map(str::to_owned)
876 .or_else(|| framework.as_ref().filter(|f| f.workers).and(framework_build));
877 let mut steps: Vec<String> = pre_build.clone().into_iter().collect();
878 if config.main.is_some() {
879 steps.push(wrangler_build(None, None));
880 }
881 return Ok(Detected {
882 kind: "workers",
883 framework,
884 install,
885 build: (!steps.is_empty()).then(|| steps.join(" && ")),
886 output_dir: config.assets.and_then(|assets| assets.directory),
887 problem: None,
888 pre_build,
889 container: None,
890 fallback: None,
891 reason: None,
892 });
893 }
894 let build = custom_build
895 .map(str::to_owned)
896 .or(framework_build)
897 .or_else(|| has_build_script(dir).then(|| "npm run build".to_owned()));
898 // An adapter's Worker, unless the project chose a folder to serve.
899 if !as_static && chosen_output.is_none() && framework.as_ref().is_some_and(|f| f.adapter.is_some()) {
900 // Where its files are is known once it has built: the adapter's
901 // own folder, or where the config it writes says.
902 return Ok(Detected {
903 kind: "workers",
904 output_dir: None,
905 framework,
906 install,
907 pre_build: build.clone(),
908 build,
909 problem: None,
910 container: None,
911 fallback: None,
912 reason: None,
913 });
914 }
915 // A command or a folder of the project's own says it knows better, as
916 // does **Build as** set to Static site.
917 let problem = framework
918 .as_ref()
919 .and_then(|f| f.problem.clone())
920 .filter(|_| !as_static && custom_build.is_none() && chosen_output.is_none());
921 if let Some(problem) = problem {
922 return Ok(Detected::nothing(framework, install, problem));
923 }
924 let output_dir = chosen_output
925 .map(str::to_owned)
926 .or_else(|| framework.as_ref().and_then(|f| f.output.clone()))
927 .or_else(|| find_output(dir));
928 // `.` is the project itself: its own files, served as they are.
929 let kind = match (&build, output_dir.as_deref()) {
930 (None, Some(".")) => "html",
931 (None, None) => "none",
932 _ => "static",
933 };
934 Ok(Detected { kind, framework, install, pre_build: build.clone(), build, output_dir, problem: None, container: None, fallback: None, reason: None })
935}
936
937/// The compatibility date a Worker gets when its config names none.
938const COMPATIBILITY_DATE: &str = "2026-09-26";
939
940/// A Workers project bundled by wrangler: `config` with its paths relative
941/// to `base`, read by wrangler from `config_file` when given (else the
942/// project's own, or the one its build wrote).
943fn build_worker(
944 log: &mut Log,
945 secrets: &[String],
946 config: WranglerConfig,
947 base: &Path,
948 config_file: Option<&Path>,
949) -> Result<(Value, Option<PathBuf>, Vec<String>)> {
950 let bundle = bundle_dir();
951 let warnings = config_warnings(&config);
952 let mut worker = json!({
953 "compatibilityDate": config.compatibility_date.clone().unwrap_or_else(|| COMPATIBILITY_DATE.to_owned()),
954 "compatibilityFlags": config.compatibility_flags,
955 "vars": config.vars,
956 });
957 if let Some(main) = &config.main {
958 // `--dry-run` runs the config's own build and bundles it, without
959 // deploying anywhere.
960 let _ = std::fs::remove_dir_all(&bundle);
961 step(log, &wrangler_build(Some(&bundle), config_file), secrets)?;
962 let (main_module, modules) = bundle_modules(main, &bundle)?;
963 worker["mainModule"] = main_module.into();
964 worker["modules"] = serde_json::to_value(modules)?;
965 }
966 let assets = config.assets.unwrap_or_default();
967 let assets_dir = assets.directory.as_ref().map(|directory| base.join(directory));
968 worker["assetsBinding"] = assets.binding.into();
969 worker["htmlHandling"] = assets.html_handling.into();
970 worker["notFoundHandling"] = assets.not_found_handling.into();
971 Ok((worker, assets_dir, warnings))
972}
973
974/// Where the config for an adapter's Worker is written: beside the
975/// bundle, never in the checkout.
976fn adapter_config_path() -> PathBuf {
977 let bundle = bundle_dir();
978 bundle.with_file_name(format!("{}.wrangler.json", bundle.file_name().unwrap_or_default().to_string_lossy()))
979}
980
981/// What a container build says where Docker deployments are switched off
982/// (a self-hosted g1t with `CONTAINERS` set to `off`).
983const CONTAINERS_OFF: &str = "This project deploys as a container, and Docker deployments are switched off on this g1t. \
984 Set **Build as** to Static site or Workers under Settings, Deployments if it can be built as one.";
985
986/// Where a container's registry login is kept for the build: its own
987/// folder, never the job's `~/.docker`.
988fn docker_config_dir() -> PathBuf {
989 bundle_dir().with_file_name(format!("{}.docker", bundle_dir().file_name().unwrap_or_default().to_string_lossy()))
990}
991
992/// Where the Dockerfile g1t writes for a project without one goes: beside
993/// the bundle, never in the checkout.
994fn generated_dockerfile_path() -> PathBuf {
995 bundle_dir().with_file_name(format!("{}.Dockerfile", bundle_dir().file_name().unwrap_or_default().to_string_lossy()))
996}
997
998/// What the deployments service answers the `registry` step with: where
999/// to push the app's image, with a login good for pushing only and only
1000/// for minutes, and the largest image the app's instance can run.
1001#[derive(Debug, Deserialize)]
1002struct RegistryGrant {
1003 registry: String,
1004 repository: String,
1005 tag: String,
1006 username: String,
1007 password: String,
1008 #[serde(alias = "maxImageBytes")]
1009 max_image_bytes: u64,
1010 #[serde(default, alias = "instanceType")]
1011 instance_type: Option<String>,
1012}
1013
1014/// `name@sha256:…` for `repository` among an image's repository digests,
1015/// as `docker image inspect` lists them, one a line.
1016fn pushed_digest(repo_digests: &str, registry: &str, repository: &str) -> Option<String> {
1017 let prefix = format!("{registry}/{repository}@sha256:");
1018 repo_digests
1019 .lines()
1020 .map(str::trim)
1021 .find(|line| line.starts_with(&prefix) && line.len() == prefix.len() + 64)
1022 .map(str::to_owned)
1023}
1024
1025/// Bytes, as a person reads them: `1.4 GB`.
1026fn size_text(bytes: u64) -> String {
1027 format!("{:.1} GB", bytes as f64 / 1_000_000_000.0)
1028}
1029
1030/// Runs `docker` with `args`, signed in through `config`; its output, or
1031/// why it failed, redacted.
1032fn docker(args: &[&str], config: &Path, input: Option<&str>, secrets: &[String]) -> Result<String> {
1033 use std::io::Write;
1034 use std::process::{Command, Stdio};
1035 let mut command = Command::new("docker");
1036 command.args(args).env("DOCKER_CONFIG", config).stdout(Stdio::piped()).stderr(Stdio::piped());
1037 command.stdin(if input.is_some() { Stdio::piped() } else { Stdio::null() });
1038 let mut child = command.spawn().context("could not run docker")?;
1039 if let (Some(input), Some(mut stdin)) = (input, child.stdin.take()) {
1040 stdin.write_all(input.as_bytes())?;
1041 }
1042 let output = child.wait_with_output()?;
1043 let text = redact(&format!("{}{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr)), secrets);
1044 if !output.status.success() {
1045 bail!("`docker {}` failed: {}", args.first().copied().unwrap_or_default(), text.trim());
1046 }
1047 Ok(text)
1048}
1049
1050/// Builds the project's container image with Docker and pushes it to
1051/// Cloudflare's registry, at the place and with the short-lived push-only
1052/// login the deployments service gives for this build (`registry`). The
1053/// sandbox never holds g1t's own Cloudflare token. None when g1t.page runs
1054/// no containers (the service answers `not_enabled`).
1055fn container_build(reporter: &Reporter, log: &mut Log, secrets: &[String], dir: &Path, plan: &Detected) -> Result<Option<Value>> {
1056 let build = plan.container.as_ref().context("not a container")?;
1057 // Asked first: a build that could not be pushed is not run.
1058 let answer = reporter.send("registry", json!({}))?;
1059 if answer["ok"] == false {
1060 if answer["error"]["code"] == "not_enabled" {
1061 return Ok(None);
1062 }
1063 bail!("{}", answer["error"]["message"].as_str().unwrap_or("g1t refused the image's upload"));
1064 }
1065 let grant: RegistryGrant =
1066 serde_json::from_value(answer["value"].clone()).context("g1t's answer to the image's upload was not understood")?;
1067 let mut secrets = secrets.to_vec();
1068 secrets.push(grant.password.clone());
1069 let dockerfile = match (&build.dockerfile, &build.generated) {
1070 (Some(own), _) => {
1071 log.line(&format!("Building a container from {own}, listening on port {}.", build.port));
1072 dir.join(own)
1073 }
1074 (None, Some(server)) => {
1075 // Shown whole, so it can be committed and changed.
1076 log.line(&format!("This {} project has no Dockerfile, so g1t wrote this one (commit it as `Dockerfile` to change it):", server.name));
1077 for line in server.dockerfile.lines() {
1078 log.line(&format!(" {line}"));
1079 }
1080 let path = generated_dockerfile_path();
1081 std::fs::write(&path, &server.dockerfile)?;
1082 // BuildKit reads `<Dockerfile>.dockerignore` beside it.
1083 std::fs::write(path.with_file_name(format!("{}.dockerignore", path.file_name().unwrap_or_default().to_string_lossy())), container::DOCKERIGNORE)?;
1084 path
1085 }
1086 (None, None) => bail!("nothing to build the container from"),
1087 };
1088 if std::env::var("BUILD_COMMAND").is_ok_and(|c| !c.trim().is_empty()) {
1089 log.line("Build command is not used for a container: its Dockerfile builds it.");
1090 }
1091 // A sandbox's own Docker Engine, started for the build; in a workflow
1092 // step, the job's.
1093 if clones() {
1094 if std::env::var("G1T_DOCKER").as_deref() != Ok("on") {
1095 bail!("Docker is turned off for g1t's sandboxes here, so a container cannot be built.");
1096 }
1097 crate::docker::engine::enable(crate::docker::engine::Options::default())
1098 .map_err(|problem| anyhow::anyhow!("Docker is not available in this sandbox: {problem}"))?;
1099 let started = crate::docker::engine::ensure_started();
1100 for note in crate::docker::take_notes() {
1101 log.line(&note);
1102 }
1103 started.map_err(|problem| anyhow::anyhow!("Docker could not start: {problem}"))?;
1104 }
1105 let config = docker_config_dir();
1106 std::fs::create_dir_all(&config)?;
1107 let image = format!("{}/{}:{}", grant.registry, grant.repository, grant.tag);
1108 let quote = |path: &Path| format!("'{}'", path.display().to_string().replace('\'', r"'\''"));
1109 // One image for one platform, with no attestation beside it: what
1110 // `wrangler containers build` pushes, so the digest pushed is the
1111 // image's own manifest, not an index Containers would have to choose from.
1112 let built = step(
1113 log,
1114 &format!("docker build --platform linux/amd64 --provenance=false --pull -f {} -t {image} .", quote(&dockerfile)),
1115 &secrets,
1116 );
1117 let pushed = built.and_then(|()| {
1118 let size: u64 = docker(&["image", "inspect", "--format", "{{.Size}}", &image], &config, None, &secrets)?.trim().parse().unwrap_or(0);
1119 log.line(&format!("The image is {}.", size_text(size)));
1120 if size > grant.max_image_bytes {
1121 bail!(
1122 "The image is {}, more than the {} an app of its size ({}) can hold. Make it smaller (a slim base image, a multi-stage build), or choose a larger instance under Settings, Deployments.",
1123 size_text(size),
1124 size_text(grant.max_image_bytes),
1125 grant.instance_type.as_deref().unwrap_or("its instance type"),
1126 );
1127 }
1128 docker(&["login", &grant.registry, "--username", &grant.username, "--password-stdin"], &config, Some(&grant.password), &secrets)?;
1129 log.line(&format!("$ docker push {image}"));
1130 let output = docker(&["push", &image], &config, None, &secrets)?;
1131 log.line(output.trim_end());
1132 let digests = docker(&["image", "inspect", "--format", "{{join .RepoDigests \"\\n\"}}", &image], &config, None, &secrets)?;
1133 let reference = pushed_digest(&digests, &grant.registry, &grant.repository).context("the pushed image's digest was not found")?;
1134 Ok(json!({
1135 "image": reference,
1136 "port": build.port,
1137 "sizeBytes": size,
1138 "dockerfile": build.dockerfile,
1139 "generated": build.generated.as_ref().map(|server| server.id),
1140 }))
1141 });
1142 let _ = std::fs::remove_dir_all(&config);
1143 let _ = std::fs::remove_file(generated_dockerfile_path());
1144 pushed.map(Some)
1145}
1146
1147fn build(reporter: &Reporter, log: &mut Log, secrets: &[String]) -> Result<Built> {
1148 let project = project_dir();
1149 let dir = project.as_path();
1150 let custom_build = std::env::var("BUILD_COMMAND").ok();
1151 let chosen = std::env::var("OUTPUT_DIR").ok().filter(|d| !d.trim().is_empty());
1152 let build_as = BuildAs::parse(&std::env::var("BUILD_AS").unwrap_or_default());
1153 let dockerfile = std::env::var("DOCKERFILE").ok();
1154 let mut plan = detect_as(dir, custom_build.as_deref(), chosen.as_deref(), build_as, dockerfile.as_deref())?;
1155 if let Some(framework) = &plan.framework {
1156 log.line(&format!("Detected {}: framework={}.", framework.name, framework.id));
1157 }
1158 if let Some(reason) = &plan.reason {
1159 log.line(reason);
1160 }
1161 // Said before anything is installed: it would fail after, less clearly.
1162 if let Some(problem) = &plan.problem {
1163 bail!("{problem}");
1164 }
1165 if plan.container.is_some() {
1166 match container_build(reporter, log, secrets, dir, &plan)? {
1167 Some(image) => {
1168 return Ok(Built {
1169 worker: json!({}),
1170 assets_dir: None,
1171 warnings: Vec::new(),
1172 detected: "container",
1173 framework: plan.framework_id(),
1174 container: Some(image),
1175 });
1176 }
1177 // Docker deployments are switched off here: the site it is
1178 // without its Dockerfile.
1179 None => match plan.fallback.take() {
1180 Some(site) => {
1181 log.line(&format!("Building it as a {} site instead, as it is without its Dockerfile.", if site.kind == "html" { "plain HTML" } else { "static" }));
1182 plan = *site;
1183 }
1184 None => bail!("{CONTAINERS_OFF}"),
1185 },
1186 }
1187 }
1188 let framework_id = plan.framework.as_ref().map(|f| f.id);
1189 if let Some(install) = &plan.install {
1190 step(log, install, secrets)?;
1191 }
1192 if let Some(command) = &plan.pre_build {
1193 step(log, command, secrets)?;
1194 }
1195 // A static site (by **Build as**, or found to be one) never reads a
1196 // Workers config.
1197 let workers = plan.kind == "workers";
1198 let own_config = if workers { read_wrangler(dir)? } else { None };
1199 // A framework built for Workers may write the config wrangler reads
1200 // (`@cloudflare/vite-plugin` does, and Astro's adapter with it), with
1201 // or without one of the project's own.
1202 let written = if own_config.is_some() || (workers && plan.framework.as_ref().is_some_and(|f| f.workers)) {
1203 redirected_config(dir)?
1204 } else {
1205 None
1206 };
1207 if let Some((config, base)) = written {
1208 log.line(&format!("Using the Workers config the build wrote, in {}.", base.display()));
1209 let (worker, assets_dir, warnings) = build_worker(log, secrets, config, &base, None)?;
1210 return Ok(Built { worker, assets_dir, warnings, detected: "workers", framework: framework_id, container: None });
1211 }
1212 if let Some(config) = own_config {
1213 let (worker, assets_dir, warnings) = build_worker(log, secrets, config, dir, None)?;
1214 return Ok(Built { worker, assets_dir, warnings, detected: "workers", framework: framework_id, container: None });
1215 }
1216 let adapter = plan.framework.as_ref().and_then(|f| f.adapter.as_ref()).filter(|_| workers && chosen.is_none());
1217 if let Some(adapter) = adapter
1218 && let Some(entry) = adapter_entry(dir, adapter)
1219 {
1220 // The adapter's Worker, bundled as a Workers project's is. Its
1221 // static files are uploaded by g1t, so wrangler is given none.
1222 let file = adapter_config_path();
1223 let flags = vec!["nodejs_compat".to_owned()];
1224 std::fs::write(
1225 &file,
1226 serde_json::to_string_pretty(&json!({
1227 "name": "g1t-app",
1228 "main": entry,
1229 "compatibility_date": COMPATIBILITY_DATE,
1230 "compatibility_flags": flags,
1231 }))?,
1232 )?;
1233 log.line(&format!("Bundling the Worker the adapter wrote, {}.", adapter.entry));
1234 let config = WranglerConfig {
1235 main: Some(entry.to_string_lossy().into_owned()),
1236 compatibility_flags: flags,
1237 assets: Some(AssetsConfig {
1238 directory: Some(adapter.assets.to_owned()),
1239 binding: Some("ASSETS".to_owned()),
1240 ..AssetsConfig::default()
1241 }),
1242 ..WranglerConfig::default()
1243 };
1244 let built = build_worker(log, secrets, config, dir, Some(&file));
1245 let _ = std::fs::remove_file(&file);
1246 let (worker, assets_dir, warnings) = built?;
1247 return Ok(Built { worker, assets_dir, warnings, detected: "workers", framework: framework_id, container: None });
1248 }
1249 if let (Some(adapter), Some(framework)) = (adapter, &plan.framework)
1250 && framework.output.is_none()
1251 {
1252 bail!("{}'s build did not write its Worker, `{}`", framework.name, adapter.entry);
1253 }
1254 let assets_dir = match &chosen {
1255 Some(chosen) => {
1256 let path = dir.join(chosen.trim().trim_matches('/'));
1257 if !path.is_dir() {
1258 bail!("the output directory `{chosen}` does not exist after the build");
1259 }
1260 path
1261 }
1262 None => {
1263 let expected = plan.framework.as_ref().and_then(|f| f.output.as_deref());
1264 match expected.filter(|output| dir.join(output).is_dir()) {
1265 Some(output) => dir.join(output),
1266 None => {
1267 let found = find_output(dir).with_context(|| match (&plan.framework, expected) {
1268 (Some(framework), Some(expected)) => format!(
1269 "found nothing to serve: {} writes its site to `{expected}`, which the build did not write. \
1270 Set **Output directory** if it writes somewhere else.",
1271 framework.name
1272 ),
1273 (Some(framework), None) if framework.problem.is_some() => {
1274 format!("found nothing to serve. {}", framework.problem.as_deref().unwrap_or_default())
1275 }
1276 _ => "found nothing to serve: no Workers config, no index.html, and none of dist, build, out, public, _site or .output/public".to_owned(),
1277 })?;
1278 if let Some(expected) = expected {
1279 log.line(&format!("`{expected}` was not written; serving `{found}`."));
1280 }
1281 if found == "." { dir.to_path_buf() } else { dir.join(found) }
1282 }
1283 }
1284 }
1285 };
1286 let spa = !assets_dir.join("404.html").exists();
1287 Ok(Built {
1288 worker: json!({
1289 "compatibilityDate": COMPATIBILITY_DATE,
1290 "compatibilityFlags": [],
1291 "vars": {},
1292 "notFoundHandling": if spa { "single-page-application" } else { "404-page" },
1293 }),
1294 detected: static_kind(dir, &assets_dir, plan.build.is_some()),
1295 assets_dir: Some(assets_dir),
1296 warnings: Vec::new(),
1297 framework: framework_id,
1298 container: None,
1299 })
1300}
1301
1302/// Whether the build checks out its own commit (a sandbox, given
1303/// `GIT_REMOTE`) rather than building the checkout it runs in.
1304fn clones() -> bool {
1305 std::env::var("GIT_REMOTE").is_ok_and(|remote| !remote.trim().is_empty())
1306}
1307
1308/// The checkout the build runs in: the sandbox's clone, or, from a
1309/// workflow step, the current directory.
1310fn checkout_dir() -> PathBuf {
1311 if clones() {
1312 PathBuf::from(WORKDIR)
1313 } else {
1314 std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
1315 }
1316}
1317
1318/// Where `wrangler deploy --dry-run` writes the bundle: in a workflow
1319/// step, a directory of the job's temporary space, never the checkout.
1320fn bundle_dir() -> PathBuf {
1321 if clones() {
1322 return PathBuf::from(BUNDLE_DIR);
1323 }
1324 let base = std::env::var_os("RUNNER_TEMP").map(PathBuf::from).unwrap_or_else(std::env::temp_dir);
1325 base.join(format!("g1t-bundle-{}", std::process::id()))
1326}
1327
1328/// Where the project lives in the checkout `base`: its root directory,
1329/// never above the checkout.
1330fn project_dir_in(base: &Path, root: &str) -> PathBuf {
1331 let root = root.trim().trim_matches('/');
1332 if root.is_empty() || root == "." || root.split('/').any(|part| part == "..") {
1333 base.to_path_buf()
1334 } else {
1335 base.join(root)
1336 }
1337}
1338
1339/// Where the project lives: `ROOT_DIR` in the checkout.
1340fn project_dir() -> PathBuf {
1341 project_dir_in(&checkout_dir(), &std::env::var("ROOT_DIR").unwrap_or_default())
1342}
1343
1344fn check_out(secrets: &[String]) -> Result<()> {
1345 let remote = env("GIT_REMOTE")?;
1346 let commit = env("GIT_COMMIT")?;
1347 let auth = auth_option(&env("G1T_USER")?, &env("G1T_TOKEN")?);
1348 std::fs::create_dir_all("/work")?;
1349 let workdir = Path::new(WORKDIR);
1350 // The clone is shallow and of the default branch; a preview's commit is
1351 // often on another one, so it is fetched before checking out.
1352 let cloned = crate::clone::clone(Path::new("/work"), &auth, &[], &remote, WORKDIR)
1353 .and_then(|_| git(workdir, &["rev-parse", "--abbrev-ref", "HEAD"]))
1354 .and_then(|branch| crate::clone::ensure(workdir, &auth, "origin", &branch, &commit))
1355 .and_then(|_| git(workdir, &["-c", "advice.detachedHead=false", "checkout", "--quiet", &commit]));
1356 if let Err(error) = cloned {
1357 bail!("{}", redact(&format!("{error:#}"), secrets));
1358 }
1359 Ok(())
1360}
1361
1362fn deploy(reporter: &Reporter, log: &mut Log, secrets: &[String]) -> Result<Value> {
1363 if clones() {
1364 check_out(secrets).context("the commit could not be checked out")?;
1365 } else {
1366 let dir = project_dir();
1367 if !dir.is_dir() {
1368 bail!("the project's directory {} does not exist in this checkout", dir.display());
1369 }
1370 log.line(&format!("Building {} as it is checked out.", dir.display()));
1371 }
1372 // The repository's variables and secrets for deploy builds.
1373 for source in ["BUILD_ENV", "BUILD_SECRETS"] {
1374 if let Ok(vars) = std::env::var(source)
1375 && let Ok(Value::Object(vars)) = serde_json::from_str::<Value>(&vars)
1376 {
1377 for (name, value) in vars {
1378 if let Some(value) = value.as_str() {
1379 // SAFETY: single-threaded; set before any command runs.
1380 unsafe { std::env::set_var(name, value) };
1381 }
1382 }
1383 }
1384 }
1385 let built = build(reporter, log, secrets)?;
1386 let mut finish = json!({
1387 "worker": built.worker,
1388 "warnings": built.warnings,
1389 "detected": built.detected,
1390 // What it is built with, shown beside `detected` on its settings.
1391 "framework": built.framework,
1392 });
1393 if let Some(image) = built.container {
1394 finish["container"] = image;
1395 }
1396 for warning in &built.warnings {
1397 log.line(&format!("Warning: {warning}"));
1398 }
1399 if let Some(dir) = &built.assets_dir {
1400 let skip_project = *dir == project_dir();
1401 let assets = collect_site(dir, skip_project)?;
1402 if assets.is_empty() {
1403 bail!("the site to serve is empty");
1404 }
1405 log.line(&format!("Uploading {} files.", assets.len()));
1406 for special in ["_headers", "_redirects"] {
1407 if let Ok(text) = std::fs::read_to_string(dir.join(special)) {
1408 finish["worker"][special] = text.into();
1409 }
1410 }
1411 let manifest: BTreeMap<&str, Value> = assets
1412 .iter()
1413 .map(|asset| (asset.path.as_str(), json!({ "hash": asset.hash, "size": asset.size })))
1414 .collect();
1415 let answer = reporter.send("session", json!({ "manifest": manifest }))?;
1416 if answer["ok"] == false {
1417 bail!("{}", answer["error"]["message"].as_str().unwrap_or("g1t refused the upload"));
1418 }
1419 let session: UploadSession =
1420 serde_json::from_value(answer["value"].clone()).context("g1t's answer to the upload was not understood")?;
1421 let by_hash: BTreeMap<&str, &Asset> = assets.iter().map(|asset| (asset.hash.as_str(), asset)).collect();
1422 let mut completion = session.jwt.clone();
1423 for bucket in &session.buckets {
1424 if let Some(jwt) = upload_bucket(&session, bucket, &by_hash)? {
1425 completion = jwt;
1426 }
1427 }
1428 finish["completionJwt"] = completion.into();
1429 }
1430 Ok(finish)
1431}
1432
1433/// A sandbox's build (`MODE=deploy`).
1434pub fn main() -> i32 {
1435 run(false)
1436}
1437
1438/// `g1t-runner deploy`, from a workflow step: the build in the job's own
1439/// checkout, its log in the step's as well. Exits 1 when the build fails,
1440/// after reporting it, so the step fails too.
1441pub fn cli(args: &[String]) -> i32 {
1442 if args.iter().any(|arg| arg == "--help" || arg == "-h") {
1443 println!("{CLI_HELP}");
1444 return 0;
1445 }
1446 run(true)
1447}
1448
1449const CLI_HELP: &str = "g1t-runner deploy: builds the project in this checkout and puts it on g1t.page.
1450
1451Reads DEPLOY_ID and DEPLOY_TOKEN (from POST /repos/{owner}/{repo}/deployments/builds),
1452G1T_API (https://api.g1t.sh), and optionally ROOT_DIR, BUILD_COMMAND, OUTPUT_DIR,
1453BUILD_AS (auto, static, workers or docker), DOCKERFILE, and BUILD_ENV and
1454BUILD_SECRETS (JSON objects). The g1t/deploy action sets them all. A container
1455is built with the docker on PATH.
1456
1457g1t-runner detect prints what the build would find, as key=value lines.";
1458
1459fn run(step_log: bool) -> i32 {
1460 let reporter = match (env("G1T_API"), env("DEPLOY_ID"), env("DEPLOY_TOKEN")) {
1461 (Ok(api), Ok(id), Ok(token)) => Reporter {
1462 base: format!("{}/deployments/jobs/{id}", api.trim_end_matches('/')),
1463 token,
1464 },
1465 _ => {
1466 eprintln!("g1t-runner: G1T_API, DEPLOY_ID and DEPLOY_TOKEN must be set");
1467 return 2;
1468 }
1469 };
1470 let mut secrets: Vec<String> = ["G1T_TOKEN", "DEPLOY_TOKEN"]
1471 .iter()
1472 .filter_map(|name| std::env::var(name).ok())
1473 .filter(|secret| !secret.is_empty())
1474 .collect();
1475 // The repository's build secrets never appear in the log.
1476 if let Ok(Value::Object(build)) = serde_json::from_str::<Value>(&std::env::var("BUILD_SECRETS").unwrap_or_default()) {
1477 secrets.extend(build.values().filter_map(Value::as_str).filter(|v| v.len() >= 4).map(str::to_owned));
1478 }
1479 if let Err(error) = reporter.send("started", json!({})) {
1480 eprintln!("g1t-runner: {error:#}");
1481 return 1;
1482 }
1483 let started = Instant::now();
1484 let mut log = Log { echo: step_log.then(|| secrets.clone()), ..Log::default() };
1485 let outcome = deploy(&reporter, &mut log, &secrets);
1486 let seconds = started.elapsed().as_secs();
1487 if step_log {
1488 let _ = std::fs::remove_dir_all(bundle_dir());
1489 let _ = std::fs::remove_file(adapter_config_path());
1490 let _ = std::fs::remove_dir_all(docker_config_dir());
1491 }
1492 let (sent, failed) = match outcome {
1493 Ok(mut finish) => {
1494 finish["log"] = redact(&log.tail(), &secrets).into();
1495 finish["buildSeconds"] = seconds.into();
1496 (reporter.send("finish", finish), false)
1497 }
1498 Err(error) => {
1499 let message = redact(&format!("{error:#}"), &secrets);
1500 log.line(&format!("The build failed: {message}"));
1501 let sent = reporter.send(
1502 "fail",
1503 json!({ "message": message, "log": redact(&log.tail(), &secrets), "buildSeconds": seconds }),
1504 );
1505 (sent, true)
1506 }
1507 };
1508 match sent {
1509 // A sandbox's build has said how it ended; a step fails with its build.
1510 Ok(answer) if step_log && (failed || answer["ok"] == false || answer["value"] == false) => {
1511 if !failed {
1512 eprintln!("g1t-runner: g1t did not take the build; its deployment says why");
1513 }
1514 1
1515 }
1516 Ok(_) => 0,
1517 Err(error) => {
1518 eprintln!("g1t-runner: {error:#}");
1519 1
1520 }
1521 }
1522}
1523
1524/// `g1t-runner detect`: what a build of the project would find, as
1525/// `key=value` lines for `$GITHUB_OUTPUT`: `kind`, `install`, `build`,
1526/// `output-dir`, and `framework` when one is found. Reads `ROOT_DIR`,
1527/// `BUILD_COMMAND` and `OUTPUT_DIR` as the build does; `--root-dir DIR` is
1528/// the same as `ROOT_DIR`. A framework set up to need a server is `none`,
1529/// with what to change on standard error.
1530pub fn detect_cli(args: &[String]) -> i32 {
1531 let mut root = std::env::var("ROOT_DIR").unwrap_or_default();
1532 let mut rest = args.iter();
1533 while let Some(arg) = rest.next() {
1534 match arg.as_str() {
1535 "--root-dir" => root = rest.next().cloned().unwrap_or_default(),
1536 "--help" | "-h" => {
1537 println!(
1538 "g1t-runner detect [--root-dir DIR]: prints kind, install, build, output-dir and framework (when one is found), and for a container dockerfile and port, as key=value lines. Reads ROOT_DIR, BUILD_COMMAND, OUTPUT_DIR, BUILD_AS (auto, static, workers or docker) and DOCKERFILE as a build does."
1539 );
1540 return 0;
1541 }
1542 other => {
1543 eprintln!("g1t-runner detect: unknown argument {other}");
1544 return 2;
1545 }
1546 }
1547 }
1548 let dir = project_dir_in(&checkout_dir(), &root);
1549 let custom = std::env::var("BUILD_COMMAND").ok();
1550 let output = std::env::var("OUTPUT_DIR").ok();
1551 let build_as = BuildAs::parse(&std::env::var("BUILD_AS").unwrap_or_default());
1552 let dockerfile = std::env::var("DOCKERFILE").ok();
1553 match detect_as(&dir, custom.as_deref(), output.as_deref(), build_as, dockerfile.as_deref()) {
1554 Ok(found) => {
1555 print!("{}", output_lines(&found));
1556 if let Some(problem) = &found.problem {
1557 eprintln!("g1t-runner detect: {problem}");
1558 }
1559 0
1560 }
1561 Err(error) => {
1562 eprintln!("g1t-runner detect: {error:#}");
1563 1
1564 }
1565 }
1566}
1567
1568/// `Detected` as `$GITHUB_OUTPUT` lines; a value on one line, empty when
1569/// there is none. `framework` is left out when none was found, which a
1570/// step's outputs read as empty all the same.
1571fn output_lines(found: &Detected) -> String {
1572 let one_line = |value: Option<&str>| value.unwrap_or_default().replace(['\r', '\n'], " ");
1573 let mut lines = format!(
1574 "kind={}\ninstall={}\nbuild={}\noutput-dir={}\n",
1575 found.kind,
1576 one_line(found.install.as_deref()),
1577 one_line(found.build.as_deref()),
1578 one_line(found.output_dir.as_deref()),
1579 );
1580 if let Some(framework) = found.framework_id() {
1581 lines.push_str(&format!("framework={framework}\n"));
1582 }
1583 // A container's Dockerfile (empty when g1t writes one) and its port.
1584 if let Some(container) = &found.container {
1585 lines.push_str(&format!("dockerfile={}\nport={}\n", one_line(container.dockerfile.as_deref()), container.port));
1586 }
1587 lines
1588}
1589
1590#[cfg(test)]
1591mod tests {
1592 use super::*;
1593
1594 /// A server that answers one request with `status` and `body`.
1595 fn answering(status: &str, body: &'static str) -> String {
1596 use std::io::{Read, Write};
1597 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1598 let base = format!("http://{}", listener.local_addr().unwrap());
1599 let status = status.to_owned();
1600 std::thread::spawn(move || {
1601 let (mut stream, _) = listener.accept().unwrap();
1602 // The whole request first (its headers, then its body by
1603 // length), or the client may still be writing when the answer
1604 // closes the connection.
1605 let mut seen = Vec::new();
1606 let mut buf = [0; 4096];
1607 loop {
1608 let n = stream.read(&mut buf).unwrap_or(0);
1609 if n == 0 {
1610 break;
1611 }
1612 seen.extend_from_slice(&buf[..n]);
1613 let text = String::from_utf8_lossy(&seen);
1614 if let Some(end) = text.find("\r\n\r\n") {
1615 let length = text[..end]
1616 .lines()
1617 .find_map(|l| l.to_ascii_lowercase().strip_prefix("content-length:").map(|v| v.trim().parse::<usize>().unwrap_or(0)))
1618 .unwrap_or(0);
1619 if seen.len() >= end + 4 + length {
1620 break;
1621 }
1622 }
1623 }
1624 let reply = format!("HTTP/1.1 {status}\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}", body.len());
1625 stream.write_all(reply.as_bytes()).unwrap();
1626 });
1627 base
1628 }
1629
1630 #[test]
1631 fn a_refusal_reaches_the_step_with_its_reason() {
1632 let base = answering("409 Conflict", r#"{"ok":false,"error":{"code":"not_enabled","message":"Docker deployments are switched off on this g1t."}}"#);
1633 let answer = Reporter { base, token: "t".into() }.send("registry", json!({})).unwrap();
1634 assert_eq!(answer["ok"], false);
1635 assert_eq!(answer["error"]["code"], "not_enabled");
1636 }
1637
1638 #[test]
1639 fn a_failure_without_an_answer_says_its_status() {
1640 let base = answering("502 Bad Gateway", "upstream down");
1641 let error = Reporter { base, token: "t".into() }.send("registry", json!({})).unwrap_err();
1642 assert!(format!("{error:#}").contains("status code 502"), "{error:#}");
1643 }
1644
1645 #[test]
1646 fn the_upload_session_is_read_in_either_spelling() {
1647 for answer in [
1648 json!({ "jwt": "j", "buckets": [["a"]], "upload_url": "https://u" }),
1649 json!({ "jwt": "j", "uploadUrl": "https://u" }),
1650 ] {
1651 let session: UploadSession = serde_json::from_value(answer).unwrap();
1652 assert_eq!(session.upload_url, "https://u");
1653 }
1654 }
1655
1656 #[test]
1657 fn jsonc_comments_and_trailing_commas_are_dropped() {
1658 let text = r#"{
1659 // a comment
1660 "main": "src/index.ts", /* another */
1661 "vars": { "URL": "https://x.dev//not-a-comment", },
1662 }"#;
1663 let config: WranglerConfig = serde_json::from_str(&strip_jsonc(text)).unwrap();
1664 assert_eq!(config.main.as_deref(), Some("src/index.ts"));
1665 assert_eq!(config.vars["URL"], "https://x.dev//not-a-comment");
1666 }
1667
1668 #[test]
1669 fn unsupported_bindings_are_noticed() {
1670 let config: WranglerConfig =
1671 serde_json::from_str(r#"{ "main": "a.js", "d1_databases": [{ "binding": "DB" }] }"#).unwrap();
1672 assert!(config.rest.contains_key("d1_databases"));
1673 }
1674
1675 #[test]
1676 fn crons_g1t_does_not_run_are_warned_about() {
1677 let config: WranglerConfig =
1678 serde_json::from_str(r#"{ "main": "a.js", "triggers": { "crons": ["*/5 * * * *", "0 3 * * *"] }, "kv_namespaces": [{ "binding": "KV" }] }"#)
1679 .unwrap();
1680 let warnings = config_warnings(&config);
1681 assert_eq!(warnings.len(), 2);
1682 assert!(warnings.iter().any(|w| w.contains("kv_namespaces")));
1683 assert!(warnings.iter().any(|w| w.contains("triggers.crons") && w.contains("2 schedules")));
1684 let none: WranglerConfig = serde_json::from_str(r#"{ "main": "a.js", "triggers": { "crons": [] } }"#).unwrap();
1685 assert!(config_warnings(&none).is_empty());
1686 let toml: WranglerConfig = toml::from_str("main = \"a.js\"
1687[triggers]
1688crons = [\"0 * * * *\"]
1689").unwrap();
1690 assert!(config_warnings(&toml)[0].contains("1 schedule)"));
1691 // A config a build wrote lists every kind of binding, mostly empty.
1692 let written: WranglerConfig = serde_json::from_str(
1693 r#"{ "main": "entry.mjs", "d1_databases": [], "durable_objects": { "bindings": [] }, "triggers": {}, "kv_namespaces": [{ "binding": "SESSION" }] }"#,
1694 )
1695 .unwrap();
1696 assert_eq!(config_warnings(&written).len(), 1);
1697 }
1698
1699 #[test]
1700 fn a_site_is_plain_html_only_when_its_own_files_are_served_unbuilt() {
1701 let project = Path::new("/w/site");
1702 assert_eq!(static_kind(project, project, false), "html");
1703 assert_eq!(static_kind(project, project, true), "static");
1704 assert_eq!(static_kind(project, &project.join("dist"), false), "static");
1705 }
1706
1707 #[test]
1708 fn files_are_typed_by_extension() {
1709 assert_eq!(content_type("/index.HTML"), "text/html");
1710 assert_eq!(content_type("/a/b.woff2"), "font/woff2");
1711 assert_eq!(content_type("/LICENSE"), "application/octet-stream");
1712 }
1713
1714 /// A fresh directory with `files` (path, contents) in it.
1715 fn project(name: &str, files: &[(&str, &str)]) -> PathBuf {
1716 let dir = std::env::temp_dir().join(format!("g1t-detect-{name}-{}", std::process::id()));
1717 let _ = std::fs::remove_dir_all(&dir);
1718 for (path, text) in files {
1719 let path = dir.join(path);
1720 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1721 std::fs::write(path, text).unwrap();
1722 }
1723 std::fs::create_dir_all(&dir).unwrap();
1724 dir
1725 }
1726
1727 #[test]
1728 fn detect_finds_a_workers_project_and_how_it_builds() {
1729 let dir = project(
1730 "workers",
1731 &[
1732 ("wrangler.jsonc", r#"{ "main": "src/index.ts", "assets": { "directory": "./public" }, }"#),
1733 ("package.json", "{}"),
1734 ("pnpm-lock.yaml", ""),
1735 ],
1736 );
1737 let found = detect(&dir, None, None).unwrap();
1738 assert_eq!(found.kind, "workers");
1739 assert_eq!(found.install.as_deref(), Some("corepack enable && pnpm install --frozen-lockfile"));
1740 assert_eq!(found.framework, None);
1741 assert_eq!(found.build.as_deref(), Some("npx --yes wrangler@4 deploy --dry-run"));
1742 assert_eq!(found.output_dir.as_deref(), Some("./public"));
1743 // The project's own build command runs first.
1744 let custom = detect(&dir, Some("npm run codegen"), None).unwrap();
1745 assert_eq!(custom.build.as_deref(), Some("npm run codegen && npx --yes wrangler@4 deploy --dry-run"));
1746 }
1747
1748 #[test]
1749 fn detect_finds_a_static_build_and_a_plain_site() {
1750 let built = project("static", &[("package.json", r#"{ "scripts": { "build": "vite build" } }"#), ("package-lock.json", "{}")]);
1751 let found = detect(&built, None, None).unwrap();
1752 assert_eq!(found.kind, "static");
1753 assert_eq!(found.install.as_deref(), Some("npm ci"));
1754 assert_eq!(found.build.as_deref(), Some("npm run build"));
1755 assert_eq!(found.output_dir, None, "its output is found once it has built");
1756 assert_eq!(found.framework, None, "a build script alone names no framework");
1757 std::fs::create_dir_all(built.join("dist")).unwrap();
1758 std::fs::write(built.join("dist/index.html"), "<h1>hi</h1>").unwrap();
1759 assert_eq!(detect(&built, None, None).unwrap().output_dir.as_deref(), Some("dist"));
1760 assert_eq!(detect(&built, None, Some("/site/")).unwrap().output_dir.as_deref(), Some("site"));
1761
1762 let html = project("html", &[("index.html", "<h1>hi</h1>")]);
1763 let found = detect(&html, None, None).unwrap();
1764 assert_eq!((found.kind, found.install, found.build, found.output_dir.as_deref()), ("html", None, None, Some(".")));
1765 // A `public` folder alone, without an index.html, is not a site.
1766 let empty = project("empty", &[("public/robots.txt", ""), ("README.md", "")]);
1767 assert_eq!(detect(&empty, None, None).unwrap().kind, "none");
1768 }
1769
1770 #[test]
1771 fn detect_prints_lines_for_github_output() {
1772 let mut found = Detected {
1773 kind: "static",
1774 framework: None,
1775 install: Some("npm ci".into()),
1776 build: Some("a\nb".into()),
1777 output_dir: None,
1778 problem: None,
1779 pre_build: None,
1780 container: None,
1781 fallback: None,
1782 reason: None,
1783 };
1784 assert_eq!(output_lines(&found), "kind=static\ninstall=npm ci\nbuild=a b\noutput-dir=\n");
1785 let vite = project("lines", &[("package.json", r#"{ "devDependencies": { "vite": "7" } }"#)]);
1786 found.framework = frameworks::detect(&vite, false);
1787 assert!(output_lines(&found).ends_with("output-dir=\nframework=vite\n"));
1788 }
1789
1790 /// One project of the table below: its files, the project's own
1791 /// settings, and what detect should say.
1792 struct Case {
1793 name: &'static str,
1794 files: &'static [(&'static str, &'static str)],
1795 custom_build: Option<&'static str>,
1796 chosen_output: Option<&'static str>,
1797 kind: &'static str,
1798 framework: Option<&'static str>,
1799 build: Option<&'static str>,
1800 output_dir: Option<&'static str>,
1801 /// A phrase the problem says, if there is one.
1802 problem: Option<&'static str>,
1803 }
1804
1805 const CASE: Case = Case {
1806 name: "",
1807 files: &[],
1808 custom_build: None,
1809 chosen_output: None,
1810 kind: "static",
1811 framework: None,
1812 build: Some("npm run build"),
1813 output_dir: None,
1814 problem: None,
1815 };
1816
1817 #[test]
1818 fn detect_knows_each_framework() {
1819 let cases = [
1820 Case {
1821 name: "vite",
1822 files: &[("package.json", r#"{ "devDependencies": { "vite": "7" } }"#)],
1823 framework: Some("vite"),
1824 build: Some("npx vite build"),
1825 output_dir: Some("dist"),
1826 ..CASE
1827 },
1828 Case {
1829 name: "vite-react",
1830 files: &[
1831 ("package.json", r#"{ "scripts": { "build": "tsc && vite build" }, "dependencies": { "react": "19" }, "devDependencies": { "vite": "7", "@vitejs/plugin-react": "5" } }"#),
1832 ("vite.config.ts", "export default defineConfig({ plugins: [react()], build: { outDir: 'site' } })"),
1833 ],
1834 framework: Some("vite-react"),
1835 output_dir: Some("site"),
1836 ..CASE
1837 },
1838 Case {
1839 name: "vite-vue",
1840 files: &[("package.json", r#"{ "scripts": { "build": "vite build" }, "devDependencies": { "vite": "7", "@vitejs/plugin-vue": "6" } }"#)],
1841 framework: Some("vite-vue"),
1842 output_dir: Some("dist"),
1843 ..CASE
1844 },
1845 Case {
1846 name: "vite-svelte",
1847 files: &[("package.json", r#"{ "devDependencies": { "vite": "7", "@sveltejs/vite-plugin-svelte": "6" } }"#)],
1848 framework: Some("vite-svelte"),
1849 build: Some("npx vite build"),
1850 output_dir: Some("dist"),
1851 ..CASE
1852 },
1853 Case {
1854 name: "vite-solid",
1855 files: &[("package.json", r#"{ "devDependencies": { "vite": "7", "vite-plugin-solid": "2" } }"#)],
1856 framework: Some("vite-solid"),
1857 build: Some("npx vite build"),
1858 output_dir: Some("dist"),
1859 ..CASE
1860 },
1861 Case {
1862 name: "vite-cloudflare",
1863 files: &[
1864 ("package.json", r#"{ "scripts": { "build": "vite build" }, "devDependencies": { "vite": "7", "@cloudflare/vite-plugin": "1" } }"#),
1865 ("wrangler.jsonc", r#"{ "main": "worker/index.ts" }"#),
1866 ],
1867 kind: "workers",
1868 framework: Some("vite"),
1869 build: Some("npm run build && npx --yes wrangler@4 deploy --dry-run"),
1870 ..CASE
1871 },
1872 Case {
1873 name: "astro",
1874 files: &[("package.json", r#"{ "scripts": { "build": "astro build" }, "dependencies": { "astro": "5" } }"#), ("astro.config.mjs", "export default defineConfig({ site: 'https://x.dev' })")],
1875 framework: Some("astro"),
1876 output_dir: Some("dist"),
1877 ..CASE
1878 },
1879 Case {
1880 name: "astro-server",
1881 files: &[("package.json", r#"{ "dependencies": { "astro": "5", "@astrojs/node": "9" } }"#), ("astro.config.mjs", "export default { output: \"server\" }")],
1882 kind: "container",
1883 framework: Some("astro"),
1884 build: Some(GENERATED),
1885 ..CASE
1886 },
1887 Case {
1888 name: "astro-cloudflare",
1889 files: &[("package.json", r#"{ "scripts": { "build": "astro build" }, "dependencies": { "astro": "5", "@astrojs/cloudflare": "12" } }"#)],
1890 kind: "workers",
1891 framework: Some("astro"),
1892 ..CASE
1893 },
1894 Case {
1895 name: "astro-cloudflare-folder",
1896 files: &[("package.json", r#"{ "scripts": { "build": "astro build" }, "dependencies": { "astro": "5", "@astrojs/cloudflare": "12" } }"#)],
1897 chosen_output: Some("dist"),
1898 framework: Some("astro"),
1899 output_dir: Some("dist"),
1900 ..CASE
1901 },
1902 Case {
1903 name: "next-export",
1904 files: &[("package.json", r#"{ "scripts": { "build": "next build" }, "dependencies": { "next": "16" } }"#), ("next.config.ts", "const config: NextConfig = {\n output: 'export',\n};")],
1905 framework: Some("nextjs"),
1906 output_dir: Some("out"),
1907 ..CASE
1908 },
1909 Case {
1910 name: "next-server",
1911 files: &[("package.json", r#"{ "scripts": { "build": "next build" }, "dependencies": { "next": "16" } }"#), ("next.config.mjs", "export default {}")],
1912 kind: "container",
1913 framework: Some("nextjs"),
1914 build: Some(GENERATED),
1915 ..CASE
1916 },
1917 Case {
1918 name: "next-server-own-folder",
1919 files: &[("package.json", r#"{ "scripts": { "build": "next build" }, "dependencies": { "next": "16" } }"#)],
1920 chosen_output: Some("out"),
1921 framework: Some("nextjs"),
1922 output_dir: Some("out"),
1923 ..CASE
1924 },
1925 Case {
1926 name: "opennext",
1927 files: &[
1928 ("package.json", r#"{ "scripts": { "build": "next build" }, "dependencies": { "next": "16", "@opennextjs/cloudflare": "1" } }"#),
1929 ("wrangler.jsonc", r#"{ "main": ".open-next/worker.js", "assets": { "directory": ".open-next/assets", "binding": "ASSETS" } }"#),
1930 ],
1931 kind: "workers",
1932 framework: Some("nextjs"),
1933 build: Some("npx opennextjs-cloudflare build && npx --yes wrangler@4 deploy --dry-run"),
1934 output_dir: Some(".open-next/assets"),
1935 ..CASE
1936 },
1937 Case {
1938 name: "opennext-no-config",
1939 files: &[("package.json", r#"{ "dependencies": { "next": "16", "@opennextjs/cloudflare": "1" } }"#)],
1940 kind: "none",
1941 framework: Some("nextjs"),
1942 build: None,
1943 problem: Some("`.open-next/worker.js`"),
1944 ..CASE
1945 },
1946 Case {
1947 name: "sveltekit-static",
1948 files: &[
1949 ("package.json", r#"{ "scripts": { "build": "vite build" }, "devDependencies": { "@sveltejs/kit": "2", "@sveltejs/adapter-static": "3", "vite": "7" } }"#),
1950 ("svelte.config.js", "import adapter from '@sveltejs/adapter-static';\nexport default { kit: { adapter: adapter() } };"),
1951 ],
1952 framework: Some("sveltekit"),
1953 output_dir: Some("build"),
1954 ..CASE
1955 },
1956 Case {
1957 name: "sveltekit-3-static",
1958 files: &[
1959 ("package.json", r#"{ "scripts": { "build": "vite build" }, "devDependencies": { "@sveltejs/kit": "3", "@sveltejs/adapter-static": "4", "@sveltejs/adapter-cloudflare": "8" } }"#),
1960 ("vite.config.js", "import adapter from '@sveltejs/adapter-static';\nexport default defineConfig({ plugins: [sveltekit({ adapter: adapter({ pages: 'www' }) })] });"),
1961 ],
1962 framework: Some("sveltekit"),
1963 output_dir: Some("www"),
1964 ..CASE
1965 },
1966 Case {
1967 name: "sveltekit-cloudflare",
1968 files: &[("package.json", r#"{ "scripts": { "build": "vite build" }, "devDependencies": { "@sveltejs/kit": "2", "@sveltejs/adapter-cloudflare": "7" } }"#)],
1969 kind: "workers",
1970 framework: Some("sveltekit"),
1971 ..CASE
1972 },
1973 Case {
1974 name: "sveltekit-auto",
1975 files: &[("package.json", r#"{ "scripts": { "build": "vite build" }, "devDependencies": { "@sveltejs/kit": "2", "@sveltejs/adapter-auto": "6" } }"#)],
1976 kind: "none",
1977 framework: Some("sveltekit"),
1978 build: None,
1979 problem: Some("Or add a `Dockerfile`"),
1980 ..CASE
1981 },
1982 Case {
1983 name: "nuxt",
1984 files: &[("package.json", r#"{ "scripts": { "build": "nuxt build", "generate": "nuxt generate" }, "dependencies": { "nuxt": "4" } }"#)],
1985 framework: Some("nuxt"),
1986 build: Some("npm run generate"),
1987 output_dir: Some(".output/public"),
1988 ..CASE
1989 },
1990 Case {
1991 name: "nuxt-no-generate",
1992 files: &[("package.json", r#"{ "scripts": { "build": "nuxt build" }, "dependencies": { "nuxt": "4" } }"#)],
1993 framework: Some("nuxt"),
1994 build: Some("npx nuxi generate"),
1995 output_dir: Some(".output/public"),
1996 ..CASE
1997 },
1998 Case {
1999 name: "react-router-spa",
2000 files: &[
2001 ("package.json", r#"{ "scripts": { "build": "react-router build" }, "devDependencies": { "@react-router/dev": "7", "vite": "7" } }"#),
2002 ("react-router.config.ts", "export default {\n // Server-side render by default\n ssr: false,\n} satisfies Config;"),
2003 ],
2004 framework: Some("react-router"),
2005 output_dir: Some("build/client"),
2006 ..CASE
2007 },
2008 Case {
2009 name: "react-router-ssr",
2010 files: &[("package.json", r#"{ "devDependencies": { "@react-router/dev": "7" } }"#), ("react-router.config.ts", "export default { ssr: true }")],
2011 kind: "container",
2012 framework: Some("react-router"),
2013 build: Some(GENERATED),
2014 ..CASE
2015 },
2016 Case {
2017 name: "remix-spa",
2018 files: &[
2019 ("package.json", r#"{ "scripts": { "build": "remix vite:build" }, "devDependencies": { "@remix-run/dev": "2", "vite": "5" } }"#),
2020 ("vite.config.ts", "export default defineConfig({ plugins: [remix({ ssr: false })] })"),
2021 ],
2022 framework: Some("remix"),
2023 output_dir: Some("build/client"),
2024 ..CASE
2025 },
2026 Case {
2027 name: "gatsby",
2028 files: &[("package.json", r#"{ "scripts": { "build": "gatsby build" }, "dependencies": { "gatsby": "5" } }"#)],
2029 framework: Some("gatsby"),
2030 output_dir: Some("public"),
2031 ..CASE
2032 },
2033 Case {
2034 name: "docusaurus",
2035 files: &[("package.json", r#"{ "scripts": { "build": "docusaurus build" }, "dependencies": { "@docusaurus/core": "3" } }"#)],
2036 framework: Some("docusaurus"),
2037 output_dir: Some("build"),
2038 ..CASE
2039 },
2040 Case {
2041 name: "eleventy",
2042 files: &[("package.json", r#"{ "devDependencies": { "@11ty/eleventy": "3" } }"#)],
2043 framework: Some("eleventy"),
2044 build: Some("npx @11ty/eleventy"),
2045 output_dir: Some("_site"),
2046 ..CASE
2047 },
2048 Case {
2049 name: "angular",
2050 files: &[
2051 ("package.json", r#"{ "scripts": { "build": "ng build" }, "dependencies": { "@angular/core": "20" } }"#),
2052 ("angular.json", r#"{ "projects": { "shop": { "projectType": "application", "architect": { "build": { "builder": "@angular/build:application", "options": { "outputPath": "dist/shop" } } } } } }"#),
2053 ],
2054 framework: Some("angular"),
2055 output_dir: Some("dist/shop/browser"),
2056 ..CASE
2057 },
2058 Case {
2059 name: "angular-default",
2060 files: &[
2061 ("package.json", r#"{ "dependencies": { "@angular/core": "20" } }"#),
2062 ("angular.json", r#"{ "projects": { "app": { "projectType": "application", "architect": { "build": { "builder": "@angular/build:application", "options": {} } } } } }"#),
2063 ],
2064 framework: Some("angular"),
2065 build: Some("npx ng build"),
2066 output_dir: Some("dist/app/browser"),
2067 ..CASE
2068 },
2069 Case {
2070 name: "hugo",
2071 files: &[("hugo.toml", "baseURL = 'https://example.org/'\ntitle = 'Site'\n")],
2072 framework: Some("hugo"),
2073 build: Some(frameworks_hugo()),
2074 output_dir: Some("public"),
2075 ..CASE
2076 },
2077 Case {
2078 name: "hugo-config-toml",
2079 files: &[("config.toml", "baseURL = \"https://example.org/\"\npublishDir = \"site\"\n")],
2080 framework: Some("hugo"),
2081 build: Some(frameworks_hugo()),
2082 output_dir: Some("site"),
2083 ..CASE
2084 },
2085 Case {
2086 name: "not-hugo",
2087 files: &[("config.toml", "base_url = \"https://example.org\"\n"), ("index.html", "")],
2088 kind: "html",
2089 build: None,
2090 output_dir: Some("."),
2091 ..CASE
2092 },
2093 Case {
2094 name: "jekyll",
2095 files: &[("_config.yml", "title: Blog\n"), ("Gemfile", "gem 'jekyll'\n")],
2096 framework: Some("jekyll"),
2097 build: Some("bundle exec jekyll build"),
2098 output_dir: Some("_site"),
2099 ..CASE
2100 },
2101 Case {
2102 name: "mdbook",
2103 files: &[("book.toml", "[book]\ntitle = \"Guide\"\n")],
2104 framework: Some("mdbook"),
2105 build: Some(frameworks_mdbook()),
2106 output_dir: Some("book"),
2107 ..CASE
2108 },
2109 Case {
2110 name: "mdbook-build-dir",
2111 files: &[("book.toml", "[book]\ntitle = \"Guide\"\n[build]\nbuild-dir = \"site\"\n")],
2112 framework: Some("mdbook"),
2113 build: Some(frameworks_mdbook()),
2114 output_dir: Some("site"),
2115 ..CASE
2116 },
2117 // The project's own settings win.
2118 Case {
2119 name: "own-settings",
2120 files: &[("package.json", r#"{ "scripts": { "build": "vite build" }, "devDependencies": { "vite": "7" } }"#)],
2121 custom_build: Some("make site"),
2122 chosen_output: Some("www"),
2123 framework: Some("vite"),
2124 build: Some("make site"),
2125 output_dir: Some("www"),
2126 ..CASE
2127 },
2128 ];
2129 for case in cases {
2130 let dir = project(&format!("fw-{}", case.name), case.files);
2131 let found = detect(&dir, case.custom_build, case.chosen_output).unwrap();
2132 let name = case.name;
2133 assert_eq!(found.kind, case.kind, "{name}: kind");
2134 assert_eq!(found.framework.as_ref().map(|f| f.id), case.framework, "{name}: framework");
2135 assert_eq!(found.build.as_deref(), case.build, "{name}: build");
2136 assert_eq!(found.output_dir.as_deref(), case.output_dir, "{name}: output-dir");
2137 match case.problem {
2138 Some(phrase) => assert!(found.problem.as_deref().is_some_and(|p| p.contains(phrase)), "{name}: {:?}", found.problem),
2139 None => assert_eq!(found.problem, None, "{name}: problem"),
2140 }
2141 let _ = std::fs::remove_dir_all(&dir);
2142 }
2143 }
2144
2145 #[test]
2146 fn build_as_and_dockerfiles_decide_in_order() {
2147 let detect_with = |dir: &Path, build_as: BuildAs, dockerfile: Option<&str>| detect_as(dir, None, None, build_as, dockerfile).unwrap();
2148 // A framework that builds a static site stays one beside a
2149 // Dockerfile that only serves its files: it costs nothing while no
2150 // one visits.
2151 let vite = project("p-vite", &[("package.json", r#"{ "devDependencies": { "vite": "7" } }"#), ("Dockerfile", "FROM nginx\nEXPOSE 80\n")]);
2152 let found = detect_with(&vite, BuildAs::Auto, None);
2153 assert_eq!(found.kind, "static");
2154 assert!(found.reason.unwrap().contains("only serves files with `nginx`"));
2155 // Beside a Dockerfile that runs a server of its own, the server:
2156 // the site alone would leave its back end out.
2157 let full = project(
2158 "p-vite-server",
2159 &[
2160 ("package.json", r#"{ "devDependencies": { "vite": "7" } }"#),
2161 (
2162 "Dockerfile",
2163 "FROM node:22-slim AS build\nRUN npm ci && npm run build\nFROM node:22-slim\nCOPY --from=build /app /app\nENV PORT=3000\nCMD [\"node\", \"server.js\"]\n",
2164 ),
2165 ],
2166 );
2167 let found = detect_with(&full, BuildAs::Auto, None);
2168 assert_eq!((found.kind, found.framework_id()), ("container", Some("vite")));
2169 assert_eq!(found.container.as_ref().unwrap().port, 3000);
2170 assert_eq!(found.fallback.as_ref().map(|site| site.kind), Some("static"));
2171 assert!(found.reason.unwrap().contains("not as the static site it also has: its Dockerfile starts a server of its own (`CMD`)"));
2172 // Unless the project says Docker.
2173 let found = detect_with(&vite, BuildAs::Docker, None);
2174 assert_eq!((found.kind, found.framework_id()), ("container", Some("vite")));
2175 let container = found.container.unwrap();
2176 assert_eq!((container.dockerfile.as_deref(), container.port), (Some("Dockerfile"), 80));
2177 // A Wrangler config wins in Auto, and not when it says Static site.
2178 let workers = project("p-workers", &[("wrangler.jsonc", r#"{ "assets": { "directory": "public" } }"#), ("index.html", "")]);
2179 assert_eq!(detect_with(&workers, BuildAs::Auto, None).kind, "workers");
2180 assert_eq!(detect_with(&workers, BuildAs::Static, None).kind, "html");
2181 // Workers, without a config or adapter, says what is missing.
2182 let html = project("p-html", &[("index.html", "<h1>hi</h1>")]);
2183 let found = detect_with(&html, BuildAs::Workers, None);
2184 assert_eq!(found.kind, "none");
2185 assert!(found.problem.unwrap().contains("no Wrangler config"));
2186 // No framework, a site and a Dockerfile that runs a server: a
2187 // container, built as the site it is without one where Docker
2188 // deployments are switched off.
2189 let both = project("p-both", &[("index.html", ""), ("Containerfile", "FROM python:3.12-slim\nEXPOSE 5000\n")]);
2190 let found = detect_with(&both, BuildAs::Auto, None);
2191 assert_eq!((found.kind, found.framework_id()), ("container", Some("dockerfile")));
2192 assert_eq!(found.container.as_ref().unwrap().port, 5000);
2193 assert_eq!(found.fallback.as_ref().map(|site| site.kind), Some("html"));
2194 // A site and a Dockerfile that says neither how it starts nor
2195 // where it listens: the site.
2196 let bare = project("p-bare", &[("index.html", ""), ("Dockerfile", "FROM nginx")]);
2197 let found = detect_with(&bare, BuildAs::Auto, None);
2198 assert_eq!(found.kind, "html");
2199 assert!(found.reason.unwrap().contains("neither starts a server"));
2200 // A Dockerfile and nothing else is a container, whatever it says.
2201 let alone = project("p-alone", &[("Dockerfile", "FROM nginx")]);
2202 assert_eq!(detect_with(&alone, BuildAs::Auto, None).kind, "container");
2203 // A Dockerfile at a path of the project's choosing.
2204 let chosen = project("p-chosen", &[("ops/web.Dockerfile", "FROM node\nEXPOSE 3000")]);
2205 assert_eq!(detect_with(&chosen, BuildAs::Auto, None).kind, "none");
2206 let found = detect_with(&chosen, BuildAs::Auto, Some("ops/web.Dockerfile"));
2207 assert_eq!((found.kind, found.container.unwrap().port), ("container", 3000));
2208 assert!(detect_as(&chosen, None, None, BuildAs::Auto, Some("nope")).is_err());
2209 // A server g1t recognises gets a Dockerfile written for it.
2210 let express = project("p-express", &[("package.json", r#"{ "scripts": { "start": "node index.js" }, "dependencies": { "express": "5" } }"#)]);
2211 let found = detect_with(&express, BuildAs::Auto, None);
2212 assert_eq!((found.kind, found.framework_id(), found.install.as_deref()), ("container", Some("node"), None));
2213 assert!(found.fallback.is_none());
2214 assert!(output_lines(&found).ends_with("framework=node\ndockerfile=\nport=8080\n"));
2215 // Nothing at all says to add a Dockerfile.
2216 let nothing = project("p-nothing", &[("README.md", "")]);
2217 let found = detect_with(&nothing, BuildAs::Auto, None);
2218 assert_eq!(found.kind, "none");
2219 assert!(found.problem.as_deref().unwrap().starts_with("found nothing to serve"));
2220 assert!(found.problem.unwrap().contains("Add a `Dockerfile`"));
2221 let found = detect_with(&nothing, BuildAs::Docker, None);
2222 assert!(found.problem.unwrap().contains("**Build as** is Docker"));
2223 // A framework set up for a server, with a Dockerfile of its own.
2224 let next = project("p-next", &[("package.json", r#"{ "dependencies": { "next": "16" } }"#), ("Dockerfile", "FROM node")]);
2225 let found = detect_with(&next, BuildAs::Auto, None);
2226 assert_eq!((found.kind, found.framework_id()), ("container", Some("nextjs")));
2227 assert_eq!(found.container.unwrap().dockerfile.as_deref(), Some("Dockerfile"));
2228 // OpenNext's adapter means Workers: it is not run as a server.
2229 let opennext = project("p-opennext", &[("package.json", r#"{ "dependencies": { "next": "16", "@opennextjs/cloudflare": "1" } }"#)]);
2230 assert_eq!(detect_with(&opennext, BuildAs::Auto, None).kind, "none");
2231 // A server g1t recognises beside a folder it could serve: the
2232 // server, saying so.
2233 let public = project(
2234 "p-public",
2235 &[("package.json", r#"{ "scripts": { "start": "node index.js" }, "dependencies": { "express": "5" } }"#), ("public/index.html", "")],
2236 );
2237 let found = detect_with(&public, BuildAs::Auto, None);
2238 assert_eq!(found.kind, "container");
2239 assert!(found.reason.unwrap().contains("as a Node.js server, not as a site of `public`"));
2240 for dir in [vite, full, workers, html, both, bare, alone, chosen, express, nothing, next, opennext, public] {
2241 let _ = std::fs::remove_dir_all(dir);
2242 }
2243 }
2244
2245 #[test]
2246 fn the_pushed_digest_is_the_one_in_the_apps_repository() {
2247 let digest = "a".repeat(64);
2248 let lines = format!("docker.io/x@sha256:{digest}\nregistry.cloudflare.com/acc/other@sha256:{digest}\nregistry.cloudflare.com/acc/g1t-web@sha256:{digest}\n");
2249 assert_eq!(
2250 pushed_digest(&lines, "registry.cloudflare.com", "acc/g1t-web"),
2251 Some(format!("registry.cloudflare.com/acc/g1t-web@sha256:{digest}"))
2252 );
2253 assert_eq!(pushed_digest("registry.cloudflare.com/acc/g1t-web@sha256:short", "registry.cloudflare.com", "acc/g1t-web"), None);
2254 let grant: RegistryGrant = serde_json::from_value(json!({
2255 "registry": "registry.cloudflare.com", "repository": "acc/g1t-web", "tag": "dpl_1",
2256 "username": "u", "password": "p", "max_image_bytes": 2_000_000_000u64, "instance_type": "lite"
2257 }))
2258 .unwrap();
2259 assert_eq!((grant.max_image_bytes, grant.instance_type.as_deref()), (2_000_000_000, Some("lite")));
2260 assert_eq!(size_text(1_450_000_000), "1.4 GB");
2261 }
2262
2263 /// What a container g1t writes the Dockerfile for is built with.
2264 const GENERATED: &str = "docker build -f Dockerfile (written by g1t) .";
2265
2266 fn frameworks_hugo() -> &'static str {
2267 "if command -v hugo >/dev/null 2>&1; then hugo --minify; else npx --yes hugo-extended@0.166.0 --minify; fi"
2268 }
2269
2270 fn frameworks_mdbook() -> &'static str {
2271 "if ! command -v mdbook >/dev/null 2>&1; then cargo install mdbook --version 0.5.4 --locked --quiet; fi && mdbook build"
2272 }
2273
2274 #[test]
2275 fn jekyll_installs_its_gems_after_the_packages() {
2276 let dir = project("jekyll-install", &[("_config.yml", ""), ("Gemfile", ""), ("package.json", "{}")]);
2277 let found = detect(&dir, None, None).unwrap();
2278 assert_eq!(found.install.as_deref(), Some("npm install && bundle config set --local path vendor/bundle && bundle install"));
2279 }
2280
2281 #[test]
2282 fn an_adapters_worker_is_found_once_built_and_never_served() {
2283 let dir = project(
2284 "adapter",
2285 &[
2286 ("dist/_worker.js/index.js", "export default {}"),
2287 ("dist/_routes.json", "{}"),
2288 ("dist/.assetsignore", "_worker.js\nsecret.txt\n"),
2289 ("dist/secret.txt", "no"),
2290 ("dist/index.html", "<h1>hi</h1>"),
2291 ("dist/about/_worker.js", "a page's own file"),
2292 ("kit/.svelte-kit/cloudflare/_worker.js", "export default {}"),
2293 ],
2294 );
2295 let astro = Adapter { entry: "dist/_worker.js", assets: "dist" };
2296 assert_eq!(adapter_entry(&dir, &astro), Some(dir.join("dist/_worker.js/index.js")));
2297 let kit = Adapter { entry: ".svelte-kit/cloudflare/_worker.js", assets: ".svelte-kit/cloudflare" };
2298 assert_eq!(adapter_entry(&dir.join("kit"), &kit), Some(dir.join("kit/.svelte-kit/cloudflare/_worker.js")));
2299 assert_eq!(adapter_entry(&dir.join("missing"), &kit), None);
2300 let mut served: Vec<String> = collect_site(&dir.join("dist"), false).unwrap().into_iter().map(|a| a.path).collect();
2301 served.sort();
2302 assert_eq!(served, ["/about/_worker.js", "/index.html"]);
2303 }
2304
2305 #[test]
2306 fn a_config_the_build_wrote_is_read_in_place_of_the_projects() {
2307 let dir = project(
2308 "redirected",
2309 &[
2310 ("wrangler.jsonc", r#"{ "main": "worker/index.ts" }"#),
2311 (".wrangler/deploy/config.json", r#"{ "configPath": "../../dist/app/wrangler.json" }"#),
2312 ("dist/app/wrangler.json", r#"{ "main": "index.js", "assets": { "directory": "../client" } }"#),
2313 ],
2314 );
2315 let (config, base) = redirected_config(&dir).unwrap().unwrap();
2316 assert_eq!(config.main.as_deref(), Some("index.js"));
2317 assert_eq!(base, canonical(&dir.join("dist/app")).unwrap());
2318 assert!(base.join(config.assets.unwrap().directory.unwrap()).ends_with("dist/app/../client"));
2319 assert!(redirected_config(&project("not-redirected", &[])).unwrap().is_none());
2320 }
2321
2322 #[test]
2323 fn the_root_directory_stays_inside_the_checkout() {
2324 let base = Path::new("/w/repo");
2325 assert_eq!(project_dir_in(base, ""), base);
2326 assert_eq!(project_dir_in(base, "."), base);
2327 assert_eq!(project_dir_in(base, "/apps/site/"), base.join("apps/site"));
2328 assert_eq!(project_dir_in(base, "../etc"), base);
2329 assert_eq!(project_dir_in(base, "apps/../../x"), base);
2330 }
2331
2332 #[test]
2333 fn a_step_prints_the_log_redacted() {
2334 let mut log = Log { echo: Some(vec!["hunter22".to_owned()]), ..Log::default() };
2335 log.line("token hunter22 used");
2336 assert!(log.text.contains("hunter22"), "the deployment's log is redacted when it is sent");
2337 assert_eq!(wrangler_build(Some(Path::new("/tmp/b")), None), "npx --yes wrangler@4 deploy --dry-run --outdir /tmp/b");
2338 assert_eq!(
2339 wrangler_build(Some(Path::new("/tmp/b")), Some(Path::new("/tmp/w.json"))),
2340 "npx --yes wrangler@4 deploy --dry-run --config /tmp/w.json --outdir /tmp/b"
2341 );
2342 }
2343}